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

@@ -0,0 +1,194 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.LocalMarkdownPadding
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.BoldHighlight
import dev.snipme.highlights.model.ColorHighlight
import dev.snipme.highlights.model.SyntaxLanguage
import org.intellij.markdown.ast.ASTNode
/**
* A fenced code block in a reply: the code highlighted, on the dark surface every verbatim thing
* sits on, scrolling sideways rather than wrapping.
*
* The renderer's own fence drew the same block in plain text. The lexer that colours a tool call's
* command colours a reply's code the same way, through [highlighted] and one theme, so a `kotlin`
* fence and the Kotlin a tool wrote are the same colours. A fence in a language the lexer has no
* rules for is plain rather than wrongly coloured: [fenceLanguage] answers null for those, and
* plain is what the reader would have seen before.
*
* Finding the code is still the library's: which children of the node are the fence markers, the
* language word and the code between them is its knowledge of the parser, and [MarkdownCodeFence]
* hands out the code and the language and leaves the drawing to the block it is given.
*/
@Composable
fun CodeFence(content: String, node: ASTNode, style: TextStyle) {
MarkdownCodeFence(content, node, style) { code, language, codeStyle ->
CodeBlockText(code, language, codeStyle)
}
}
/** An indented code block, which is a fence with no language word. */
@Composable
fun CodeBlock(content: String, node: ASTNode, style: TextStyle) {
MarkdownCodeBlock(content, node, style) { code, language, codeStyle ->
CodeBlockText(code, language, codeStyle)
}
}
/**
* The renderer's own block, less what nothing here needs: the same background, corner, padding and
* sideways scroll, without the shadow, the border and the empty pointer handler it also carried.
* The vertical margin is the renderer's too, kept so a reply's fences sit where they always have.
*/
@Composable
private fun CodeBlockText(code: String, language: String?, style: TextStyle) {
val colors = LocalMarkdownColors.current
val dimens = LocalMarkdownDimens.current
val padding = LocalMarkdownPadding.current
Box(
Modifier.fillMaxWidth()
.padding(vertical = 8.dp)
.background(colors.codeBackground, RoundedCornerShape(dimens.codeBackgroundCornerSize))
.semantics { isTraversalGroup = true }
) {
BasicText(
highlighted(code, fenceLanguage(language)),
style = style,
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock),
)
}
}
/**
* The lexer's language for a fence's info word, or null for one it has no lexer for.
*
* The aliases are what people actually write after the backticks: the file extension as often as
* the name. A word not here gets no colour rather than the nearest lexer's, because a fence
* coloured by the wrong language's rules looks highlighted and is wrong in a way the reader cannot
* see.
*/
fun fenceLanguage(name: String?): SyntaxLanguage? =
FENCE_LANGUAGES[name?.trim()?.lowercase() ?: return null]
private val FENCE_LANGUAGES: Map<String, SyntaxLanguage> =
mapOf(
"kotlin" to SyntaxLanguage.KOTLIN,
"kt" to SyntaxLanguage.KOTLIN,
"kts" to SyntaxLanguage.KOTLIN,
"rust" to SyntaxLanguage.RUST,
"rs" to SyntaxLanguage.RUST,
"sh" to SyntaxLanguage.SHELL,
"bash" to SyntaxLanguage.SHELL,
"shell" to SyntaxLanguage.SHELL,
"zsh" to SyntaxLanguage.SHELL,
"console" to SyntaxLanguage.SHELL,
"python" to SyntaxLanguage.PYTHON,
"py" to SyntaxLanguage.PYTHON,
"javascript" to SyntaxLanguage.JAVASCRIPT,
"js" to SyntaxLanguage.JAVASCRIPT,
"jsx" to SyntaxLanguage.JAVASCRIPT,
"typescript" to SyntaxLanguage.TYPESCRIPT,
"ts" to SyntaxLanguage.TYPESCRIPT,
"tsx" to SyntaxLanguage.TYPESCRIPT,
"java" to SyntaxLanguage.JAVA,
"c" to SyntaxLanguage.C,
"h" to SyntaxLanguage.C,
"cpp" to SyntaxLanguage.CPP,
"c++" to SyntaxLanguage.CPP,
"cc" to SyntaxLanguage.CPP,
"hpp" to SyntaxLanguage.CPP,
"csharp" to SyntaxLanguage.CSHARP,
"cs" to SyntaxLanguage.CSHARP,
"c#" to SyntaxLanguage.CSHARP,
"go" to SyntaxLanguage.GO,
"golang" to SyntaxLanguage.GO,
"swift" to SyntaxLanguage.SWIFT,
"dart" to SyntaxLanguage.DART,
"ruby" to SyntaxLanguage.RUBY,
"rb" to SyntaxLanguage.RUBY,
"php" to SyntaxLanguage.PHP,
"perl" to SyntaxLanguage.PERL,
"pl" to SyntaxLanguage.PERL,
"coffeescript" to SyntaxLanguage.COFFEESCRIPT,
"coffee" to SyntaxLanguage.COFFEESCRIPT,
)
/**
* [code] with its keywords and strings coloured, or plain if there is no language for it.
*
* The lexing is dev.snipme:highlights. The colours are this app's, mapped in [catppuccinSyntax] --
* a library's default theme would be the one place in the app whose palette came from somewhere
* else. Shared by a tool call's input ([ToolInputView]) and a reply's fences ([CodeFence]), so the
* same code is the same colours wherever it appears.
*
* Timed, because a fence is highlighted whole and a reply still arriving re-highlights its last
* block on every delta; the counter says what that costs before anybody has to guess.
*/
@Composable
fun highlighted(code: String, language: SyntaxLanguage?): AnnotatedString {
val theme = catppuccinSyntax()
val plain = MaterialTheme.colorScheme.onSurface
return remember(code, language, theme, plain) {
if (language == null) return@remember AnnotatedString(code)
val marks =
DebugStats.timed("code highlighted") {
Highlights.Builder(code = code, language = language, theme = theme)
.build()
.getHighlights()
// highlights 1.1.0's shell lexer answers a quoted glob that looks like a
// comment -- `x '*/a/*'` is the smallest input -- with a span whose end is
// before its start, and AnnotatedString refuses such a range. That crashed
// the app the moment a card holding `-path '*/.git/*'` was opened. Dropped
// rather than clamped: a span the lexer got backwards is not one it knows
// the colour of. Delete when snipme/highlights fixes it.
.filter {
it.location.start in 0..it.location.end && it.location.end <= code.length
}
}
buildAnnotatedString {
append(code)
marks.forEach { mark ->
when (mark) {
is ColorHighlight ->
addStyle(
SpanStyle(color = Color(mark.rgb or 0xFF000000.toInt())),
mark.location.start,
mark.location.end,
)
is BoldHighlight ->
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
mark.location.start,
mark.location.end,
)
}
}
}
}
}