From 80de923cab7fd684f83bf09908e1490e529ca39d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 02:42:25 +0000 Subject: [PATCH 1/6] perf: replace nested Row/Column with custom NoteComposeLayout Create a single-pass custom Layout for NoteCompose that replaces the nested Row > Column > Column structure with direct constraint calculation using known fixed dimensions (55dp author column, 10dp gap, 12dp padding). This eliminates Row's two-pass measurement (measure author first, then content with remaining width), reduces content Column children from ~6 to ~3, and pre-computes all pixel dimensions once. https://claude.ai/code/session_01Fam5VHNaNkKBBafg1gRfFS --- .../amethyst/ui/layouts/NoteComposeLayout.kt | 207 ++++++++++++++++++ .../amethyst/ui/note/NoteCompose.kt | 121 +++++----- 2 files changed, 274 insertions(+), 54 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt new file mode 100644 index 000000000..f44dcf2bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.layouts + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.theme.Size55dp + +private val ZeroConstraints = Constraints.fixed(0, 0) + +@Immutable +private data class NoteLayoutPx( + val authorWidth: Int, + val authorContentGap: Int, + val authorBadgeGap: Int, + val contentSpacer: Int, + val padStart: Int, + val padEnd: Int, + val padTop: Int, +) + +/** + * Custom single-pass layout for note items that replaces nested Row/Column + * measurements with direct constraint calculation using known fixed dimensions. + * + * Layout structure (when [showAuthorColumn] is true): + * ``` + * ┌──────────────────────────────────────────────┐ + * │ padding (start=12, end=12, top=10) │ + * │ ┌──────┐ 10dp ┌────────────────────────────┐ │ + * │ │author│ gap │ firstRow │ │ + * │ │ 55dp │ │ secondRow (optional) │ │ + * │ │ │ │ 4dp spacer (optional) │ │ + * │ ├──────┤ 5dp │ noteContent │ │ + * │ │relay │ gap │ │ │ + * │ │badges│ │ │ │ + * │ └──────┘ └────────────────────────────┘ │ + * ├───────────────────────────────────────────────┤ + * │ reactionsRow (full width, no side padding) │ + * └───────────────────────────────────────────────┘ + * ``` + * + * Performance benefits over nested Row/Column: + * - Eliminates Row's two-pass measurement (measure author, then content with remaining width) + * - Uses pre-computed pixel dimensions for author column width (55dp) and gaps + * - Reduces content Column children from ~6 to ~3 + * - Single measure pass positions all sections with known constraints + * + * @param addPadding Whether to add standard note padding (12dp sides, 10dp top) + * @param showAuthorColumn Whether to reserve space for the author picture column + * @param showSecondRow Whether to measure and place the second info row + * @param showContentSpacer Whether to add a 4dp spacer between header rows and content + */ +@Composable +fun NoteComposeLayout( + modifier: Modifier = Modifier, + addPadding: Boolean = true, + showAuthorColumn: Boolean = true, + showSecondRow: Boolean = false, + showContentSpacer: Boolean = true, + authorPicture: @Composable () -> Unit, + relayBadges: @Composable () -> Unit, + firstRow: @Composable () -> Unit, + secondRow: @Composable () -> Unit, + noteContent: @Composable () -> Unit, + reactionsRow: @Composable () -> Unit, +) { + val density = LocalDensity.current + val px = + remember(density) { + with(density) { + NoteLayoutPx( + authorWidth = Size55dp.roundToPx(), + authorContentGap = 10.dp.roundToPx(), + authorBadgeGap = 5.dp.roundToPx(), + contentSpacer = 4.dp.roundToPx(), + padStart = 12.dp.roundToPx(), + padEnd = 12.dp.roundToPx(), + padTop = 10.dp.roundToPx(), + ) + } + } + + Layout( + content = { + // Slot 0: Author picture (55x55 area) + Box { authorPicture() } + // Slot 1: Relay badges (55dp wide, below author) + Box { relayBadges() } + // Slot 2: First row (author name, time, options) + Box { firstRow() } + // Slot 3: Second row (NIP05, location, PoW) + Box { secondRow() } + // Slot 4: Note content (event-specific + zap splits + approval) + Column(Modifier.fillMaxWidth()) { noteContent() } + // Slot 5: Reactions row (full width) + Column(Modifier.fillMaxWidth()) { reactionsRow() } + }, + modifier = modifier, + ) { measurables, constraints -> + val maxWidth = constraints.maxWidth + + // Compute padding offsets + val padStart = if (addPadding) px.padStart else 0 + val padEnd = if (addPadding) px.padEnd else 0 + val padTop = if (addPadding) px.padTop else 0 + + // Compute column widths from known dimensions + val innerWidth = maxWidth - padStart - padEnd + val authorW = if (showAuthorColumn) px.authorWidth else 0 + val gap = if (showAuthorColumn) px.authorContentGap else 0 + val contentWidth = (innerWidth - authorW - gap).coerceAtLeast(0) + + // Build constraints for each section + val authorConstraints = + if (showAuthorColumn) Constraints(maxWidth = px.authorWidth) else ZeroConstraints + val contentConstraints = Constraints(maxWidth = contentWidth) + + // Measure all children in a single pass with pre-computed constraints + val authorPlaceable = measurables[0].measure(authorConstraints) + val relayPlaceable = measurables[1].measure(authorConstraints) + val firstRowPlaceable = measurables[2].measure(contentConstraints) + val secondRowPlaceable = + measurables[3].measure( + if (showSecondRow) contentConstraints else ZeroConstraints, + ) + val contentPlaceable = measurables[4].measure(contentConstraints) + val reactionsPlaceable = + measurables[5].measure(Constraints(maxWidth = maxWidth)) + + // Calculate section heights + val spacer = if (showContentSpacer) px.contentSpacer else 0 + + val contentColumnHeight = + firstRowPlaceable.height + + (if (showSecondRow) secondRowPlaceable.height else 0) + + spacer + + contentPlaceable.height + + val authorColumnHeight = + if (showAuthorColumn) { + authorPlaceable.height + px.authorBadgeGap + relayPlaceable.height + } else { + 0 + } + + val mainAreaHeight = maxOf(authorColumnHeight, contentColumnHeight) + val totalHeight = padTop + mainAreaHeight + reactionsPlaceable.height + + layout(maxWidth, totalHeight.coerceAtLeast(constraints.minHeight)) { + // Place author column (inside padding) + if (showAuthorColumn) { + authorPlaceable.placeRelative(padStart, padTop) + relayPlaceable.placeRelative( + padStart, + padTop + authorPlaceable.height + px.authorBadgeGap, + ) + } + + // Place content column (to the right of author, inside padding) + val contentX = padStart + authorW + gap + var y = padTop + + firstRowPlaceable.placeRelative(contentX, y) + y += firstRowPlaceable.height + + if (showSecondRow) { + secondRowPlaceable.placeRelative(contentX, y) + y += secondRowPlaceable.height + } + + y += spacer + + contentPlaceable.placeRelative(contentX, y) + + // Place reactions row at full width, below main area + reactionsPlaceable.placeRelative(0, padTop + mainAreaHeight) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 320033df7..4559985e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.GenericRepostLayout +import com.vitorpamplona.amethyst.ui.layouts.NoteComposeLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -178,8 +179,6 @@ import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.HalfPadding import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier -import com.vitorpamplona.amethyst.ui.theme.RowColSpacing10dp -import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.Size30dp @@ -189,12 +188,9 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight -import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier -import com.vitorpamplona.amethyst.ui.theme.boostedNoteModifier import com.vitorpamplona.amethyst.ui.theme.channelNotePictureModifier import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor -import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent @@ -632,74 +628,91 @@ fun InnerNoteWithReactions( ) { val notBoostedNorQuote = !isBoostedNote && !isQuotedNote val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel) + val isNotRepost = baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent + val showSecondRow = isNotRepost && notBoostedNorQuote && accountViewModel.settings.isCompleteUIMode() - Row( - modifier = - if (!isBoostedNote) { - normalWithTopMarginNoteModifier - } else { - boostedNoteModifier - }, - horizontalArrangement = RowColSpacing10dp, - ) { - if (notBoostedNorQuote) { - Column(WidthAuthorPictureModifier, verticalArrangement = RowColSpacing5dp) { - // Draws the boosted picture outside the boosted card. + NoteComposeLayout( + modifier = Modifier.fillMaxWidth(), + addPadding = !isBoostedNote, + showAuthorColumn = notBoostedNorQuote, + showSecondRow = showSecondRow, + showContentSpacer = isNotRepost, + authorPicture = { + if (notBoostedNorQuote) { Box(modifier = Size55Modifier, contentAlignment = Alignment.BottomEnd) { RenderAuthorImages(baseNote, nav, accountViewModel) } - + } + }, + relayBadges = { + if (notBoostedNorQuote) { BadgeBox(baseNote, accountViewModel, nav) } - } - - Column(Modifier.fillMaxWidth()) { - val showSecondRow = - baseNote.event !is RepostEvent && - baseNote.event !is GenericRepostEvent && - !isBoostedNote && - !isQuotedNote && - accountViewModel.settings.isCompleteUIMode() - NoteBody( + }, + firstRow = { + FirstUserInfoRow( baseNote = baseNote, showAuthorPicture = isQuotedNote, - unPackReply = unPackReply, - makeItShort = makeItShort, - canPreview = canPreview, - showSecondRow = showSecondRow, isPinned = isPinned, - quotesLeft = quotesLeft, - backgroundColor = backgroundColor, editState = editState, accountViewModel = accountViewModel, nav = nav, moreOptions = moreOptions, ) - - RenderApprovalIfNeeded(baseNote, accountViewModel, nav) - } - } - - val isNotRepost = baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent && baseNote.event !is DraftWrapEvent - - if (isNotRepost) { - if (makeItShort) { - Spacer(modifier = DoubleVertSpacer) - } else { - ReactionsRow( + }, + secondRow = { + if (showSecondRow) { + SecondUserInfoRow( + baseNote, + editState, + accountViewModel, + nav, + ) + } + }, + noteContent = { + RenderNoteRow( baseNote = baseNote, - showReactionDetail = notBoostedNorQuote, - addPadding = !isBoostedNote, + backgroundColor = backgroundColor, + makeItShort = makeItShort, + canPreview = canPreview, editState = editState, + quotesLeft = quotesLeft, + unPackReply = unPackReply, accountViewModel = accountViewModel, nav = nav, ) - } - } else { - if (baseNote.event is DraftWrapEvent) { - Spacer(modifier = DoubleVertSpacer) - } - } + + if (!makeItShort) { + val noteEvent = baseNote.event + val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false } + if (zapSplits && noteEvent != null) { + Spacer(modifier = HalfDoubleVertSpacer) + DisplayZapSplits(noteEvent, false, accountViewModel, nav) + } + } + + RenderApprovalIfNeeded(baseNote, accountViewModel, nav) + }, + reactionsRow = { + if (isNotRepost && baseNote.event !is DraftWrapEvent) { + if (makeItShort) { + Spacer(modifier = DoubleVertSpacer) + } else { + ReactionsRow( + baseNote = baseNote, + showReactionDetail = notBoostedNorQuote, + addPadding = !isBoostedNote, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } else if (baseNote.event is DraftWrapEvent) { + Spacer(modifier = DoubleVertSpacer) + } + }, + ) } @Composable From 350873b7eb57aaf871b3fdea56b8ff204e54942f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 02:49:42 +0000 Subject: [PATCH 2/6] perf: use multi-content Layout to eliminate all wrapper nodes Replace Box/Column wrappers with the multi-content Layout overload (contents: List) which makes each slot's composables direct measurables. This removes 6 intermediate layout nodes per note item. Multi-child slots (noteContent, reactionsRow) are now measured and stacked inline using arrayOfNulls with zero-allocation iteration, avoiding Column's internal measure pass entirely. https://claude.ai/code/session_01Fam5VHNaNkKBBafg1gRfFS --- .../amethyst/ui/layouts/NoteComposeLayout.kt | 130 ++++++++++-------- 1 file changed, 75 insertions(+), 55 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt index f44dcf2bb..58c50927f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt @@ -20,14 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.layouts -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp @@ -47,8 +45,10 @@ private data class NoteLayoutPx( ) /** - * Custom single-pass layout for note items that replaces nested Row/Column - * measurements with direct constraint calculation using known fixed dimensions. + * Custom zero-overhead layout for note items. Uses the multi-content [Layout] + * overload to eliminate ALL intermediate wrapper nodes (Box/Column). Each slot's + * composables become direct measurables measured with pre-computed constraints + * in a single pass. * * Layout structure (when [showAuthorColumn] is true): * ``` @@ -67,11 +67,12 @@ private data class NoteLayoutPx( * └───────────────────────────────────────────────┘ * ``` * - * Performance benefits over nested Row/Column: - * - Eliminates Row's two-pass measurement (measure author, then content with remaining width) - * - Uses pre-computed pixel dimensions for author column width (55dp) and gaps - * - Reduces content Column children from ~6 to ~3 - * - Single measure pass positions all sections with known constraints + * Performance characteristics: + * - Zero intermediate layout nodes (no Box/Column wrappers) + * - Pre-computed pixel dimensions cached across recompositions + * - Single measure pass with known constraints for all 6 slots + * - Multi-child slots (noteContent, reactionsRow) stacked inline + * - Hidden slots skip measurement entirely (empty measurable lists) * * @param addPadding Whether to add standard note padding (12dp sides, 10dp top) * @param showAuthorColumn Whether to reserve space for the author picture column @@ -109,22 +110,17 @@ fun NoteComposeLayout( } Layout( - content = { - // Slot 0: Author picture (55x55 area) - Box { authorPicture() } - // Slot 1: Relay badges (55dp wide, below author) - Box { relayBadges() } - // Slot 2: First row (author name, time, options) - Box { firstRow() } - // Slot 3: Second row (NIP05, location, PoW) - Box { secondRow() } - // Slot 4: Note content (event-specific + zap splits + approval) - Column(Modifier.fillMaxWidth()) { noteContent() } - // Slot 5: Reactions row (full width) - Column(Modifier.fillMaxWidth()) { reactionsRow() } - }, + contents = + listOf( + authorPicture, + relayBadges, + firstRow, + secondRow, + noteContent, + reactionsRow, + ), modifier = modifier, - ) { measurables, constraints -> + ) { allMeasurables, constraints -> val maxWidth = constraints.maxWidth // Compute padding offsets @@ -138,70 +134,94 @@ fun NoteComposeLayout( val gap = if (showAuthorColumn) px.authorContentGap else 0 val contentWidth = (innerWidth - authorW - gap).coerceAtLeast(0) - // Build constraints for each section - val authorConstraints = - if (showAuthorColumn) Constraints(maxWidth = px.authorWidth) else ZeroConstraints + // Pre-compute constraint objects once + val authorConstraints = Constraints(maxWidth = px.authorWidth) val contentConstraints = Constraints(maxWidth = contentWidth) + val fullWidthConstraints = Constraints(maxWidth = maxWidth) - // Measure all children in a single pass with pre-computed constraints - val authorPlaceable = measurables[0].measure(authorConstraints) - val relayPlaceable = measurables[1].measure(authorConstraints) - val firstRowPlaceable = measurables[2].measure(contentConstraints) + // Measure author column (single child per slot, skip if empty) + val authorPlaceable = allMeasurables[0].firstOrNull()?.measure(authorConstraints) + val relayPlaceable = allMeasurables[1].firstOrNull()?.measure(authorConstraints) + + // Measure header rows + val firstRowPlaceable = allMeasurables[2].firstOrNull()?.measure(contentConstraints) val secondRowPlaceable = - measurables[3].measure( - if (showSecondRow) contentConstraints else ZeroConstraints, - ) - val contentPlaceable = measurables[4].measure(contentConstraints) - val reactionsPlaceable = - measurables[5].measure(Constraints(maxWidth = maxWidth)) + if (showSecondRow) allMeasurables[3].firstOrNull()?.measure(contentConstraints) else null + + // Measure note content children directly (no Column wrapper) + val contentMeasurables = allMeasurables[4] + val contentPlaceables = arrayOfNulls(contentMeasurables.size) + var contentStackHeight = 0 + for (i in contentMeasurables.indices) { + val placeable = contentMeasurables[i].measure(contentConstraints) + contentPlaceables[i] = placeable + contentStackHeight += placeable.height + } + + // Measure reactions row children directly (no Column wrapper) + val reactionsMeasurables = allMeasurables[5] + val reactionsPlaceables = arrayOfNulls(reactionsMeasurables.size) + var reactionsHeight = 0 + for (i in reactionsMeasurables.indices) { + val placeable = reactionsMeasurables[i].measure(fullWidthConstraints) + reactionsPlaceables[i] = placeable + reactionsHeight += placeable.height + } // Calculate section heights val spacer = if (showContentSpacer) px.contentSpacer else 0 val contentColumnHeight = - firstRowPlaceable.height + - (if (showSecondRow) secondRowPlaceable.height else 0) + + (firstRowPlaceable?.height ?: 0) + + (secondRowPlaceable?.height ?: 0) + spacer + - contentPlaceable.height + contentStackHeight val authorColumnHeight = - if (showAuthorColumn) { - authorPlaceable.height + px.authorBadgeGap + relayPlaceable.height + if (showAuthorColumn && authorPlaceable != null) { + authorPlaceable.height + px.authorBadgeGap + (relayPlaceable?.height ?: 0) } else { 0 } val mainAreaHeight = maxOf(authorColumnHeight, contentColumnHeight) - val totalHeight = padTop + mainAreaHeight + reactionsPlaceable.height + val totalHeight = padTop + mainAreaHeight + reactionsHeight layout(maxWidth, totalHeight.coerceAtLeast(constraints.minHeight)) { - // Place author column (inside padding) + // Place author column if (showAuthorColumn) { - authorPlaceable.placeRelative(padStart, padTop) - relayPlaceable.placeRelative( + authorPlaceable?.placeRelative(padStart, padTop) + relayPlaceable?.placeRelative( padStart, - padTop + authorPlaceable.height + px.authorBadgeGap, + padTop + (authorPlaceable?.height ?: 0) + px.authorBadgeGap, ) } - // Place content column (to the right of author, inside padding) + // Place content column val contentX = padStart + authorW + gap var y = padTop - firstRowPlaceable.placeRelative(contentX, y) - y += firstRowPlaceable.height + firstRowPlaceable?.placeRelative(contentX, y) + y += firstRowPlaceable?.height ?: 0 if (showSecondRow) { - secondRowPlaceable.placeRelative(contentX, y) - y += secondRowPlaceable.height + secondRowPlaceable?.placeRelative(contentX, y) + y += secondRowPlaceable?.height ?: 0 } y += spacer - contentPlaceable.placeRelative(contentX, y) + for (placeable in contentPlaceables) { + placeable?.placeRelative(contentX, y) + y += placeable?.height ?: 0 + } // Place reactions row at full width, below main area - reactionsPlaceable.placeRelative(0, padTop + mainAreaHeight) + var reactionsY = padTop + mainAreaHeight + for (placeable in reactionsPlaceables) { + placeable?.placeRelative(0, reactionsY) + reactionsY += placeable?.height ?: 0 + } } } } From 002d7e631de9312da221a5011f5ca5e96d131e1c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 03:01:32 +0000 Subject: [PATCH 3/6] perf: merge ClickableNote Column into NoteComposeLayout modifier Move the combinedClickable + background modifiers from the wrapping ClickableNote Column directly onto NoteComposeLayout's modifier chain. This eliminates one Column layout node per note item in the feed. The ClickableNote function is preserved for other callers (ChannelCardCompose) but now delegates to clickableNoteModifier. https://claude.ai/code/session_01Fam5VHNaNkKBBafg1gRfFS --- .../amethyst/ui/note/NoteCompose.kt | 100 ++++++++++-------- 1 file changed, 53 insertions(+), 47 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 4559985e4..5595a1c70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -546,33 +546,58 @@ private fun CheckNewAndRenderNote( accountViewModel, ) - ClickableNote( + InnerNoteWithReactions( baseNote = baseNote, backgroundColor = backgroundColor, - modifier = modifier, + clickModifier = clickableNoteModifier(baseNote, modifier, accountViewModel, showPopup, nav), + isBoostedNote = isBoostedNote, + isQuotedNote = isQuotedNote, + unPackReply = unPackReply, + makeItShort = makeItShort, + canPreview = canPreview, + isPinned = isPinned, + quotesLeft = quotesLeft, accountViewModel = accountViewModel, - showPopup = showPopup, nav = nav, - ) { - InnerNoteWithReactions( - baseNote = baseNote, - backgroundColor = backgroundColor, - isBoostedNote = isBoostedNote, - isQuotedNote = isQuotedNote, - unPackReply = unPackReply, - makeItShort = makeItShort, - canPreview = canPreview, - isPinned = isPinned, - quotesLeft = quotesLeft, - accountViewModel = accountViewModel, - nav = nav, - moreOptions = moreOptions, - ) - } + moreOptions = moreOptions, + ) } @Composable @OptIn(ExperimentalFoundationApi::class) +fun clickableNoteModifier( + baseNote: Note, + modifier: Modifier, + accountViewModel: AccountViewModel, + showPopup: () -> Unit, + nav: INav, +): Modifier = + remember(baseNote, modifier) { + modifier + .combinedClickable( + onClick = { + val redirectToNote = + if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { + baseNote.replyTo?.lastOrNull() ?: baseNote + } else { + baseNote + } + + nav.nav { + if (redirectToNote.event is DraftWrapEvent) { + withContext(Dispatchers.IO) { + routeEditDraftTo(redirectToNote, accountViewModel.account) + } + } else { + routeFor(redirectToNote, accountViewModel.account) + } + } + }, + onLongClick = showPopup, + ) + } + +@Composable fun ClickableNote( baseNote: Note, modifier: Modifier, @@ -582,39 +607,20 @@ fun ClickableNote( nav: INav, content: @Composable () -> Unit, ) { - val updatedModifier = - remember(baseNote, modifier) { - modifier - .combinedClickable( - onClick = { - val redirectToNote = - if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { - baseNote.replyTo?.lastOrNull() ?: baseNote - } else { - baseNote - } - - nav.nav { - if (redirectToNote.event is DraftWrapEvent) { - withContext(Dispatchers.IO) { - routeEditDraftTo(redirectToNote, accountViewModel.account) - } - } else { - routeFor(redirectToNote, accountViewModel.account) - } - } - }, - onLongClick = showPopup, - ) - } - - Column(modifier = updatedModifier.background(backgroundColor.value)) { content() } + Column( + modifier = + clickableNoteModifier(baseNote, modifier, accountViewModel, showPopup, nav) + .background(backgroundColor.value), + ) { + content() + } } @Composable fun InnerNoteWithReactions( baseNote: Note, backgroundColor: MutableState, + clickModifier: Modifier = Modifier, isBoostedNote: Boolean, isQuotedNote: Boolean, unPackReply: ReplyRenderType, @@ -632,7 +638,7 @@ fun InnerNoteWithReactions( val showSecondRow = isNotRepost && notBoostedNorQuote && accountViewModel.settings.isCompleteUIMode() NoteComposeLayout( - modifier = Modifier.fillMaxWidth(), + modifier = clickModifier.background(backgroundColor.value).fillMaxWidth(), addPadding = !isBoostedNote, showAuthorColumn = notBoostedNorQuote, showSecondRow = showSecondRow, From b7ecdaf2e6692c4cdc6414569c9785d9a48baa3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 13:55:12 +0000 Subject: [PATCH 4/6] perf: use drawBehind for background color to avoid recomposition Replace background(backgroundColor.value) with drawBehind { drawRect() } so the color state is read during the draw phase, not composition. This prevents NoteComposeLayout from recomposing when the "new item" background color changes after 5 seconds. https://claude.ai/code/session_01Fam5VHNaNkKBBafg1gRfFS --- .../java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 5595a1c70..235f72c23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -51,6 +51,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver @@ -638,7 +639,10 @@ fun InnerNoteWithReactions( val showSecondRow = isNotRepost && notBoostedNorQuote && accountViewModel.settings.isCompleteUIMode() NoteComposeLayout( - modifier = clickModifier.background(backgroundColor.value).fillMaxWidth(), + modifier = + clickModifier + .drawBehind { drawRect(backgroundColor.value) } + .fillMaxWidth(), addPadding = !isBoostedNote, showAuthorColumn = notBoostedNorQuote, showSecondRow = showSecondRow, From 0e6f970e889b3085fadb7a9be189af86e078a22d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 14:25:28 +0000 Subject: [PATCH 5/6] feat: add @Preview for NoteComposeLayout Add two previews: full layout with all slots (author picture, relay badges, header rows, content, reactions) and a boosted variant without author column or padding. https://claude.ai/code/session_01Fam5VHNaNkKBBafg1gRfFS --- .../amethyst/ui/layouts/NoteComposeLayout.kt | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt index 58c50927f..21b6ac776 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt @@ -20,16 +20,29 @@ */ package com.vitorpamplona.amethyst.ui.layouts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Placeable import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn private val ZeroConstraints = Constraints.fixed(0, 0) @@ -44,6 +57,82 @@ private data class NoteLayoutPx( val padTop: Int, ) +@Composable +@Preview +private fun NoteComposeLayoutPreview() { + ThemeComparisonColumn { + NoteComposeLayoutPreviewCard() + } +} + +@Composable +private fun NoteComposeLayoutPreviewCard() { + NoteComposeLayout( + modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface), + showSecondRow = true, + authorPicture = { + Box( + Modifier + .size(55.dp) + .clip(CircleShape) + .background(Color(0xFF9575CD)), + ) + }, + relayBadges = { + Text("R1 R2 R3", fontSize = 10.sp, color = Color.Gray) + }, + firstRow = { + Text( + "Alice @alice 2h ...", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + secondRow = { + Text( + "alice@nostr.com", + fontSize = 12.sp, + color = Color.Gray, + maxLines = 1, + ) + }, + noteContent = { + Text( + "This is a sample note content that demonstrates the custom " + + "NoteComposeLayout with all slots populated. The layout handles " + + "author picture, relay badges, header rows, content, and reactions.", + ) + }, + reactionsRow = { + Text(" Reply 3 Boost 5 Like 12 Zap 1.2k", fontSize = 12.sp, color = Color.Gray) + }, + ) +} + +@Composable +@Preview +private fun NoteComposeLayoutBoostedPreview() { + ThemeComparisonColumn { + NoteComposeLayout( + modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface), + addPadding = false, + showAuthorColumn = false, + authorPicture = {}, + relayBadges = {}, + firstRow = { + Text("Bob boosted 2h ...") + }, + secondRow = {}, + noteContent = { + Text("Boosted note content without author column or padding.") + }, + reactionsRow = { + Text(" Reply Boost Like Zap", fontSize = 12.sp, color = Color.Gray) + }, + ) + } +} + /** * Custom zero-overhead layout for note items. Uses the multi-content [Layout] * overload to eliminate ALL intermediate wrapper nodes (Box/Column). Each slot's From 39743a14b13c4dca14b13a106ab2afc44be741f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 15:39:59 +0000 Subject: [PATCH 6/6] docs: document design choices in NoteComposeLayout Add comprehensive KDoc covering: why multi-content Layout over layoutId, why padding is in the measure policy, why placeRelative, why arrayOfNulls, slot ordering contract, remaining per-frame allocations, and drawBehind guidance. Remove unused ZeroConstraints. Document clickableNoteModifier and ClickableNote's backward-compatibility role. https://claude.ai/code/session_01Fam5VHNaNkKBBafg1gRfFS --- .../amethyst/ui/layouts/NoteComposeLayout.kt | 143 ++++++++++++++---- .../amethyst/ui/note/NoteCompose.kt | 16 ++ 2 files changed, 126 insertions(+), 33 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt index 21b6ac776..ec6a9e72d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt @@ -44,16 +44,25 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn -private val ZeroConstraints = Constraints.fixed(0, 0) - +/** + * Pre-computed pixel dimensions for [NoteComposeLayout], cached via [remember] + * so dp-to-px conversion only runs once per density change. + */ @Immutable private data class NoteLayoutPx( + /** Author picture column width: 55dp */ val authorWidth: Int, + /** Horizontal gap between author column and content column: 10dp */ val authorContentGap: Int, + /** Vertical gap between author picture and relay badges: 5dp */ val authorBadgeGap: Int, + /** Vertical spacer between header rows and note content: 4dp */ val contentSpacer: Int, + /** Left padding for the content area: 12dp */ val padStart: Int, + /** Right padding for the content area: 12dp */ val padEnd: Int, + /** Top padding for the content area: 10dp */ val padTop: Int, ) @@ -134,12 +143,13 @@ private fun NoteComposeLayoutBoostedPreview() { } /** - * Custom zero-overhead layout for note items. Uses the multi-content [Layout] - * overload to eliminate ALL intermediate wrapper nodes (Box/Column). Each slot's - * composables become direct measurables measured with pre-computed constraints - * in a single pass. + * Custom single-pass layout for note feed items that replaces the nested + * Row/Column structure with direct constraint calculation using known fixed + * dimensions. * - * Layout structure (when [showAuthorColumn] is true): + * ## Layout structure + * + * When [showAuthorColumn] is true (normal notes): * ``` * ┌──────────────────────────────────────────────┐ * │ padding (start=12, end=12, top=10) │ @@ -156,17 +166,81 @@ private fun NoteComposeLayoutBoostedPreview() { * └───────────────────────────────────────────────┘ * ``` * - * Performance characteristics: - * - Zero intermediate layout nodes (no Box/Column wrappers) - * - Pre-computed pixel dimensions cached across recompositions - * - Single measure pass with known constraints for all 6 slots - * - Multi-child slots (noteContent, reactionsRow) stacked inline - * - Hidden slots skip measurement entirely (empty measurable lists) + * When [showAuthorColumn] is false (boosted/quoted notes): + * ``` + * ┌───────────────────────────────────────────────┐ + * │ firstRow (full width) │ + * │ noteContent │ + * ├───────────────────────────────────────────────┤ + * │ reactionsRow (full width) │ + * └───────────────────────────────────────────────┘ + * ``` * + * ## Design choices + * + * **Multi-content Layout instead of layoutId**: Uses [Layout] with `contents: List` + * so each slot's composables become direct measurables without Box/Column wrappers. + * This eliminates 6 intermediate layout nodes per note compared to the layoutId + * approach. Empty slot lambdas produce empty measurable lists, which are skipped + * entirely (no [Constraints.fixed] zero-size measurement needed). + * + * **Padding in measure policy instead of Modifier.padding()**: The reactions row + * spans the full parent width without side padding, while the author + content area + * has 12dp horizontal padding. A single Modifier.padding() can't express different + * padding for different children, so padding offsets are applied during placement. + * + * **placeRelative instead of place**: Ensures correct positioning in RTL layouts + * by automatically mirroring x coordinates. + * + * **arrayOfNulls instead of map**: For multi-child slots (noteContent, reactionsRow), + * uses pre-sized arrays with indexed iteration to avoid List allocation overhead in + * the measure pass hot path. + * + * ## Slot ordering contract + * + * The `contents` list and `allMeasurables` indices are: + * - 0: [authorPicture] - 0 or 1 measurables + * - 1: [relayBadges] - 0 or 1 measurables + * - 2: [firstRow] - exactly 1 measurable + * - 3: [secondRow] - 0 or 1 measurables + * - 4: [noteContent] - 1+ measurables (stacked vertically) + * - 5: [reactionsRow] - 0+ measurables (stacked vertically) + * + * ## Performance + * + * Compared to the previous nested Row > Column > Column structure: + * - Eliminates 3 layout node levels (Row, author Column, content Column) + * - Eliminates Row's two-pass measurement (measure author first, then content) + * - Pre-computes all pixel dimensions once via [remember], cached across recompositions + * - Remaining per-frame allocations: ~48 bytes for `listOf(6 lambdas)` + + * ~24 bytes per `arrayOfNulls` for multi-child slots + * + * The caller should use `drawBehind { drawRect(color) }` instead of + * `background(color)` on the [modifier] to avoid recomposition when the + * background color state changes (e.g. "new item" highlight fade). + * + * @param modifier Applied to the Layout root. Typically includes combinedClickable + * for note navigation, drawBehind for background color, and fillMaxWidth. * @param addPadding Whether to add standard note padding (12dp sides, 10dp top) - * @param showAuthorColumn Whether to reserve space for the author picture column - * @param showSecondRow Whether to measure and place the second info row - * @param showContentSpacer Whether to add a 4dp spacer between header rows and content + * to the author + content area. False for boosted notes. + * @param showAuthorColumn Whether to reserve 55dp for the author picture column + * with a 10dp gap. False for boosted/quoted notes. + * @param showSecondRow Whether to measure and place the second header row + * (NIP-05 status, location, PoW). Requires complete UI mode. + * @param showContentSpacer Whether to add a 4dp spacer between header rows and + * note content. False for repost events. + * @param authorPicture Slot for the author's profile picture (55x55dp area). + * Should emit nothing when [showAuthorColumn] is false to skip composition. + * @param relayBadges Slot for relay indicator icons below the author picture. + * Should emit nothing when [showAuthorColumn] is false. + * @param firstRow Slot for the primary header: author name, time, more options. + * Always present. + * @param secondRow Slot for the secondary header: NIP-05, location, PoW, OTS. + * Should emit nothing when [showSecondRow] is false. + * @param noteContent Slot for the event-specific content. May emit multiple + * children (content + zap splits + approval button) which are stacked vertically. + * @param reactionsRow Slot for the reaction buttons row. May emit a ReactionsRow, + * a Spacer, or nothing depending on event type. */ @Composable fun NoteComposeLayout( @@ -198,46 +272,49 @@ fun NoteComposeLayout( } } + // Slot order must match the indices used in the measure policy below. Layout( contents = listOf( - authorPicture, - relayBadges, - firstRow, - secondRow, - noteContent, - reactionsRow, + authorPicture, // 0 + relayBadges, // 1 + firstRow, // 2 + secondRow, // 3 + noteContent, // 4 + reactionsRow, // 5 ), modifier = modifier, ) { allMeasurables, constraints -> val maxWidth = constraints.maxWidth - // Compute padding offsets + // Padding is applied in the measure policy (not via Modifier.padding) because + // the reactions row needs full width while the content area is inset. val padStart = if (addPadding) px.padStart else 0 val padEnd = if (addPadding) px.padEnd else 0 val padTop = if (addPadding) px.padTop else 0 - // Compute column widths from known dimensions + // Column widths are computed from known fixed dimensions, eliminating + // the Row's two-pass measurement (measure author → compute remaining → measure content). val innerWidth = maxWidth - padStart - padEnd val authorW = if (showAuthorColumn) px.authorWidth else 0 val gap = if (showAuthorColumn) px.authorContentGap else 0 val contentWidth = (innerWidth - authorW - gap).coerceAtLeast(0) - // Pre-compute constraint objects once + // Constraints is a value class (inline Long) so these don't allocate. val authorConstraints = Constraints(maxWidth = px.authorWidth) val contentConstraints = Constraints(maxWidth = contentWidth) val fullWidthConstraints = Constraints(maxWidth = maxWidth) - // Measure author column (single child per slot, skip if empty) + // Single-child slots: firstOrNull() returns null for empty slots (hidden), + // skipping measurement entirely. val authorPlaceable = allMeasurables[0].firstOrNull()?.measure(authorConstraints) val relayPlaceable = allMeasurables[1].firstOrNull()?.measure(authorConstraints) - // Measure header rows val firstRowPlaceable = allMeasurables[2].firstOrNull()?.measure(contentConstraints) val secondRowPlaceable = if (showSecondRow) allMeasurables[3].firstOrNull()?.measure(contentConstraints) else null - // Measure note content children directly (no Column wrapper) + // Multi-child slots: measured into pre-sized arrays to avoid List allocation. val contentMeasurables = allMeasurables[4] val contentPlaceables = arrayOfNulls(contentMeasurables.size) var contentStackHeight = 0 @@ -247,7 +324,6 @@ fun NoteComposeLayout( contentStackHeight += placeable.height } - // Measure reactions row children directly (no Column wrapper) val reactionsMeasurables = allMeasurables[5] val reactionsPlaceables = arrayOfNulls(reactionsMeasurables.size) var reactionsHeight = 0 @@ -257,7 +333,7 @@ fun NoteComposeLayout( reactionsHeight += placeable.height } - // Calculate section heights + // Height calculation val spacer = if (showContentSpacer) px.contentSpacer else 0 val contentColumnHeight = @@ -276,8 +352,9 @@ fun NoteComposeLayout( val mainAreaHeight = maxOf(authorColumnHeight, contentColumnHeight) val totalHeight = padTop + mainAreaHeight + reactionsHeight + // placeRelative handles RTL by mirroring x coordinates automatically. layout(maxWidth, totalHeight.coerceAtLeast(constraints.minHeight)) { - // Place author column + // Author column (inside padding area) if (showAuthorColumn) { authorPlaceable?.placeRelative(padStart, padTop) relayPlaceable?.placeRelative( @@ -286,7 +363,7 @@ fun NoteComposeLayout( ) } - // Place content column + // Content column (to the right of author, inside padding area) val contentX = padStart + authorW + gap var y = padTop @@ -305,7 +382,7 @@ fun NoteComposeLayout( y += placeable?.height ?: 0 } - // Place reactions row at full width, below main area + // Reactions row (full width, no side padding, below main area) var reactionsY = padTop + mainAreaHeight for (placeable in reactionsPlaceables) { placeable?.placeRelative(0, reactionsY) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 235f72c23..0e89d1199 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -564,6 +564,14 @@ private fun CheckNewAndRenderNote( ) } +/** + * Creates a [Modifier] with [combinedClickable] for note navigation (click to open, + * long-press for quick action popup). Remembered by [baseNote] and [modifier] to avoid + * recreating the click handler on every recomposition. + * + * Used directly on [NoteComposeLayout]'s modifier in [InnerNoteWithReactions] to avoid + * a wrapping Column node. For callers that need a wrapping container, use [ClickableNote]. + */ @Composable @OptIn(ExperimentalFoundationApi::class) fun clickableNoteModifier( @@ -598,6 +606,14 @@ fun clickableNoteModifier( ) } +/** + * Wraps [content] in a Column with click handling and animated background. + * + * Kept for callers outside NoteCompose (e.g. ChannelCardCompose) that need a + * container for non-NoteComposeLayout content. For NoteCompose itself, + * [InnerNoteWithReactions] applies [clickableNoteModifier] directly on the + * [NoteComposeLayout] modifier to eliminate this Column wrapper node. + */ @Composable fun ClickableNote( baseNote: Note,