Colour list markers by depth, highlight fences, draw images as links, and stream a list an item at a time

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>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-03 13:49:51 -04:00
1 parent 801618ba0e
commit 6892dc7caf
9 files changed
+533 -161

No files matched your search

@@ -21,9 +21,10 @@ 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 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
@@ -51,6 +52,12 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
* 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) {
@@ -82,41 +89,23 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
}
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 },
)
}
// 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 },
)
}
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.
*
@@ -153,7 +142,7 @@ private fun plainLinkSettings(): AnnotatorSettings {
/**
* 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.
* `LinkAnnotation` for, or an image it would place; false leaves anything else to the renderer.
*/
private fun appendPlainLink(
builder: AnnotatedString.Builder,
@@ -162,7 +151,10 @@ private fun appendPlainLink(
settings: AnnotatorSettings,
): Boolean {
val destination: String
val label: List<ASTNode>?
/** 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
@@ -174,20 +166,20 @@ private fun appendPlainLink(
// The brackets are the first and last children of the label.
label = text.children.drop(1).dropLast(1)
}
MarkdownElementTypes.AUTOLINK -> {
MarkdownElementTypes.AUTOLINK ->
destination = node.getUnescapedTextInNode(content).removeSurrounding("<", ">")
label = null
}
GFMTokenTypes.GFM_AUTOLINK -> {
destination = node.getUnescapedTextInNode(content)
label = null
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.append(destination)
else builder.buildMarkdownAnnotatedString(content, label, settings)
if (label != null) builder.buildMarkdownAnnotatedString(content, label, settings)
else builder.append(words ?: destination)
builder.pop()
builder.pop()
return true