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

@@ -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()
}
}