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
}
@@ -27,6 +27,7 @@ import androidx.compose.ui.semantics.collectionInfo
import androidx.compose.ui.semantics.collectionItemInfo
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
@@ -54,6 +55,7 @@ import com.mikepenz.markdown.model.markdownAnimations
import com.mikepenz.markdown.model.markdownDimens
import com.mikepenz.markdown.model.markdownPadding
import com.mikepenz.markdown.model.parseMarkdown
import dev.snipme.highlights.model.SyntaxLanguage
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -91,7 +93,7 @@ fun MarkdownText(
var previousSegment: Segment? = null
segments.forEachIndexed { at, segment ->
val nextContinues = segments.getOrNull(at + 1)?.continues == true
MarkdownRoot(segment.parse) {
MarkdownRoot(segment.parse, replies) {
segment.pieces.forEachIndexed { index, piece ->
val gap =
when {
@@ -305,7 +307,7 @@ fun MarkdownPiece(
// Remembered so a message the flatten drew before [warm] reached it is parsed once here, not
// once per composition.
val parse = remember(text) { replies.of(text) }
MarkdownRoot(parse) { MarkdownPiece(parse, text, piece, modifier) }
MarkdownRoot(parse, replies) { MarkdownPiece(parse, text, piece, modifier) }
}
/**
@@ -328,7 +330,7 @@ fun MarkdownPiece(
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
*/
@Composable
private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
private fun MarkdownRoot(parse: State, replies: ParsedReplies, content: @Composable () -> Unit) {
if (parse !is State.Success) {
// Nothing below needs the environment; [MarkdownPiece] draws the words plainly.
content()
@@ -458,8 +460,8 @@ private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
table = { LinkedTable(it.content, it.node, it.typography.table) },
// Code is highlighted the way a tool call's input is; see [CodeFence].
codeFence = { CodeFence(it.content, it.node, it.typography.code) },
codeBlock = { CodeBlock(it.content, it.node, it.typography.code) },
codeFence = { CodeFence(it.content, it.node, it.typography.code, replies) },
codeBlock = { CodeBlock(it.content, it.node, it.typography.code, replies) },
),
content = content,
)
@@ -597,6 +599,17 @@ class ParsedReplies {
private val chunks = ConcurrentHashMap<String, List<String>>()
/**
* Each fence's coloured text, keyed by its language and code.
*
* Beside the parses for the same reason and at the same cost: lexing is proportional to how
* much code was written -- a two-hundred-line Kotlin fence measured 174ms on the emulator --
* and a lazy list drops the composition of a block that scrolls away, so a `remember` inside
* the fence paid that again every time the reader came back to it. Six times in one scroll,
* measured. [warm] fills this off the drawing thread before the row is reached.
*/
private val highlights = ConcurrentHashMap<String, AnnotatedString>()
private val ready = ConcurrentHashMap.newKeySet<String>()
/** The pieces of [text], from its parse -- made now if [warm] has not made it. */
@@ -633,6 +646,15 @@ class ParsedReplies {
DebugStats.timed("message cut into parts") { messageParts(it) }
}
/**
* [code] coloured for [language] -- the answer made ahead, or one made now.
*
* The key carries the language, because the same code lexes differently under two of them.
*/
fun highlighted(code: String, language: SyntaxLanguage?): AnnotatedString =
if (language == null) AnnotatedString(code)
else highlights.computeIfAbsent("$language\n$code") { highlight(code, language) }
/** The parse of [text] -- the one made ahead, or one made now. */
fun of(text: String): State =
parsed[text]?.also { DebugStats.count("markdown ready") }
@@ -649,9 +671,15 @@ class ParsedReplies {
*/
suspend fun warm(texts: List<String>) {
texts.forEach { text ->
parsed.computeIfAbsent(text) {
DebugStats.timed("markdown warmed") { parseMarkdown(it) }
}
val parse =
parsed.computeIfAbsent(text) {
DebugStats.timed("markdown warmed") { parseMarkdown(it) }
}
// The fences too, and here rather than in a pass of its own: they are found in the
// parse this just made, and lexing one is the same kind of cost as parsing the
// message it is in -- proportional to what was written, and charged to the frame
// that first draws it if nobody paid it earlier.
fences(parse).forEach { (code, language) -> highlighted(code, language) }
}
}
@@ -661,6 +689,7 @@ class ParsedReplies {
pieces.clear()
parts.clear()
chunks.clear()
highlights.clear()
ready.clear()
}
}
@@ -711,14 +711,26 @@ fun SessionScreen(
// Raw, not coalesced: this counts events back to a known seq, and a page
// measured in rows cannot be counted to a seq. `RESTORE_PAGE_MAX` and the loop
// bound it; see [loadOlderPage].
val span = oldestSeq - anchor.seq + RESTORE_PAGE_CUSHION
if (
!loadOlderPage(
span.coerceIn(1L, RESTORE_PAGE_MAX.toLong()).toInt(),
coalesce = false,
)
)
break
val behind = oldestSeq - anchor.seq
val loaded =
if (behind < 0) {
// The anchor's row is loaded but is the oldest half-row, which
// [anchorRow] refuses; what completes it is the row before it,
// and only a page counted in rows can promise to reach that.
// Counted in events, the span here is negative and was coerced
// to one: a request per delta, walking a long reply back one word
// at a time -- six hundred round trips and a spinner for all of
// them, seen 2026-09-03 with an anchor inside a 1,400-delta reply.
loadOlderPage()
} else {
loadOlderPage(
(behind + RESTORE_PAGE_CUSHION)
.coerceIn(1L, RESTORE_PAGE_MAX.toLong())
.toInt(),
coalesce = false,
)
}
if (!loaded) break
}
// Resolved to the row that *holds* the saved position rather than passed
// straight through, because the two are not always the same seq: the events
@@ -117,7 +117,9 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
// Not wrapped: a wrapped command hides where its arguments end,
// and the long one is the one being read closely.
Text(
highlighted(subject, parsed.language),
// Not cached: a tool's subject is one command line, which lexes in microseconds
// -- the cache exists for a fence with two hundred lines in it.
remember(subject, parsed.language) { highlight(subject, parsed.language) },
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
softWrap = false,
+41 -5
View File
@@ -38,17 +38,53 @@ if [ -z "$keep" ]; then
sleep 3
fi
# Pinned to the newest end before anything is sent. The transcript never
# moves under a reader who is further back (see TranscriptList), so a reply
# streaming into a session parked at an older row arrives entirely
# off-screen: nothing recomposes, nothing draws, and the report comes back
# with two recompositions in it and no streaming counters at all. That
# reads exactly like a build where the work vanished. The control is the
# app's own "Jump to latest", which is only there while the newest message
# is off screen -- so when it is absent the list is already where it needs
# to be.
jump=$(ui-trace record -d 1200 -o /tmp/bench-jump.txt >/dev/null 2>&1
ui-trace show /tmp/bench-jump.txt -m 'Jump to latest' --field box |
grep -o '[0-9]*,[0-9]*\.\.[0-9]*,[0-9]*' | tail -1)
if [ -n "$jump" ]; then
x=$(echo "$jump" | awk -F'[,.]' '{print int(($1 + $4) / 2)}')
y=$(echo "$jump" | awk -F'[,.]' '{print int(($2 + $5) / 2)}')
ui-trace record -d 1500 --do "tap $x $y" -o /tmp/bench-tolatest.txt >/dev/null 2>&1
fi
# The first tap resets the report's window; see transcript-bench.sh.
ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-reset.txt >/dev/null 2>&1
adb logcat -c
./ui-sandbox.sh send "$sid" "@$file" >/dev/null
# Until the echo has finished: its status goes back to idle.
# Until the reply has finished, measured by the transcript rather than by
# the status. A session is idle at both ends of a turn, and polling for
# "idle" answers on the first poll -- before the turn has even started --
# so the report then covers the moment between the send and the first
# delta, and prints a window with nothing in it. The event count only
# grows, so "it stopped growing" is the one signal that cannot be true
# before the work begins.
events() {
./ui-sandbox.sh api "/sessions/$sid/transcript?limit=1" |
python3 -c 'import json,sys; d=json.load(sys.stdin); print(d[-1]["seq"] if d else 0)'
}
last=""
still=0
i=0
while [ "$i" -lt 120 ]; do
status=$(./ui-sandbox.sh api "/sessions/$sid" |
python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')
[ "$status" = idle ] && break
while [ "$i" -lt 180 ]; do
now=$(events)
if [ "$now" = "$last" ]; then
still=$((still + 1))
[ "$still" -ge 2 ] && break
else
still=0
fi
last=$now
sleep 2
i=$((i + 1))
done