Three costs left in the renderer's composition layer, taken one at a time: The text leaf. Every paragraph went through the renderer's text composable, which exists to place inline images and charged each text for the possibility: a placement callback, a derived map of inline content, a semantics group and a size animation. A paragraph with no image -- nearly all of them -- now goes straight to BasicText, with the renderer's own rule for a style that names no colour. One with an image keeps the old path. The table. The renderer decided "spread or scroll" with a BoxWithConstraints, a subcomposition. LinkedTable does it with one layout modifier placed after the horizontal scroll: fillMaxWidth fixes the minimum to the room, the scroll passes that minimum through while lifting the maximum, and the modifier sizes the rows to the larger of the room and the columns' floor. Rows no longer need a width handed to them or a row index from a composition local. The live reply. Every delta reparsed the whole message off-thread; for a long reply that was tens of milliseconds hundreds of times, every core busy while the frame's thread waited. LiveParse freezes every top-level block that a later block has started after -- markdown's block rules make that safe -- and reparses only the tail. Each piece is keyed by where it starts in the message, so a block keeps its composition when it freezes. Verified on the emulator: the block-kind fixture draws the same with links opening from a paragraph and a bullet and plain text opening nothing; a six-column table still scrolls sideways; a 58-word mixed stream of list, fence, table and quote drew every block as it arrived, 47 tail reparses at 1.7ms mean against whole-message parses before. ktfmt, build and lint clean but for the AGP 9.4.0 notice. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
195 lines
8.5 KiB
Kotlin
195 lines
8.5 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.gestures.detectTapGestures
|
|
import androidx.compose.foundation.text.BasicText
|
|
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.graphics.isSpecified
|
|
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.LocalMarkdownColors
|
|
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.MarkdownTokenTypes
|
|
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, and so does every table cell through
|
|
* [LinkedTableRow]. Reference-style links are the one kind still drawn the renderer's way; it
|
|
* resolves those against its definitions.
|
|
*/
|
|
@Composable
|
|
fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
|
|
LinkedText(model.content, model.node, style)
|
|
}
|
|
|
|
/**
|
|
* A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or
|
|
* `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it
|
|
* does not know, so handed the heading node itself it draws an empty line. Which is what this did
|
|
* for a week.
|
|
*/
|
|
@Composable
|
|
fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) {
|
|
val words =
|
|
model.node.findChildOfType(MarkdownTokenTypes.ATX_CONTENT)
|
|
?: model.node.findChildOfType(MarkdownTokenTypes.SETEXT_CONTENT)
|
|
?: model.node
|
|
LinkedText(model.content, words, style, Modifier.semantics { heading() })
|
|
}
|
|
|
|
/** The inline content of [node] within [content], drawn as [LinkedText] describes. */
|
|
@Composable
|
|
fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modifier = Modifier) {
|
|
val settings = plainLinkSettings()
|
|
val text =
|
|
remember(content, node, style) {
|
|
content.buildMarkdownAnnotatedString(node, style, settings)
|
|
}
|
|
val uriHandler = LocalUriHandler.current
|
|
val layout = remember { Ref<TextLayoutResult>() }
|
|
val tapping =
|
|
modifier.pointerInput(text) {
|
|
detectTapGestures { position ->
|
|
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures
|
|
uriHandler.openUri(url)
|
|
}
|
|
}
|
|
// The renderer's text composable exists to place inline images, and it charges every text
|
|
// for the possibility: a placement callback, a derived map of inline content, a semantics
|
|
// group and a size animation, per paragraph. Almost no paragraph has an image, so those go
|
|
// straight to the platform text; the few that do keep the renderer's path.
|
|
if (remember(node) { node.hasImage() }) {
|
|
MarkdownText(
|
|
content = text,
|
|
node = node,
|
|
modifier = tapping,
|
|
style = style,
|
|
onTextLayout = { result, _ -> layout.value = result },
|
|
)
|
|
} else {
|
|
// 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
|
|
BasicText(
|
|
text = text,
|
|
modifier = tapping,
|
|
style = style,
|
|
color = { color },
|
|
onTextLayout = { layout.value = it },
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun ASTNode.hasImage(): Boolean =
|
|
type == MarkdownElementTypes.IMAGE || children.any { it.hasImage() }
|
|
|
|
/**
|
|
* The address under [position], if a link's glyph is there rather than merely nearest to it.
|
|
*
|
|
* The layout answers with a caret, the boundary nearest the finger, so a tap on the right half of a
|
|
* glyph names the character after it; the glyph under the finger is the one on either side of that
|
|
* boundary whose box holds the point. Checked with the box rather than assumed, so a tap past the
|
|
* end of a line ending in a link opens nothing.
|
|
*/
|
|
private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset): String? {
|
|
layout ?: return null
|
|
val caret = layout.getOffsetForPosition(position)
|
|
val glyph =
|
|
(caret - 1..caret).firstOrNull {
|
|
it in 0 until length && layout.getBoundingBox(it).contains(position)
|
|
} ?: return null
|
|
return getStringAnnotations(LINK_URL, glyph, glyph + 1).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
|
|
}
|