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:
1 parent
63c0bb9e4a
commit
f87c99c71b
2 files changed
+154
No files matched your search
@@ -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 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.
|
// the whole set, and this is the only member of it the Material layer overrides.
|
||||||
checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) },
|
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 = {
|
table = {
|
||||||
MarkdownTable(
|
MarkdownTable(
|
||||||
it.content,
|
it.content,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user