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 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-02 04:39:05 -04:00
1 parent 63c0bb9e4a
commit f87c99c71b
2 files changed
+154

No files matched your search

@@ -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<TextLayoutResult>() }
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<AnnotatorSettings>() }
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<ASTNode>?
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
}