Warm a fence's highlighting like a parse, and page the restore by rows

Two things the measurements for the previous commit turned up.

Highlighting a fence cost 174ms for a two-hundred-line Kotlin block, and
the lazy list charged it again every time that block scrolled back into
composition -- six times in one bench run, with the scroll's draw phase at
1.29ms per frame. So it is warmed and cached where parses already are:
`highlight` is a plain function taking no colour from the theme, `warm`
fills `ParsedReplies.highlighted` from `fences(parse)` off the drawing
thread, and `fenceContent` extracts the code here rather than through the
library's composable, so the string warmed is the string drawn.

And the anchor restore asked for a span counted in events, which goes
negative when the anchor's row is the oldest half-row and was coerced to
one -- a request per delta, six hundred round trips walking one reply back
a word at a time with the spinner up. It asks for a page of rows now.

Clean pairs, fresh sessions each side, same gestures. Fence scroll
(transcript-bench.sh): draw phase 0.74ms per frame before highlighting
existed, 0.77ms after, no lexing in the window either side. Streaming
forty linked items (stream-bench.sh): 2412ms of reparsing before, 674ms
after; mean 5.0ms to 1.4ms, worst 8.9ms to 7.9ms. Bullet glyphs, fence
colours, the image links and the reference link checked on the emulator;
lint clean on AGP 9.4.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-03 18:50:29 -04:00
1 parent 6892dc7caf
commit ab6a797941
6 files changed
+344 -148

No files matched your search

@@ -8,7 +8,6 @@ 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
@@ -24,13 +23,16 @@ 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 com.mikepenz.markdown.model.State
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.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.ast.getTextInNode
/**
* A fenced code block in a reply: the code highlighted, on the dark surface every verbatim thing
@@ -47,18 +49,44 @@ import org.intellij.markdown.ast.ASTNode
* 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)
}
fun CodeFence(content: String, node: ASTNode, style: TextStyle, replies: ParsedReplies) {
val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return
CodeBlockText(code, language, style, replies)
}
/** 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)
fun CodeBlock(content: String, node: ASTNode, style: TextStyle, replies: ParsedReplies) {
val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return
CodeBlockText(code, language, style, replies)
}
/**
* The code inside a fence or indented block, and the lexer's language for its info word.
*
* Which children of the node are the fence markers, the language word and the code between them is
* the library's knowledge of the parser, copied from its `MarkdownCodeFence` rather than called:
* that one is a composable, and the whole point of this function is that [warm] can run it on a
* background thread and highlight the same string the drawing will ask for. Two extractions would
* be two keys, and the warmed answer would be silently missed at every fence.
*
* Null for a fence too short to hold anything -- an unterminated one still arriving, which the
* library skips as invalid.
*/
fun fenceContent(content: String, node: ASTNode): Pair<String, SyntaxLanguage?>? {
val word =
node.findChildOfType(MarkdownTokenTypes.FENCE_LANG)?.getTextInNode(content)?.toString()
val language = fenceLanguage(word)
if (node.type == MarkdownElementTypes.CODE_BLOCK) {
val start = node.children.firstOrNull()?.startOffset ?: return null
val end = node.children.lastOrNull()?.endOffset ?: return null
return content.substring(start, end).replaceIndent() to language
}
if (node.children.size < 3) return null
val start = node.children[2].startOffset
val fenceCount = if (word != null && node.children.size > 3) 3 else 2
val end = node.children[(node.children.size - 2).coerceAtLeast(fenceCount)].endOffset
return content.substring(start, end).replaceIndent() to language
}
/**
@@ -67,7 +95,12 @@ fun CodeBlock(content: String, node: ASTNode, style: TextStyle) {
* 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) {
private fun CodeBlockText(
code: String,
language: SyntaxLanguage?,
style: TextStyle,
replies: ParsedReplies,
) {
val colors = LocalMarkdownColors.current
val dimens = LocalMarkdownDimens.current
val padding = LocalMarkdownPadding.current
@@ -78,7 +111,7 @@ private fun CodeBlockText(code: String, language: String?, style: TextStyle) {
.semantics { isTraversalGroup = true }
) {
BasicText(
highlighted(code, fenceLanguage(language)),
replies.highlighted(code, language),
style = style,
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock),
)
@@ -147,48 +180,71 @@ private val FENCE_LANGUAGES: Map<String, SyntaxLanguage> =
* 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.
* Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it
* off the drawing thread: the syntax palette is fixed, and a fence with no language is plain text
* which needs no colour of its own -- the style the caller draws it with carries that.
*
* Measured on the emulator before it was cached: a two-hundred-line Kotlin fence costs **174ms** to
* lex, and the lazy list charged that again every time the block scrolled back into composition.
* That is the whole reason [ParsedReplies.highlighted] exists rather than a `remember`.
*/
@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,
)
fun highlight(code: String, language: SyntaxLanguage?): AnnotatedString {
if (language == null) return AnnotatedString(code)
val marks =
DebugStats.timed("code highlighted") {
Highlights.Builder(code = code, language = language, theme = catppuccinSyntax())
.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
}
}
return 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,
)
}
}
}
}
/**
* Every fence in [parse], as the code and language [highlight] will be asked for.
*
* Walks the whole tree rather than the top level: a fence inside a list item or a quote is drawn
* the same way and costs the same to lex.
*/
fun fences(parse: State): List<Pair<String, SyntaxLanguage?>> {
val success = parse as? State.Success ?: return emptyList()
val out = ArrayList<Pair<String, SyntaxLanguage?>>()
fun walk(node: ASTNode) {
if (
node.type == MarkdownElementTypes.CODE_FENCE ||
node.type == MarkdownElementTypes.CODE_BLOCK
) {
fenceContent(success.content, node)?.let { if (it.second != null) out += it }
return
}
node.children.forEach(::walk)
}
walk(success.node)
return out
}