From 3bb178363d1e95375abf88571bc85abfe1b7aacd Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Thu, 3 Sep 2026 23:32:17 -0400 Subject: [PATCH 1/2] Draw an inline code chip behind the text instead of under it The chip was the renderer's span background, and a span's background is part of the text's own drawing: the text node paints the selection first and the glyphs over it, so an opaque chip covered the selection and selecting a sentence highlighted every word of it except the ones in backticks. The previous fix let the selection show through by taking the chip to 60% alpha, which is a compromise on both sides -- the chip is a weaker step down from the page, and selected it reached #3C344F where the words around it reached #776394. There is a place that is under both, and a fenced block was already in it: a modifier on the text rather than a style inside it. So `appendCodeChip` takes the code span from the renderer's inline builder, keeps its style and its space of padding either side but drops the background, and marks the range; `LinkedText` draws those ranges in a `drawBehind`. The chip is back to the full `rawSurface` fill (measured #11111B against a #1E1E2E page) and a selection over it now lands at #776394, the same as the rest of the sentence -- the fenced block's numbers exactly. The geometry is one box per line, from the bounding boxes of the run's first and last characters, taken as far as the line's `visibleEnd`. Not `getPathForRange`: that is the shape of a *selection*, which runs to the right edge of every line but the last, and a code span that wrapped left a full-width empty chip behind on the line above -- twice in one fixture. `visibleEnd` is the same rule the selection rectangle obeys, so the chip stops where the selection stops instead of sticking its padding space out past the end of a selected line. Checked on the emulator against a fixture with chips in a heading, three kinds of list item, a quote, a table cell and a link label, unselected and under Select All, and a link with a chip in its label still opens. Cost, against the same build without the change, streaming sixty paragraphs of three chips each: measure 755ms against 776ms, record 327ms against 321ms, transcript draw 0.22ms in both. --- AGENTS.md | 4 + TRANSCRIPT_RENDERING.md | 20 ++++ .../main/kotlin/com/example/aiapp/Markdown.kt | 22 +---- .../kotlin/com/example/aiapp/MarkdownLinks.kt | 97 +++++++++++++++++-- 4 files changed, 119 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 442c77b..4fb9544 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -307,6 +307,10 @@ first if a remote spawn ever mangles an argument. reply into the tool output under it -- and a container per row leaves whatever was drawn without one silently unselectable, which nothing on screen reports. Rows keep their tap handlers; selection is a long press. + **An inline code chip is drawn behind the text** rather than as the + renderer's span background, because a span background is part of the + text's own drawing and hid the selection under it -- see + `appendCodeChip` in `MarkdownLinks.kt` and TRANSCRIPT_RENDERING.md. - **A session can be moved to another directory** from the settings dialog (`POST /sessions/{id}/cwd`). It stops the process, because a working directory is settled at spawn; the next message starts it in the new one. diff --git a/TRANSCRIPT_RENDERING.md b/TRANSCRIPT_RENDERING.md index 3d9b11f..72ac5a9 100644 --- a/TRANSCRIPT_RENDERING.md +++ b/TRANSCRIPT_RENDERING.md @@ -72,6 +72,26 @@ the inline builder draws nothing for a node type it does not know (a week of blank headings). Tables go through `LinkedTable`/`LinkedTableRow` so cells get the same treatment. +**An inline code chip is drawn behind the text, not as a span +background.** A `SpanStyle` background is part of the text's own drawing +and the text node draws the selection *under* the glyphs, so an opaque +chip hid the selection: selecting a sentence highlighted every word of it +except the ones in backticks, and there is no way to reorder that -- the +order is the node's. `appendCodeChip` therefore takes the code span from +the renderer's builder, keeps its style and its space of padding either +side but drops the background, and marks the range; `LinkedText` draws +those ranges in a `drawBehind`, which is under both the selection and the +glyphs -- the same place a fenced block's box already was, which is why +one of those always looked right. Geometry is one box per line, from the +bounding boxes of the run's first and last characters, taken as far as the +line's `visibleEnd`: `getPathForRange` is a *selection* shape and runs to +the right edge of every line but the last, which left a full-width empty +chip behind whenever the code wrapped, and `visibleEnd` is what makes the +chip and the selection rectangle stop in the same place. Measured against +the same build without it, streaming 60 paragraphs of three chips each: +measure 755ms against 776ms, record 327ms against 321ms, transcript draw +0.22ms in both -- noise. + **Text draws on the platform directly.** A paragraph without an image skips the renderer's `MarkdownText`, which charges every paragraph for the possibility of inline images (placement callback, derived inline-content diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt index 20a1b80..1509efd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -368,15 +368,10 @@ private fun MarkdownRoot( // exactly a card's own fill, so a fenced block inside a tool call had no // background at all and one in a reply read as a step *up* out of the page. codeBackground = rawSurface, - // The same colour, but let through. An inline span's background is part of the - // *text's* own drawing and the selection rectangle is drawn underneath it, so an - // opaque chip hides the selection completely: selecting a sentence highlighted - // every word of it except the ones in backticks, which is a difference in - // appearance the reader has no way to account for. Translucent, the selection - // shows through and the chip still reads as one step down from the page -- there - // is no way to draw it over the selection instead, since the order is the text - // node's. - inlineCodeBackground = rawSurface.copy(alpha = INLINE_CODE_ALPHA), + // The same colour. Not drawn by the renderer as a span background but by + // [LinkedText] behind the text, so a selection lands on top of it as it does on a + // fenced block -- see `appendCodeChip`. + inlineCodeBackground = rawSurface, // The same tint a code block gets, rather than the renderer's 2%-alpha default: // two adjacent tints that differ by a fiftieth read as one flat block on a phone, // so the table would have had a border-less grid and nothing saying where it began. @@ -719,12 +714,3 @@ class ParsedReplies { ready.clear() } } - -/** - * How much of the inline-code chip's fill is its own colour, the rest being whatever it sits on. - * - * High enough that the chip is still a clear step down from the page, low enough that a selection - * under it changes what the chip looks like. Both halves are the point: at 1.0 the chip was the - * only part of a selected sentence that did not look selected. - */ -private const val INLINE_CODE_ALPHA = 0.6f diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt index c4cdc8c..9ea7ca5 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt @@ -9,7 +9,10 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.node.Ref @@ -96,12 +99,23 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif val layout = remember { Ref() } // The renderer's own rule for a style that names no colour: the theme's text colour. val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text + val chips = remember(text) { text.getStringAnnotations(CODE_CHIP, 0, text.length) } + val chipColor = LocalMarkdownColors.current.inlineCodeBackground + // Filled in by `onTextLayout`, which runs in the layout phase, so the draw of the same frame + // finds it set -- no state needed, and a relayout redraws the node anyway. + val chipFills = remember { Ref>() } + val chipFill = + if (chips.isEmpty()) Modifier + else + Modifier.drawBehind { + chipFills.value?.forEach { drawRect(chipColor, it.topLeft, it.size) } + } BasicText( text = text, modifier = // A tap here is either a link or the card's; see [LocalMarkdownTap] for why the // second one has to be answered from inside the text rather than left to the card. - modifier.pointerInput(text, onPlainTap) { + modifier.then(chipFill).pointerInput(text, onPlainTap) { awaitEachGesture { // Unconsumed is not required: something outside may already be tracking this // press, and it is still the press that may land on a link. @@ -130,7 +144,10 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif }, style = style, color = { color }, - onTextLayout = { layout.value = it }, + onTextLayout = { + layout.value = it + chipFills.value = chips.flatMap { chip -> it.chipRects(chip.start, chip.end) } + }, ) } @@ -188,15 +205,83 @@ private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset): private const val LINK_URL = "url" /** - * The renderer's annotator settings with [appendPlainLink] answering for links. The annotator needs - * the settings to draw a link's label, and the settings hold the annotator, so the reference goes - * through a cell filled in once both exist. + * Appends [node] as inline code -- the renderer's own span, padded by a space each side as it does, + * but with no background of its own -- if it is a code span; false leaves anything else to the + * renderer. + * + * The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's + * background is part of the text's own drawing, and the text node draws the selection first and the + * glyphs over it, so a chip painted as a span background covered the selection: selecting a + * sentence highlighted every word of it except the ones in backticks. Anything drawn by a modifier + * on the text is under both, which is where a fenced block's box already is and why one of those + * always looked right. The [CODE_CHIP] annotation is what says where the fill goes. + */ +private fun appendCodeChip( + builder: AnnotatedString.Builder, + content: String, + node: ASTNode, + settings: AnnotatorSettings, +): Boolean { + if (node.type != MarkdownElementTypes.CODE_SPAN) return false + builder.pushStringAnnotation(CODE_CHIP, "") + builder.pushStyle(settings.codeSpanStyle.copy(background = Color.Unspecified)) + builder.append(' ') + // The backticks are the first and last children. + builder.buildMarkdownAnnotatedString(content, node.children.drop(1).dropLast(1), settings) + builder.append(' ') + builder.pop() + builder.pop() + return true +} + +private const val CODE_CHIP = "code" + +/** + * One box per line of the text [start] until [end] covers, in the layout's own coordinates. + * + * Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every + * line but the last, so a chip whose code wrapped left a full-width empty box behind on the line + * above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space + * stops being drawn: the same rule the selection rectangle obeys, so the two agree rather than the + * chip sticking a space out past the end of a selected line. It is also what leaves nothing behind + * when the only thing to reach a line is the space a chip is padded with. + * + * A run's extent is taken from the boxes of its first and last characters, which is exact while a + * line reads in one direction; mixed directions inside a code span would draw one box across the + * whole run rather than one per direction, and code spans are code. + */ +private fun TextLayoutResult.chipRects(start: Int, end: Int): List { + val rects = mutableListOf() + for (line in getLineForOffset(start)..getLineForOffset(end - 1)) { + val from = maxOf(start, getLineStart(line)) + val to = minOf(end, getLineEnd(line, visibleEnd = true)) + if (from >= to) continue + val head = getBoundingBox(from) + val tail = getBoundingBox(to - 1) + rects += + Rect( + left = minOf(head.left, tail.left), + top = minOf(head.top, tail.top), + right = maxOf(head.right, tail.right), + bottom = maxOf(head.bottom, tail.bottom), + ) + } + return rects +} + +/** + * The renderer's annotator settings with [appendPlainLink] answering for links and [appendCodeChip] + * for inline code. The annotator needs the settings to draw a link's label, and the settings hold + * the annotator, so the reference goes through a cell filled in once both exist. */ @Composable private fun plainLinkSettings(): AnnotatorSettings { val cell = remember { Ref() } val annotator = remember { - markdownAnnotator { content, node -> appendPlainLink(this, content, node, cell.value!!) } + markdownAnnotator { content, node -> + appendPlainLink(this, content, node, cell.value!!) || + appendCodeChip(this, content, node, cell.value!!) + } } return annotatorSettings(annotator = annotator).also { cell.value = it } } From 8881a40919c806b5bfcf7579b9df365ff644c9ea Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Thu, 3 Sep 2026 23:41:58 -0400 Subject: [PATCH 2/2] Remove the drag-right-to-go-back gesture The screen no longer follows a horizontal drag. Back is the arrow at the top left and the platform's own edge gesture, both unchanged. Co-Authored-By: Claude Opus 5 --- EXPLORER.md | 2 +- .../main/kotlin/com/example/aiapp/AppRoot.kt | 24 +++---- .../kotlin/com/example/aiapp/SwipeBack.kt | 70 ------------------- 3 files changed, 11 insertions(+), 85 deletions(-) delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SwipeBack.kt diff --git a/EXPLORER.md b/EXPLORER.md index 7deb312..75b0ca6 100644 --- a/EXPLORER.md +++ b/EXPLORER.md @@ -214,7 +214,7 @@ which highlighting is on -- see "Numbers to measure". `FilesScreen` is composed **on top of** the session in the same `Box`, and the session stays composed under it: its event stream keeps flowing, its scroll position and draft stay where they were, and returning from a file -costs nothing. Back -- the button, the platform gesture and `swipeBack` -- +costs nothing. Back -- the button and the platform gesture -- clears `files` when it is set and goes to the list otherwise. Inside the explorer the same back steps one level: editor → viewer (with the unsaved question), viewer → listing, listing → parent directory it came from, and diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index ad1b81c..28a6d77 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -198,21 +198,17 @@ fun AppRoot( // row key. Only reachable since a notification can move straight from one session to // another; every other way here passes through [Screen.Main], which disposes it anyway. key(here.summary.id) { - // The gesture goes on a box around the screen rather than inside it, so it is the - // outermost thing in the tree and everything within has already had its chance at - // the drag. See [swipeBack]. No imePadding here, for the reason above. - Box(Modifier.swipeBack(goToMain)) { - SessionScreen( - settings = current, - summary = here.summary, - onBack = goToMain, - share = share, - onShareTaken = { share = null }, - ) - } + // No imePadding here, for the reason above. + SessionScreen( + settings = current, + summary = here.summary, + onBack = goToMain, + share = share, + onShareTaken = { share = null }, + ) } is Screen.Spawn -> - Box(Modifier.imePadding().swipeBack(goToMain)) { + Box(Modifier.imePadding()) { SpawnScreen( settings = current, onSpawned = { spawned -> @@ -223,7 +219,7 @@ fun AppRoot( ) } is Screen.Settings -> - Box(Modifier.imePadding().swipeBack(goToMain)) { + Box(Modifier.imePadding()) { SettingsScreen( existing = current, onSaved = { saved -> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SwipeBack.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SwipeBack.kt deleted file mode 100644 index 7697715..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SwipeBack.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.example.aiapp - -import androidx.compose.animation.core.Animatable -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch - -/** - * Dragging the screen to the right to step back to the one behind it. - * - * The platform's own back gesture is a swipe from the very edge, and only from there; on a phone - * held in one hand the way back from a session is either that narrow strip or the arrow at the top - * left, which is the far corner from the thumb. This is the same movement from anywhere on the - * screen. - * - * **It loses every argument.** The gesture is a plain horizontal [draggable] on the outside of the - * screen, so anything inside that wants horizontal drags has already taken them by the time this - * would see them: pointer events reach the innermost node first, and a drag a child has consumed - * never crosses this modifier's touch slop. That is what keeps a wide code fence, a table scrolled - * sideways or a text selection working -- they are the components the reader meant, and this is - * only what is left over. Vertical drags are not its orientation, so the transcript scrolls - * untouched. - * - * The screen follows the finger rather than jumping at the end, because a gesture with no feedback - * cannot be aborted: the reader has to be able to see it starting and change their mind. Released - * short of [SWIPE_BACK_TRAVEL] it slides back and nothing happens. Right rather than left, and only - * right, since there is nothing forward of these screens to go to. - */ -@Composable -fun Modifier.swipeBack(onBack: () -> Unit): Modifier { - val offset = remember { Animatable(0f) } - val scope = rememberCoroutineScope() - val travel = with(LocalDensity.current) { SWIPE_BACK_TRAVEL.toPx() } - return draggable( - state = - rememberDraggableState { delta -> - // Rightward only: a leftward drag stays at zero rather than lifting the - // screen off its left edge, which would look like a gesture that does - // something and does not. - scope.launch { offset.snapTo((offset.value + delta).coerceAtLeast(0f)) } - }, - orientation = Orientation.Horizontal, - onDragStopped = { - if (offset.value >= travel) { - onBack() - // Straight back rather than animated: the screen this was moving is being - // replaced, and animating it home first would show the old one sliding back - // into place after the new one had arrived. - offset.snapTo(0f) - } else { - offset.animateTo(0f) - } - }, - ) - // Read inside the block, so following the finger is a draw-phase change and costs no - // recomposition of the screen being dragged. - .graphicsLayer { translationX = offset.value } -} - -/** How far the screen has to be pulled for letting go to mean "back" rather than "never mind". */ -private val SWIPE_BACK_TRAVEL: Dp = 96.dp