From f87c99c71b5b00aecc0a5d7f93539bd2169f3b16 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Wed, 2 Sep 2026 04:39:05 -0400 Subject: [PATCH] Draw a reply's links as text spans, not a node per link Compose materializes every LinkAnnotation as its own layout node -- a clipped, focusable, hoverable, clickable box laid out against the text -- and the markdown renderer emits one per link. Measured on the emulator against the same paragraphs with each link replaced by its label and URL as plain words: the linked version cost five times the worst measure (26.3ms vs 5.2ms) and 1.7x the place time, with less text on screen. On the phone that was the bump at a reply's list of sources. The paragraph, text and heading components now go through LinkedText, which builds the renderer's annotated string with an annotator that styles a link as a span carrying its address, and hit-tests taps against the text layout itself. After: place back at the link-free level (worst 3.1ms), worst measure halved. Table cells and reference links keep the renderer's path; the table draws its own cells. Co-Authored-By: Claude Fable 5.1 --- .../main/kotlin/com/example/aiapp/Markdown.kt | 13 ++ .../kotlin/com/example/aiapp/MarkdownLinks.kt | 141 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt 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 117a03c..e54b0c7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -144,6 +144,19 @@ fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modi // The m3 renderer's own default, restored: supplying `components` at all replaces // the whole set, and this is the only member of it the Material layer overrides. checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) }, + // Everything that draws a run of text, so a link is a span rather than a node -- + // see [LinkedText]. Setext headings take the same styles as `#` and `##`, which + // is the renderer's own pairing. + text = { LinkedText(it, it.typography.text) }, + paragraph = { LinkedText(it, it.typography.paragraph) }, + heading1 = { LinkedText(it, it.typography.h1, heading = true) }, + heading2 = { LinkedText(it, it.typography.h2, heading = true) }, + heading3 = { LinkedText(it, it.typography.h3, heading = true) }, + heading4 = { LinkedText(it, it.typography.h4, heading = true) }, + heading5 = { LinkedText(it, it.typography.h5, heading = true) }, + heading6 = { LinkedText(it, it.typography.h6, heading = true) }, + setextHeading1 = { LinkedText(it, it.typography.h1, heading = true) }, + setextHeading2 = { LinkedText(it, it.typography.h2, heading = true) }, table = { MarkdownTable( it.content, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt new file mode 100644 index 0000000..6e454f7 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt @@ -0,0 +1,141 @@ +package com.example.aiapp + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.node.Ref +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import com.mikepenz.markdown.annotator.AnnotatorSettings +import com.mikepenz.markdown.annotator.annotatorSettings +import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString +import com.mikepenz.markdown.compose.components.MarkdownComponentModel +import com.mikepenz.markdown.compose.elements.MarkdownText +import com.mikepenz.markdown.model.markdownAnnotator +import com.mikepenz.markdown.utils.getUnescapedTextInNode +import org.intellij.markdown.MarkdownElementTypes +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.ast.findChildOfType +import org.intellij.markdown.flavours.gfm.GFMTokenTypes + +/** + * A paragraph, heading or bare text whose links are spans of the text rather than nodes of their + * own. + * + * Compose turns every `LinkAnnotation` in a text into a layout node: a clipped, focusable, + * hoverable, clickable box laid out against the glyphs, with its outline recomputed from the text + * layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one of those + * annotations per link. Measured on the emulator against the same paragraphs with each link + * replaced by its label and address as plain words -- *more* text, the same gestures -- the linked + * version cost five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time. On a + * Pixel 9 Pro XL that was the bump at the list of sources in a reply, and nowhere else in it. + * + * Here a link is the link colour and underline, a string annotation carrying its address, and one + * tap detector for the whole text that asks the layout which character was under the finger. What + * that gives up is a link being its own accessibility node with a pressed state; the app's link + * style never defined a pressed style, so nothing visible changes. + * + * Every block the renderer dispatches through its component table comes here, which includes the + * paragraphs inside lists, quotes and alerts. Table cells do not: the table draws its own cells and + * offers no slot for them, so a link in a cell keeps the renderer's path -- correct, and dearer. + * Reference-style links stay there too; the renderer resolves those against its definitions. + */ +@Composable +fun LinkedText(model: MarkdownComponentModel, style: TextStyle, heading: Boolean = false) { + val settings = plainLinkSettings() + val text = + remember(model.content, model.node, style) { + model.content.buildMarkdownAnnotatedString(model.node, style, settings) + } + val uriHandler = LocalUriHandler.current + val layout = remember { Ref() } + MarkdownText( + content = text, + node = model.node, + modifier = + Modifier.then(if (heading) Modifier.semantics { heading() } else Modifier).pointerInput( + text + ) { + detectTapGestures { position -> + val url = text.linkAt(layout.value, position) ?: return@detectTapGestures + uriHandler.openUri(url) + } + }, + style = style, + onTextLayout = { result, _ -> layout.value = result }, + ) +} + +/** The address under [position], if a link's glyph is there rather than merely nearest to it. */ +private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset): String? { + layout ?: return null + val offset = layout.getOffsetForPosition(position) + if (offset >= length || !layout.getBoundingBox(offset).contains(position)) return null + return getStringAnnotations(LINK_URL, offset, offset).firstOrNull()?.item +} + +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. + */ +@Composable +private fun plainLinkSettings(): AnnotatorSettings { + val cell = remember { Ref() } + val annotator = remember { + markdownAnnotator { content, node -> appendPlainLink(this, content, node, cell.value!!) } + } + return annotatorSettings(annotator = annotator).also { cell.value = it } +} + +/** + * Appends [node] as a styled, annotated span if it is a link the renderer would otherwise emit a + * `LinkAnnotation` for; false leaves anything else to the renderer. + */ +private fun appendPlainLink( + builder: AnnotatedString.Builder, + content: String, + node: ASTNode, + settings: AnnotatorSettings, +): Boolean { + val destination: String + val label: List? + when (node.type) { + MarkdownElementTypes.INLINE_LINK -> { + val text = node.findChildOfType(MarkdownElementTypes.LINK_TEXT) ?: return false + destination = + node + .findChildOfType(MarkdownElementTypes.LINK_DESTINATION) + ?.getUnescapedTextInNode(content) + ?.removeSurrounding("<", ">") ?: return false + // The brackets are the first and last children of the label. + label = text.children.drop(1).dropLast(1) + } + MarkdownElementTypes.AUTOLINK -> { + destination = node.getUnescapedTextInNode(content).removeSurrounding("<", ">") + label = null + } + GFMTokenTypes.GFM_AUTOLINK -> { + destination = node.getUnescapedTextInNode(content) + label = null + } + else -> return false + } + builder.pushStringAnnotation(LINK_URL, destination) + builder.pushStyle(settings.linkTextSpanStyle.style ?: SpanStyle()) + if (label == null) builder.append(destination) + else builder.buildMarkdownAnnotatedString(content, label, settings) + builder.pop() + builder.pop() + return true +}