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:
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
|
||||
}
|
||||
Reference in new issue
Block a user