Items 1-5 of TRANSCRIPT_RENDERING.md's list, plus the AGP 9.4.0 bump from 7. MarkdownRoot provides the renderer's locals itself instead of calling its Markdown() composable; fences and indented blocks go through CodeFence.kt, which shares the tool-input highlighter and a fence-language alias table; an image in a paragraph is a link carrying its alt text, so every paragraph is now platform text; LiveParse freezes the finished items of the tail list so a forty-item list streams as forty paragraphs would. Measured before, on the emulator (report from transcript-bench.sh over the 200-line fence fixture): draw phase 0.72ms per frame, transcript 0.36ms. stream-bench.sh (new) streaming forty linked bullets on the old build: markdown reparsed while streaming 483, 3.9ms mean, 11.6ms worst; record: one block worst 1.6ms. The after runs, the on-screen check of the glyphs and lint are recorded as owed in the doc's "What is next". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
187 lines
8.6 KiB
Kotlin
187 lines
8.6 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.model.markdownAnnotator
|
|
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
|
import com.mikepenz.markdown.utils.resolveImageAlt
|
|
import com.mikepenz.markdown.utils.resolveImageLink
|
|
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.
|
|
*
|
|
* An image is a link too, carrying its alt text. The app has no image loader and the renderer's
|
|
* transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the
|
|
* model put something, with no sign of what fell out. The link says what was there and where, and
|
|
* opens it. It also means no paragraph needs the renderer's own text composable, which existed to
|
|
* place inline images and charged every paragraph for the possibility.
|
|
*/
|
|
@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>() }
|
|
// 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 =
|
|
modifier.pointerInput(text) {
|
|
detectTapGestures { position ->
|
|
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures
|
|
uriHandler.openUri(url)
|
|
}
|
|
},
|
|
style = style,
|
|
color = { color },
|
|
onTextLayout = { layout.value = it },
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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, or an image it would place; false leaves anything else to the renderer.
|
|
*/
|
|
private fun appendPlainLink(
|
|
builder: AnnotatedString.Builder,
|
|
content: String,
|
|
node: ASTNode,
|
|
settings: AnnotatorSettings,
|
|
): Boolean {
|
|
val destination: String
|
|
/** The label's own inline nodes, when it has markup of its own to draw. */
|
|
var label: List<ASTNode>? = null
|
|
/** Plain words for the label; the address itself when there are none. */
|
|
var words: String? = null
|
|
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("<", ">")
|
|
GFMTokenTypes.GFM_AUTOLINK -> destination = node.getUnescapedTextInNode(content)
|
|
MarkdownElementTypes.IMAGE -> {
|
|
destination =
|
|
node.resolveImageLink(content, settings.referenceLinkHandler) ?: return false
|
|
words = node.resolveImageAlt(content)
|
|
}
|
|
else -> return false
|
|
}
|
|
builder.pushStringAnnotation(LINK_URL, destination)
|
|
builder.pushStyle(settings.linkTextSpanStyle.style ?: SpanStyle())
|
|
if (label != null) builder.buildMarkdownAnnotatedString(content, label, settings)
|
|
else builder.append(words ?: destination)
|
|
builder.pop()
|
|
builder.pop()
|
|
return true
|
|
}
|