git.iko.soy/opencode-android
on a0ce9ce help sign in
632 lines 30 kB history raw
1@file:Suppress("TooManyFunctions")
3package soy.iko.opencode.ui.components
5import androidx.compose.material3.MaterialTheme
6import androidx.compose.runtime.Composable
7import androidx.compose.runtime.LaunchedEffect
8import androidx.compose.runtime.getValue
9import androidx.compose.runtime.mutableStateOf
10import androidx.compose.runtime.remember
11import androidx.compose.runtime.setValue
12import androidx.compose.ui.graphics.Color
13import androidx.compose.ui.text.AnnotatedString
14import androidx.compose.ui.text.SpanStyle
15import androidx.compose.ui.text.font.FontFamily
16import androidx.compose.ui.text.font.FontStyle
17import androidx.compose.ui.text.font.FontWeight
18import androidx.compose.ui.text.withStyle
19import kotlinx.coroutines.Dispatchers
20import kotlinx.coroutines.withContext
21import soy.iko.opencode.util.runCatchingCancellable
23/**
24 * Lightweight, dependency-free syntax highlighting for the file viewer.
25 *
26 * A full tokenizer (TreeSitter, Prism, etc.) would add a heavy native or generated
27 * dependency that conflicts with the project's no-KSP / no-native-deps stance. This
28 * heuristic highlighter instead colors comments, strings, numbers, and a per-language
29 * keyword set via simple character/word scanning — enough to make a code file scannable
30 * in the viewer without pulling in a parser.
31 *
32 * The highlighter is per-line (the viewer renders line-by-line), so multi-line block
33 * comments and triple-quoted strings are not handled; that's an accepted trade-off for
34 * a viewer (not an editor) where the goal is visual orientation, not correctness.
35 */
37/** Language families recognized by [highlightLine]. The set of keywords is shared
38 * across C-family languages (kotlin, java, js, ts, swift, rust, go, c, cpp) since the
39 * common keywords overlap heavily; python, ruby, shell, json each have their own. */
40internal enum class Language {
41 C_FAMILY, // kotlin, java, js, ts, swift, rust, go, c, cpp, csharp, scala
42 PYTHON,
43 SHELL, // sh, bash, zsh
44 MARKUP, // html, xml, svg
45 RUBY,
46 JSON,
47 NONE,
50private fun languageFor(filename: String): Language {
51 val ext = filename.substringAfterLast('.', missingDelimiterValue = "").lowercase()
52 return languageForToken(ext)
55/**
56 * Resolve a language from a markdown fence info string (e.g. "kotlin" from ```kotlin).
57 * Accepts both full language names and file extensions so a fence tagged either way
58 * (` ```py ` or ` ```python `) highlights. Lowercased by the caller via [languageForToken].
59 */
60internal fun languageForTag(tag: String): Language = languageForToken(tag.trim().lowercase())
62private fun languageForToken(token: String): Language = when (token) {
63 "kt", "kotlin", "kts", "ktm", "java", "js", "javascript", "mjs", "cjs",
64 "ts", "typescript", "tsx", "jsx",
65 "swift", "rs", "rust", "go", "golang", "c", "h", "cpp", "c++", "cc", "cxx", "hpp", "hh",
66 "cs", "csharp", "c#", "scala", "groovy", "gradle", "dart",
67 -> Language.C_FAMILY
68 "py", "python", "pyw" -> Language.PYTHON
69 "rb", "ruby" -> Language.RUBY
70 "sh", "bash", "zsh", "fish", "shell", "shellscript", "console" -> Language.SHELL
71 "html", "htm", "xml", "svg", "vue" -> Language.MARKUP
72 "json", "json5", "jsonc" -> Language.JSON
73 else -> Language.NONE
76private val cFamilyKeywords = setOf(
77 "abstract", "as", "assert", "async", "await", "break", "case", "catch", "class",
78 "const", "continue", "default", "defer", "do", "else", "enum", "export",
79 "extends", "external", "final", "finally", "fn", "for", "func", "fun", "go",
80 "if", "impl", "implements", "import", "in", "init", "inline", "instanceof", "is",
81 "let", "match", "mod", "module", "mut", "namespace", "new", "object", "of",
82 "override", "package", "private", "protected", "public", "pub", "raise", "return",
83 "sealed", "self", "static", "struct", "super", "suspend", "switch", "this", "throw",
84 "throws", "trait", "try", "type", "typeof", "union", "unsafe", "val", "var", "when",
85 "where", "while", "with", "yield", "true", "false", "null", "nil",
86 "and", "or", "not", "lambda", "elif", "pass", "print",
89private val pythonKeywords = setOf(
90 "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class",
91 "continue", "def", "del", "elif", "else", "except", "finally", "for", "from",
92 "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass",
93 "raise", "return", "try", "while", "with", "yield", "self", "cls", "print",
96private val shellKeywords = setOf(
97 "if", "then", "else", "elif", "fi", "for", "do", "done", "while", "case", "esac",
98 "function", "in", "return", "exit", "echo", "export", "local", "readonly", "unset",
99 "set", "shift", "source", "true", "false", "cd", "pwd", "ls", "cp", "mv", "rm",
102private val rubyKeywords = setOf(
103 "BEGIN", "END", "alias", "and", "begin", "break", "case", "class", "def", "defined?",
104 "do", "else", "elsif", "end", "ensure", "false", "for", "if", "in", "module", "next",
105 "nil", "not", "or", "redo", "rescue", "retry", "return", "self", "super", "then",
106 "true", "undef", "unless", "until", "when", "while", "yield", "__FILE__", "__LINE__",
109private val jsonKeywords = setOf("true", "false", "null")
111/** Theme-derived colors for the highlighter. Captured in a @Composable so the styles
112 * re-resolve when the color scheme changes (e.g. dynamic color or a theme switch). */
113data class HighlightPalette(
114 val keyword: Color,
115 val comment: Color,
116 val string: Color,
117 val number: Color,
118 val annotation: Color,
119 val tag: Color,
120 val base: Color,
123@Composable
124fun rememberHighlightPalette(): HighlightPalette {
125 // Use muted theme roles so the highlighting reads as part of the UI, not a foreign
126 // palette. Keyword = primary, string = secondary, comment = outline (dimmed),
127 // number = tertiary. Falls back gracefully when dynamic color is on (the roles
128 // come from the wallpaper-derived scheme).
129 val scheme = MaterialTheme.colorScheme
130 return remember(scheme) {
131 HighlightPalette(
132 keyword = scheme.primary,
133 comment = scheme.outline,
134 string = scheme.secondary,
135 number = scheme.tertiary,
136 annotation = scheme.tertiary,
137 tag = scheme.primary,
138 base = scheme.onSurface,
139 )
140 }
143/**
144 * Produce a highlighted [AnnotatedString] for a single line of source code.
145 *
146 * The scanner walks the line character by character, classifying runs of chars as
147 * comments, strings, numbers, identifiers (checked against the keyword set), or plain
148 * text. It's O(n) per line and allocation-light (a single AnnotatedString.Builder),
149 * which matters because the file viewer renders lazily but can scroll through
150 * thousands of lines — a heavy highlighter would jank on fast scrolls.
151 */
152fun highlightLine(line: String, filename: String, palette: HighlightPalette): AnnotatedString =
153 highlightLine(line, syntaxFor(filename), palette)
155/**
156 * A file's language classification, resolved once from its name. Resolving this per file
157 * (instead of re-parsing the extension inside [highlightLine] for every line) keeps the
158 * hot per-line highlight path free of the `substringAfterLast`/`lowercase` work.
159 */
160class FileSyntax internal constructor(
161 internal val lang: Language,
162 internal val keywords: Set<String>,
165/** Resolve the [FileSyntax] for a filename. Cheap; call once per file and reuse. */
166fun syntaxFor(filename: String): FileSyntax {
167 val lang = languageFor(filename)
168 return FileSyntax(lang, keywordsFor(lang))
171/**
172 * Resolve the [FileSyntax] from a markdown fence language tag (e.g. "kotlin", "py",
173 * "json"). Used by the chat code-block renderer, which only has the fence's info string
174 * (not a filename). Returns a [FileSyntax] with [Language.NONE] for unknown tags so the
175 * caller can short-circuit to plain text.
176 */
177fun syntaxForLanguageTag(tag: String): FileSyntax {
178 val lang = languageForTag(tag)
179 return FileSyntax(lang, keywordsFor(lang))
182/**
183 * Highlight a whole (possibly multi-line) code snippet into a single [AnnotatedString].
184 * Each line is highlighted independently via [highlightLine], because the per-line
185 * scanner carries no state across lines (so multi-line block comments/triple-quoted
186 * strings are not handled — an accepted trade-off documented on [highlightLine]).
187 *
188 * Used by the chat code-block renderer, which renders the whole block in one [Text].
189 * The file viewer renders line-by-line instead and calls [highlightLine] directly.
190 */
191fun highlightCode(code: String, syntax: FileSyntax, palette: HighlightPalette): AnnotatedString {
192 if (syntax.lang == Language.NONE) return AnnotatedString(code)
193 val builder = AnnotatedString.Builder(code.length + 16)
194 val firstNewline = code.indexOf('\n')
195 // Fast path for single-line code (common for short inline-ish fences): skip the
196 // split + per-line append loop and the leading-newline bookkeeping entirely.
197 if (firstNewline == -1) {
198 builder.append(highlightLine(code, syntax, palette))
199 return builder.toAnnotatedString()
200 }
201 var start = 0
202 var first = true
203 while (start <= code.length) {
204 val end = code.indexOf('\n', start)
205 val line = if (end == -1) code.substring(start) else code.substring(start, end)
206 if (!first) builder.append("\n")
207 builder.append(highlightLine(line, syntax, palette))
208 first = false
209 if (end == -1) break
210 start = end + 1
211 }
212 return builder.toAnnotatedString()
215/** Highlight a single line using a pre-resolved [FileSyntax]. See [FileSyntax]. */
216fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette): AnnotatedString {
217 val lang = syntax.lang
218 if (lang == Language.NONE) return AnnotatedString(line)
219 val keywords = syntax.keywords
220 val builder = AnnotatedString.Builder(line.length + 8)
221 val baseStyle = SpanStyle(color = palette.base, fontFamily = FontFamily.Monospace)
222 val mono = FontFamily.Monospace
223 var i = 0
224 val n = line.length
225 while (i < n) {
226 val c = line[i]
227 val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
228 if (emitted.consumed) {
229 i = emitted.nextIndex
230 } else {
231 builder.withStyle(baseStyle) { append(c) }
232 i++
233 }
234 }
235 return builder.toAnnotatedString()
238/**
239 * State carried across lines while highlighting a multi-line file, so block comments
240 * (C-family slash-star, Ruby =begin/=end) and triple-quoted strings (Python/Kotlin)
241 * are colored correctly on every line they span, not just the opening line.
242 *
243 * The per-line [highlightLine] carries no state and is still the right call for chat
244 * code fences (which render a whole snippet in one Text). The file viewer renders
245 * line-by-line and should use the stateful [highlightLine] overload instead so a
246 * C-family file with a 50-line block comment doesn't lose highlighting on lines 2..49.
247 */
248class BlockCommentState {
249 /** True when the previous line ended inside an unclosed C-family block comment. */
250 internal var inBlockComment: Boolean = false
251 /** True when the previous line ended inside an unclosed triple-quoted string. */
252 internal var inTripleString: Boolean = false
255/** Highlight a single line, carrying [state] across calls so multi-line block comments and
256 * triple-quoted strings stay colored on every line they span. Mutates [state] in place.
257 * Use this from line-by-line renderers (the file viewer); use [highlightLine] / [highlightCode]
258 * for self-contained snippets (chat code fences) where cross-line state isn't worth tracking. */
259fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette, state: BlockCommentState): AnnotatedString {
260 val lang = syntax.lang
261 if (lang == Language.NONE) return AnnotatedString(line)
262 val keywords = syntax.keywords
263 val builder = AnnotatedString.Builder(line.length + 8)
264 val baseStyle = SpanStyle(color = palette.base, fontFamily = FontFamily.Monospace)
265 val mono = FontFamily.Monospace
266 val commentStyle = SpanStyle(color = palette.comment, fontStyle = FontStyle.Italic, fontFamily = mono)
267 val stringStyle = SpanStyle(color = palette.string, fontFamily = mono)
269 // If we're continuing inside an open block comment, scan for the closing `*/` first;
270 // everything before it is comment-colored. If none exists on this line, the whole line
271 // is comment-colored and we stay in the block.
272 if (state.inBlockComment) {
273 val closeIdx = line.indexOf("*/")
274 if (closeIdx < 0) {
275 builder.withStyle(commentStyle) { append(line) }
276 return builder.toAnnotatedString()
277 }
278 val end = closeIdx + 2
279 builder.withStyle(commentStyle) { append(line.substring(0, end)) }
280 state.inBlockComment = false
281 // Continue highlighting the remainder of the line normally.
282 var i = end
283 val n = line.length
284 while (i < n) {
285 val c = line[i]
286 // An opening `/*` inside the remainder re-enters the block.
287 if (isCBlockCommentOpen(c, line, i, n, lang)) {
288 val close = findBlockCommentEnd(line, i + 2)
289 if (close < 0) {
290 builder.withStyle(commentStyle) { append(line.substring(i)) }
291 state.inBlockComment = true
292 return builder.toAnnotatedString()
293 }
294 builder.withStyle(commentStyle) { append(line.substring(i, close)) }
295 i = close
296 continue
297 }
298 val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
299 if (emitted.consumed) {
300 i = emitted.nextIndex
301 } else {
302 builder.withStyle(baseStyle) { append(c) }
303 i++
304 }
305 }
306 return builder.toAnnotatedString()
307 }
309 // If we're continuing inside a triple-quoted string, scan for the closing `"""`.
310 if (state.inTripleString) {
311 val closeIdx = line.indexOf("\"\"\"")
312 if (closeIdx < 0) {
313 builder.withStyle(stringStyle) { append(line) }
314 return builder.toAnnotatedString()
315 }
316 val end = closeIdx + 3
317 builder.withStyle(stringStyle) { append(line.substring(0, end)) }
318 state.inTripleString = false
319 var i = end
320 val n = line.length
321 while (i < n) {
322 val c = line[i]
323 val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
324 if (emitted.consumed) {
325 i = emitted.nextIndex
326 } else {
327 builder.withStyle(baseStyle) { append(c) }
328 i++
329 }
330 }
331 return builder.toAnnotatedString()
332 }
334 // Not currently in a block — run the normal per-line scanner, but intercept `/*` and `"""`
335 // openings so we can flip state and color the rest of the line accordingly.
336 var i = 0
337 val n = line.length
338 while (i < n) {
339 val c = line[i]
340 // Block comment open: C-family `/* ... */`. (Ruby's `=begin`/`=end` is handled below.)
341 if (isCBlockCommentOpen(c, line, i, n, lang)) {
342 val close = findBlockCommentEnd(line, i + 2)
343 if (close < 0) {
344 builder.withStyle(commentStyle) { append(line.substring(i)) }
345 state.inBlockComment = true
346 return builder.toAnnotatedString()
347 }
348 builder.withStyle(commentStyle) { append(line.substring(i, close)) }
349 i = close
350 continue
351 }
352 // Triple-quoted string open: Python/Kotlin `""" ... """`. Only track for languages
353 // that actually have triple-quoted strings; in plain C/JS a `"` run is just adjacent
354 // string literals.
355 if (isTripleStringOpen(c, line, i, n, lang)) {
356 val close = line.indexOf("\"\"\"", i + 3)
357 if (close < 0) {
358 builder.withStyle(stringStyle) { append(line.substring(i)) }
359 state.inTripleString = true
360 return builder.toAnnotatedString()
361 }
362 val end = close + 3
363 builder.withStyle(stringStyle) { append(line.substring(i, end)) }
364 i = end
365 continue
366 }
367 // Ruby `=begin` … `=end` block comment (must be at the start of the line per Ruby
368 // syntax; we check the leading-run here).
369 if (isRubyBlockCommentOpen(c, line, i, lang)) {
370 val close = line.indexOf("=end", i + 6)
371 if (close < 0) {
372 builder.withStyle(commentStyle) { append(line.substring(i)) }
373 state.inBlockComment = true
374 return builder.toAnnotatedString()
375 }
376 val end = close + 4
377 builder.withStyle(commentStyle) { append(line.substring(i, end)) }
378 i = end
379 continue
380 }
381 val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
382 if (emitted.consumed) {
383 i = emitted.nextIndex
384 } else {
385 builder.withStyle(baseStyle) { append(c) }
386 i++
387 }
388 }
389 return builder.toAnnotatedString()
392/** Index just past the closing block-comment delimiter for a block comment opened at [from],
393 * or -1 if the line ends before the block closes. */
394private fun findBlockCommentEnd(line: String, from: Int): Int {
395 val idx = line.indexOf("*/", from)
396 return if (idx < 0) -1 else idx + 2
399/** True when [line] at [i] opens a C-family block comment (`/` followed by `*`). */
400private fun isCBlockCommentOpen(c: Char, line: String, i: Int, n: Int, lang: Language): Boolean =
401 c == '/' && i + 1 < n && line[i + 1] == '*' && lang == Language.C_FAMILY
403/** True when [line] at [i] opens a triple-quoted string (three double-quotes), for languages
404 * that support it (Python, Kotlin/C-family). */
405private fun isTripleStringOpen(c: Char, line: String, i: Int, n: Int, lang: Language): Boolean =
406 c == '"' && i + 2 < n && line[i + 1] == '"' && line[i + 2] == '"' &&
407 (lang == Language.PYTHON || lang == Language.C_FAMILY)
409/** True when [line] at [i] opens a Ruby `=begin` block comment. Must be at the start of the
410 * line or preceded by whitespace per Ruby syntax. */
411private fun isRubyBlockCommentOpen(c: Char, line: String, i: Int, lang: Language): Boolean =
412 lang == Language.RUBY && c == '=' && line.startsWith("=begin", i) &&
413 (i == 0 || line[i - 1] == ' ' || line[i - 1] == '\t')
415/** Result of attempting to emit one token starting at [start]: the next scan index and
416 * whether a token was consumed (vs. falling through to the default plain-text emit). */
417private data class EmitResult(val nextIndex: Int, val consumed: Boolean)
419private fun emitToken(
420 builder: AnnotatedString.Builder,
421 line: String,
422 start: Int,
423 lang: Language,
424 keywords: Set<String>,
425 palette: HighlightPalette,
426 baseStyle: SpanStyle,
427 mono: FontFamily,
428): EmitResult {
429 val c = line[start]
430 if (isLineCommentStart(c, line, start, lang)) {
431 builder.withStyle(SpanStyle(color = palette.comment, fontStyle = FontStyle.Italic, fontFamily = mono)) {
432 append(line.substring(start))
433 }
434 return EmitResult(line.length, true)
435 }
436 if (c == '"' || c == '\'') {
437 val end = findStringEnd(line, start, c)
438 builder.withStyle(SpanStyle(color = palette.string, fontFamily = mono)) { append(line.substring(start, end)) }
439 return EmitResult(end, true)
440 }
441 if (isNumberStart(c, line, start)) {
442 val end = findNumberEnd(line, start)
443 builder.withStyle(SpanStyle(color = palette.number, fontFamily = mono)) { append(line.substring(start, end)) }
444 return EmitResult(end, true)
445 }
446 if (c.isLetter() || c == '_' || c == '@') {
447 val end = findIdentifierEnd(line, start)
448 val word = line.substring(start, end)
449 when {
450 c == '@' -> builder.withStyle(SpanStyle(color = palette.annotation, fontFamily = mono)) { append(word) }
451 word in keywords -> builder.withStyle(SpanStyle(color = palette.keyword, fontWeight = FontWeight.SemiBold, fontFamily = mono)) { append(word) }
452 else -> builder.withStyle(baseStyle) { append(word) }
453 }
454 return EmitResult(end, true)
455 }
456 if (lang == Language.MARKUP && isMarkupDelim(c)) {
457 builder.withStyle(SpanStyle(color = palette.tag, fontFamily = mono)) { append(c) }
458 return EmitResult(start + 1, true)
459 }
460 return EmitResult(start + 1, false)
463private fun keywordsFor(lang: Language): Set<String> = when (lang) {
464 Language.C_FAMILY -> cFamilyKeywords
465 Language.PYTHON -> pythonKeywords
466 Language.SHELL -> shellKeywords
467 Language.RUBY -> rubyKeywords
468 Language.JSON -> jsonKeywords
469 Language.MARKUP -> emptySet()
470 Language.NONE -> emptySet()
473private fun isLineCommentStart(c: Char, line: String, i: Int, lang: Language): Boolean =
474 // `//` is a line comment only in C-family languages. Elsewhere it isn't: e.g. Python's
475 // `a // b` is floor division, which must not be dimmed as a comment to end-of-line.
476 (c == '/' && lang == Language.C_FAMILY && i + 1 < line.length && line[i + 1] == '/') ||
477 // `#` is a line comment in Python, shell and Ruby. It is NOT one in C-family
478 // (`#include`, `#define`, C# `#region`, JS/TS `this.#field`) or markup.
479 (c == '#' && (lang == Language.PYTHON || lang == Language.SHELL || lang == Language.RUBY))
481private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
483private fun isNumberStart(c: Char, line: String, i: Int): Boolean =
484 c.isDigit() || (c == '.' && i + 1 < line.length && line[i + 1].isDigit())
486/** Scan forward to the end of a string literal starting at [start] (the opening quote
487 * is line[start]). Returns the index just past the closing quote (or line.length if
488 * the string is unterminated on this line). Honors backslash escapes. */
489private fun findStringEnd(line: String, start: Int, quote: Char): Int {
490 var i = start + 1
491 val n = line.length
492 while (i < n && line[i] != quote) {
493 if (line[i] == '\\' && i + 1 < n) i += 2 else i++
494 }
495 return if (i < n) i + 1 else n
498/** Scan forward to the end of a number literal starting at [start].
499 *
500 * A small state machine so we don't over-consume neighbouring source: `5-3`, `1..10`
501 * and `123abc` must split rather than merge into one token.
502 * - `+`/`-` are consumed only immediately after an `e`/`E` (an exponent sign).
503 * - `e`/`E` continues a decimal number at most once, and never after a `0x` prefix.
504 * - hex digits `a-f`/`A-F` and the `x`/`X` marker are consumed only for a `0x`/`0X` literal.
505 * - a single `.` continues a decimal number; a second `.` stops the scan (so `1..10` splits).
506 */
507private fun findNumberEnd(line: String, start: Int): Int {
508 val n = line.length
509 // Detect a hex prefix ("0x"/"0X") right at the number start.
510 val hex = start + 1 < n && line[start] == '0' && (line[start + 1] == 'x' || line[start + 1] == 'X')
511 var i = if (hex) start + 2 else start + 1
512 var seenDot = line[start] == '.'
513 var seenExp = false
514 while (i < n) {
515 val c = line[i]
516 val prev = line[i - 1]
517 when {
518 c.isDigit() || c == '_' -> i++
519 hex && isHexDigit(c) -> i++
520 c == '.' && !hex && !seenDot && !seenExp -> { seenDot = true; i++ }
521 !hex && !seenExp && (c == 'e' || c == 'E') -> { seenExp = true; i++ }
522 !hex && (c == '+' || c == '-') && (prev == 'e' || prev == 'E') -> i++
523 else -> break
524 }
525 }
526 return i
529private fun isHexDigit(c: Char): Boolean = c in 'a'..'f' || c in 'A'..'F'
531/** Scan forward to the end of an identifier starting at [start]. */
532private fun findIdentifierEnd(line: String, start: Int): Int {
533 var i = start
534 if (line[i] == '@') i++
535 val n = line.length
536 while (i < n && isIdentifierPartChar(line[i])) i++
537 return i
540private fun isIdentifierPartChar(c: Char): Boolean =
541 c.isLetterOrDigit() || c == '_' || c == '$'
543// --- Off-main highlighting with a cross-message LRU cache ---
545/**
546 * A small bounded LRU cache for highlighted [AnnotatedString]s, keyed by
547 * `(language tag, code, palette identity hash)`. Shared across all code blocks in the app
548 * (chat fences, file viewer), so two identical short fences don't re-tokenize, and a
549 * scrolling viewer reusing the same lines doesn't re-highlight.
550 *
551 * The palette is hashed (not held by reference equality) because [HighlightPalette] is a
552 * data class — its `equals`/`hashCode` cover all theme colors, so a palette instance is
553 * cache-equivalent across recompositions that resolve the same color scheme. Hashing it
554 * into the key string keeps the cache a flat `LinkedHashMap` with no nested map overhead.
555 *
556 * Bounded to [HIGHLIGHT_CACHE_MAX] entries; access-ordered so the least-recently-used
557 * entry is evicted. Synchronized because the cache is touched from both the main thread
558 * (cache lookup before the off-main fallback) and the `Dispatchers.Default` workers that
559 * populate it. In practice contention is low (a lookup is a hash hit; a populate happens
560 * once per unique block).
561 */
562private const val HIGHLIGHT_CACHE_MAX = 128
563private val highlightCache = object : LinkedHashMap<String, AnnotatedString>(64, 0.75f, true) {
564 override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, AnnotatedString>): Boolean =
565 size > HIGHLIGHT_CACHE_MAX
568private fun highlightCacheKey(code: String, syntax: FileSyntax?, palette: HighlightPalette): String =
569 // Palette hashCode covers all 7 colors (data class auto-generated hashCode). The syntax
570 // language enum + keyword set reference is stable for a given language tag (the sets are
571 // module-level singletons), so its identity hashCode is a stable cache bucket.
572 "${syntax?.lang ?: "none"}:${syntax?.keywords?.hashCode() ?: 0}:${palette.hashCode()}:${code.hashCode()}::${code.length}"
574/**
575 * Look up a cached highlight for `(code, syntax, palette)`, computing it on the calling
576 * thread (synchronously) on miss. Used by the off-main worker to populate the cache and
577 * by [rememberHighlightedCode] for a synchronous first-frame fallback.
578 */
579private fun highlightCached(code: String, syntax: FileSyntax?, palette: HighlightPalette): AnnotatedString {
580 val key = highlightCacheKey(code, syntax, palette)
581 synchronized(highlightCache) { highlightCache[key] }?.let { return it }
582 val result = if (syntax != null) highlightCode(code, syntax, palette) else AnnotatedString(code)
583 synchronized(highlightCache) { highlightCache[key] = result }
584 return result
587/**
588 * Resolve syntax highlighting for [code] off the main thread, falling back to a cached or
589 * synchronous result for the first frame so the block doesn't flash plain-text before the
590 * highlighted version is ready.
591 *
592 * - On first composition (or when `code`/`syntax`/`palette` change), the synchronous
593 * [highlightCached] runs on the main thread to produce an immediate result. For a short
594 * fence this is fast (sub-millisecond); for a long fence the cache usually already has
595 * the result from a prior composition of the same block (e.g. scrolled away and back).
596 * - A `LaunchedEffect` then re-computes on `Dispatchers.Default` and swaps in the result.
597 * If the synchronous result already matched (cache hit), the off-main pass is a no-op
598 * swap of an equal AnnotatedString — cheap.
599 *
600 * This replaces a direct `remember(code, syntax, palette) { highlightCode(...) }` which
601 * ran the full tokenizer on the main thread on every cache miss — during streaming, a
602 * growing code fence missed on every ~50ms throttle commit, re-tokenizing the whole
603 * growing block each time (O(n²) over the stream). The off-main pass keeps the main
604 * thread free for layout/draw, and the cross-message cache means a re-displayed block
605 * (scroll recycle, theme switch) is a hash hit.
606 *
607 * For the streaming case, callers should still throttle how often `code` updates (the
608 * markdown renderer's 50ms throttle does this); this function then bounds the per-update
609 * main-thread cost to a cache lookup.
610 */
611@Composable
612fun rememberHighlightedCode(code: String, syntax: FileSyntax?, palette: HighlightPalette): AnnotatedString {
613 // Synchronous first-frame result (cache hit or compute). For a long block this can
614 // take a few ms on the main thread the first time it's seen; subsequent compositions
615 // of the same block (scroll recycle) are a pure cache hit.
616 val initial = remember(code, syntax, palette) { highlightCached(code, syntax, palette) }
617 var result by remember(code, syntax, palette) { mutableStateOf(initial) }
618 // Recompute off-main; swaps in the (possibly identical) result when ready. Skipped
619 // work: if the cache already held the exact entry, the off-main pass recomputes and
620 // assigns an equal string — a cheap no-op for Compose (no recomposition since the
621 // State value is structurally equal). The off-main pass still populates the cache for
622 // future callers. For a cache hit on a very long block, we could skip the LaunchedEffect
623 // entirely, but the cost of the off-main recompute is exactly what we want to keep off
624 // the main thread, and the cache makes the main-thread lookup a hit next time.
625 LaunchedEffect(code, syntax, palette) {
626 val offMain = withContext(Dispatchers.Default) {
627 runCatchingCancellable { highlightCached(code, syntax, palette) }.getOrDefault(initial)
628 }
629 result = offMain
630 }
631 return result