Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt
T
irisandClaude Opus 5 edc39c7371 Thin the app's comments
The same pass the server had, on the Kotlin side: comments restating what
the code says are gone, and the ones recording a measurement, a constraint
or an incident are kept but cut to a few lines each. 6540 comment lines to
5674, and 920 lines off the app.

Two doc comments had drifted onto the item above the one they describe --
`contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s
equivalent on the server was fixed in the previous commit. Each is back on
its own item, which is the only non-comment line this diff moves.

The comments are reflowed to the column limit at their own indentation:
several were written wide, and ktfmt re-wrapped them into lines holding a
single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the
result, which is the check.

Left alone deliberately: this codebase's remaining comment density is high
because the comments carry things the code cannot say -- what a null means,
what a number was measured against, which bug a guard exists for. Of the
238 one-line doc comments in the app, five were pure restatement of the
name and were removed; the rest each say something the signature does not.

ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass;
cargo test (127), clippy --all-targets and fmt still clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:20:16 -04:00

668 lines
32 KiB
Kotlin

package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.semantics.CollectionInfo
import androidx.compose.ui.semantics.CollectionItemInfo
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
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalImageTransformer
import com.mikepenz.markdown.compose.LocalMarkdownAnimations
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownComponents
import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.LocalMarkdownPadding
import com.mikepenz.markdown.compose.LocalMarkdownTypography
import com.mikepenz.markdown.compose.LocalReferenceLinkHandler
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownDivider
import com.mikepenz.markdown.compose.elements.listDepth
import com.mikepenz.markdown.m3.elements.MarkdownCheckBox
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.NoOpImageTransformerImpl
import com.mikepenz.markdown.model.State
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 java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.flavours.gfm.GFMElementTypes
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
/**
* [text] drawn as its pieces, one under the other; see [Piece].
*
* [live] is the reply still arriving, and two things are different for it. Its parse is incremental
* -- see [LiveParse] -- so a delta costs a parse of the block it landed in rather than of the whole
* message. And its pieces get a layer each, so only the piece that changed is re-recorded. That is
* worth a great deal while every delta invalidates the message and worth nothing once it stops
* changing -- and it is not free: each layer is a layout node and a display list held for the life
* of the row, and live node count is what the transcript's per-frame cost scales with.
*/
@Composable
fun MarkdownText(
text: String,
replies: ParsedReplies,
modifier: Modifier = Modifier,
live: Boolean = false,
) {
val segments =
if (live) liveSegments(text)
else remember(text) { listOf(Segment(text, 0, replies.of(text), replies.piecesOf(text))) }
Column(modifier.fillMaxWidth()) {
var previous: Piece? = null
var previousSegment: Segment? = null
segments.forEachIndexed { at, segment ->
val nextContinues = segments.getOrNull(at + 1)?.continues == true
// Only the tail is still being written; a frozen segment is finished text that happens
// to sit in a live reply, and it takes its colours now.
MarkdownRoot(segment.parse, replies, streaming = live && at == segments.lastIndex) {
segment.pieces.forEachIndexed { index, piece ->
val gap =
when {
previousSegment == null -> 0.dp
previousSegment !== segment ->
if (segment.continues) 0.dp else BLOCK_SPACING
else -> gapBefore(previous, piece)
}
// Keyed by where the piece starts in the message rather than by its position in
// this column, so a delta landing in the last block leaves every other piece's
// composition alone -- and a block keeps its key when it freezes.
key(segment.start, piece) {
MarkdownPiece(
segment.parse,
segment.text,
piece,
Modifier.padding(top = gap)
.then(if (live) Modifier.graphicsLayer() else Modifier)
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record(
"record: one block",
System.nanoTime() - started,
)
},
continuesList = segment.continues && index == 0,
listContinues = nextContinues && index == segment.pieces.lastIndex,
)
}
previous = piece
previousSegment = segment
}
}
}
}
}
/**
* A stretch of a message with a parse of its own: the whole of a settled message, or one block, the
* finished items of one list, or the unfinished tail of a live one. [start] is where [text] begins
* in the message. [continues] says the first piece is an item of the list the segment before it
* ended with, so the two draw as one list.
*/
private class Segment(
val text: String,
val start: Int,
val parse: State,
val pieces: List<Piece>,
val continues: Boolean = false,
)
/**
* The live reply's segments: parsed on the composing thread the first time the row is drawn, and
* incrementally off it for every delta afterwards.
*
* The first parse has to be inline. The renderer's own asynchronous path draws an empty loading
* slot until its result arrives, so a row is measured at nothing before it is measured at its real
* height, and the transcript above it collapses and springs back -- seen with five replies on
* screen at once, the whole conversation shrunk to fit a single screen.
*
* Every parse after the first is off the composing thread, and the row keeps drawing the parse it
* already has until the new one lands, so there is never a frame without a height.
*/
@Composable
private fun liveSegments(text: String): List<Segment> {
val parsed = remember {
mutableStateOf(
DebugStats.timed("markdown parsed while composing") { LiveParse.whole(text) }
)
}
LaunchedEffect(text) {
if (parsed.value.text == text) return@LaunchedEffect
val previous = parsed.value
parsed.value =
withContext(Dispatchers.Default) {
DebugStats.timed("markdown reparsed while streaming") { previous.advanceTo(text) }
}
}
return parsed.value.segments
}
/**
* A reply still arriving, parsed a block at a time.
*
* Reparsing the whole message per delta was fine for a short reply and not for a long one: a
* twenty-five-screen reply parses in tens of milliseconds, hundreds of times, and although that ran
* off the composing thread it was every core busy while the frame's own thread waited for one.
*
* Markdown's blocks make the cut safe: a top-level block that another block has started *after* is
* finished -- nothing appended later can reach back into it. So every block but the last is
* [frozen] with the parse that finished it, and only the tail is parsed again.
*
* A list is cut once more, at its last item, by the same reasoning one level down. Without this a
* reply that is one long list -- forty sources -- parsed the whole list per delta. The item the cut
* lands on has to have begun in earnest: a bare `-` is an empty item now and the first character of
* a paragraph line once `-x` arrives.
*
* What the cut gives up is one thing: a reference definition arriving later than a link that uses
* it. The link draws as its brackets until the reply settles and is parsed whole by [warm].
*/
private class LiveParse(
val text: String,
private val frozen: List<Segment>,
/** How much of [text] the frozen segments cover; the tail starts here. */
private val consumed: Int,
private val tail: Segment,
) {
val segments: List<Segment>
get() = frozen + tail
fun advanceTo(next: String): LiveParse {
// Anything but an append to what was frozen -- a message replaced, a stream reset -- starts
// over.
if (!next.regionMatches(0, text, 0, consumed)) return whole(next)
val tailText = next.substring(consumed)
val parse = parseMarkdown(tailText)
val all = pieces(parse)
val open = (parse as? State.Success)?.let { openPiece(it, all) }
if (open == null) {
return LiveParse(
next,
frozen,
consumed,
Segment(tailText, consumed, parse, all, tail.continues),
)
}
val done =
all.subList(0, all.indexOf(open))
.groupBy { it.block }
.values
.mapIndexed { at, pieces ->
Segment(
tailText,
consumed,
parse,
pieces,
continues = at == 0 && tail.continues,
)
}
// Cut at the start of the open piece's line rather than at the piece, so an indented item
// or block keeps the indentation the parse of the rest reads its nesting from.
val node =
parse.node.children[open.block].let {
if (open.item == Piece.WHOLE_BLOCK) it else it.listItems()[open.item]
}
val cut = tailText.lastIndexOf('\n', node.startOffset) + 1
val rest = tailText.substring(cut)
val restParse = parseMarkdown(rest)
return LiveParse(
next,
frozen + done,
consumed + cut,
Segment(rest, consumed + cut, restParse, pieces(restParse), continues = open.item > 0),
)
}
/**
* The piece of the tail still being written: the last item of a list of several, or the first
* piece of the last block when there is more than one. Null when nothing before it is finished.
*/
private fun openPiece(parse: State.Success, all: List<Piece>): Piece? {
val last = all.lastOrNull() ?: return null
val lastBlockStart = all.indexOfFirst { it.block == last.block }
return when {
last.item > 0 && parse.node.children[last.block].listItems()[last.item].hasBegun -> last
lastBlockStart > 0 -> all[lastBlockStart]
else -> null
}
}
/** Whether a list item holds anything beyond its marker yet. */
private val ASTNode.hasBegun: Boolean
get() = children.any { it.type !in MARKER_TOKENS }
companion object {
fun whole(text: String): LiveParse {
val parse = parseMarkdown(text)
return LiveParse(text, emptyList(), 0, Segment(text, 0, parse, pieces(parse)))
}
private val MARKER_TOKENS =
setOf(
MarkdownTokenTypes.LIST_BULLET,
MarkdownTokenTypes.LIST_NUMBER,
MarkdownTokenTypes.WHITE_SPACE,
MarkdownTokenTypes.EOL,
)
}
}
/** One [piece] of [text], drawn on its own -- a unit of the transcript list. */
@Composable
fun MarkdownPiece(
text: String,
piece: Piece,
replies: ParsedReplies,
modifier: Modifier = Modifier,
) {
// 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, replies) { MarkdownPiece(parse, text, piece, modifier) }
}
/**
* The renderer's own environment -- its colours, type scale, dimensions, component table and
* reference links -- around whatever draws pieces of [parse].
*
* The parsing is the library's: markdown is somebody else's specification, and a hand-written
* parser would get the edge cases wrong one case at a time. So is the environment: the element
* composables its dispatch reaches read these locals, and providing them once here is what lets a
* piece be drawn anywhere -- in a message's column, or as one item of the transcript list.
*
* The locals are provided directly rather than through the renderer's `Markdown()` composable,
* which was the last of its composables on the hot path and was here only to provide them. So
* nothing between a piece and the screen is the library's but the leaf composables named in the
* component table.
*
* Colours come from the theme rather than the renderer's defaults. Nothing here picks one of its
* own.
*
* [streaming] says this parse is the part of a reply still being written, which only the fences
* care about: lexing is proportional to how much code there is. Measured streaming a two-hundred-
* line Kotlin fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms --
* for colours on text being replaced as fast as they were computed. So a fence still being written
* is drawn plain and takes its colours when the block freezes.
*/
@Composable
private fun MarkdownRoot(
parse: State,
replies: ParsedReplies,
streaming: Boolean = false,
content: @Composable () -> Unit,
) {
if (parse !is State.Success) {
// Nothing below needs the environment; [MarkdownPiece] draws the words plainly.
content()
return
}
val body = MaterialTheme.typography.bodyLarge
CompositionLocalProvider(
LocalReferenceLinkHandler provides parse.referenceLinkHandler,
LocalMarkdownPadding provides markdownPadding(),
// Read by the renderer's own text composable, which no paragraph reaches any more, and by
// its checkbox. Provided so a path that does reach them draws no image rather than failing
// to compose.
LocalImageTransformer provides remember { NoOpImageTransformerImpl() },
LocalMarkdownAnimations provides markdownAnimations(),
LocalMarkdownColors provides
markdownColor(
text = MaterialTheme.colorScheme.onSurface,
dividerColor = MaterialTheme.colorScheme.outlineVariant,
// The dark surface every verbatim thing in this app sits on -- and the tool call
// above this reply, which now matches. `surfaceVariant` was exactly a card's own
// fill, so a fenced block inside a tool call had no background at all.
codeBackground = rawSurface,
// The same colour. Not drawn by the renderer as a span background but by
// [LinkedText] behind the text, so a selection lands on top of it -- see
// `appendCodeChip`.
inlineCodeBackground = rawSurface,
// The same tint a code block gets, rather than the renderer's 2%-alpha default: two
// adjacent tints that differ by a fiftieth read as one flat block on a phone.
tableBackground = MaterialTheme.colorScheme.surfaceVariant,
),
LocalMarkdownTypography provides
markdownTypography(
// A ladder that starts near the body text and descends, because these are headings
// inside a chat message rather than the top of a document. The renderer's defaults
// are the Material *display* styles -- `#` came out at 57sp, bigger than this app's
// own screen titles. Every step is a different size, so two levels of nesting never
// draw the same.
h1 = MaterialTheme.typography.headlineSmall,
h2 = MaterialTheme.typography.titleLarge,
h3 = MaterialTheme.typography.titleMedium,
h4 = MaterialTheme.typography.titleSmall,
h5 = MaterialTheme.typography.labelMedium,
h6 = MaterialTheme.typography.labelSmall,
text = body,
paragraph = body,
ordered = body,
bullet = body,
list = body,
table = body,
// Code in a monospace face, in the ordinary text colour. The face and the tinted
// background are what say "this is code"; colour is not, and it used to be green --
// the palette's colour for a *literal*. A block of code is not a literal, and
// painting all of it green said the whole block was one. Where a literal really
// does appear inside code, what should colour it is a syntax highlighter.
code =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
),
inlineCode =
body.copy(
fontFamily = FontFamily.Monospace,
// Unspecified so an inline span keeps the size of the line it sits in.
fontSize = TextUnit.Unspecified,
color = MaterialTheme.colorScheme.onSurface,
),
textLink =
TextLinkStyles(
style =
body
.copy(
color = linkColor,
textDecoration = TextDecoration.Underline,
)
.toSpanStyle()
),
),
LocalMarkdownDimens provides
markdownDimens(
// Half the renderer's 16dp. Padding is charged on both sides of every cell, so at
// the default a fifth of the narrowest column went on space rather than on words.
tableCellPadding = 8.dp,
// What a column narrows to before the table starts scrolling sideways instead. It
// is the floor, not the width: a table with room to spare spreads across it.
//
// Down from the renderer's 160dp, and the number is a measurement rather than a
// taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp
// makes even a three-column table scroll, while 136dp fits three across the phone
// this app is read on. Four and up still scroll, which is the right answer for
// genuinely too many columns. This is the widest minimum that keeps three on
// screen.
tableCellWidth = 136.dp,
),
LocalMarkdownComponents provides
markdownComponents(
// The m3 renderer's own default, restored: supplying `components` at all replaces
// the whole set, and this is the only member the Material layer overrides.
checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) },
// Everything that draws a run of text, so a link is a span rather than a node --
// see [LinkedText]. Setext headings take the same styles as `#` and `##`.
text = { LinkedText(it, it.typography.text) },
paragraph = { LinkedText(it, it.typography.paragraph) },
heading1 = { LinkedHeading(it, it.typography.h1) },
heading2 = { LinkedHeading(it, it.typography.h2) },
heading3 = { LinkedHeading(it, it.typography.h3) },
heading4 = { LinkedHeading(it, it.typography.h4) },
heading5 = { LinkedHeading(it, it.typography.h5) },
heading6 = { LinkedHeading(it, it.typography.h6) },
setextHeading1 = { LinkedHeading(it, it.typography.h1) },
setextHeading2 = { LinkedHeading(it, it.typography.h2) },
// Lists are ours wherever the renderer's dispatch meets one -- inside a quote -- so
// they draw like the top-level ones the transcript cuts into items.
orderedList = { MarkdownList(it.content, it.node, it.listDepth) },
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, replies, streaming)
},
codeBlock = {
CodeBlock(it.content, it.node, it.typography.code, replies, streaming)
},
),
content = content,
)
}
/**
* A table: its rows, on the renderer's tinted, rounded background, as wide as its columns need.
*
* Each column has a floor, so the table is at least columns-times-floor wide; narrower than the
* room it has, it spreads to fill it, and wider, it scrolls sideways rather than squeezing. The
* renderer decided that with a `BoxWithConstraints`, which is a subcomposition; here it is one
* layout modifier. `fillMaxWidth` fixes the minimum width to the room available, the horizontal
* scroll passes that minimum through while lifting the maximum to unbounded, and the modifier after
* it reads the minimum back and sizes the rows to the larger of that and the floor.
*/
@Composable
private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
val dimens = LocalMarkdownDimens.current
val colors = LocalMarkdownColors.current
val columns =
remember(node) {
node.findChildOfType(GFMElementTypes.HEADER)?.children?.count {
it.type == GFMTokenTypes.CELL
} ?: 0
}
val rows = remember(node) { node.children.count { it.type == GFMElementTypes.ROW } + 1 }
val floor = dimens.tableCellWidth * columns
Column(
Modifier.background(colors.tableBackground, RoundedCornerShape(dimens.tableCornerSize))
.semantics { collectionInfo = CollectionInfo(rowCount = rows, columnCount = columns) }
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.layout { measurable, constraints ->
val width = maxOf(constraints.minWidth, floor.roundToPx())
val placeable =
measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
layout(width, placeable.height) { placeable.place(0, 0) }
}
) {
var rowIndex = 1
node.children.forEach { child ->
when (child.type) {
GFMElementTypes.HEADER -> LinkedTableRow(content, child, style, rowIndex = 0)
GFMElementTypes.ROW -> LinkedTableRow(content, child, style, rowIndex = rowIndex++)
GFMTokenTypes.TABLE_SEPARATOR -> MarkdownDivider()
}
}
}
}
/**
* One row of a table -- the header when [rowIndex] is zero -- with every cell a [LinkedText].
*
* The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means
* most of a table is simply not readable: an elided cell looks like a short one, so a table of
* measurements reads as a table of plausible shorter measurements. And they draw a link in a cell
* as its own layout node, the cost [LinkedText] exists to avoid.
*
* So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell
* beside a one-line one centred the short one against the middle of the tall one. What the wrapping
* does *not* do is make a wide table fit; [LinkedTable] scrolls it instead.
*
* The semantics are the renderer's: each cell is an item of the table's collection.
*/
@Composable
private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowIndex: Int) {
val padding = LocalMarkdownDimens.current.tableCellPadding
val header = rowIndex == 0
val cellStyle = if (header) style.copy(fontWeight = FontWeight.Bold) else style
Row(verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) {
row.children
.filter { it.type == GFMTokenTypes.CELL }
.forEachIndexed { column, cell ->
LinkedText(
content,
cell,
cellStyle,
Modifier.padding(padding).weight(1f).semantics {
if (header) heading()
collectionItemInfo =
CollectionItemInfo(
rowIndex = rowIndex,
rowSpan = 1,
columnIndex = column,
columnSpan = 1,
)
},
)
}
}
}
/**
* Replies parsed before the row that draws them is composed.
*
* Parsing is the expensive half of drawing a reply, and it is expensive in proportion to how much
* was written. Measured against a real Claude Code transcript on the emulator, one message took
* **51ms** and several took 10-25ms, against 4.6ms for the short synthetic replies this was first
* tuned on -- so a page of history landing composed several rows that each stalled the frame.
*
* Nothing here changes what a row does when it has no answer waiting: it parses inline, because a
* row measured at nothing before its real height collapses the transcript above it. The point is
* only that by the time the reader scrolls to a row, the answer is usually already made.
*
* A miss is not stored, and that is what bounds this: the map holds one entry per message a page
* warmed, so a reply still streaming cannot fill it with hundreds of copies of itself.
*/
@Stable
class ParsedReplies {
private val parsed = ConcurrentHashMap<String, State>()
/**
* How each message divides into pieces, cached beside its parse: [transcriptUnits] asks per
* fold, and walking the tree again each time is proportional to the message.
*/
private val pieces = ConcurrentHashMap<String, List<Piece>>()
/**
* How each message divides into prose and memory notes, cached for the same reason: the regex
* scan behind [messageParts] is proportional to the message.
*/
private val parts = ConcurrentHashMap<String, List<MessagePart>>()
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.
*/
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. */
fun piecesOf(text: String): List<Piece> =
pieces.computeIfAbsent(text) {
DebugStats.timed("markdown cut into pieces") { pieces(of(it)) }
}
/** How a long user message divides into slices; cached for the same reason as [piecesOf]. */
fun chunksOf(text: String): List<String> =
chunks.computeIfAbsent(text) {
DebugStats.timed("user message cut into slices") { userChunks(it) }
}
/**
* Whether [warm] has made everything drawing [text] as pieces will look up.
*
* What the flatten asks before drawing a reply that way. Cutting costs a parse of the whole
* message and the flatten runs on the composing thread, so a reply not marked yet stays whole
* until the screen has warmed it. An explicit mark rather than a peek into the parse cache,
* because a message with memory notes is warmed as its *parts*: nothing ever parses its full
* text, and inferring readiness from the cache left exactly that message unsplittable forever.
*/
fun splitReady(text: String): Boolean = text in ready
/** The other half of [splitReady]; [warm] calls it once a message's parses exist. */
fun markSplitReady(text: String) {
ready.add(text)
}
fun partsOf(text: String): List<MessagePart> =
parts.computeIfAbsent(text) {
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: Language?): 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") }
?: DebugStats.timed("markdown parsed while composing") { parseMarkdown(text) }
/**
* Parses whatever is not held yet. Call off the composing thread; that is the whole point.
*
* Suspending, and yielding between messages, because "off the composing thread" is not the same
* as "free". A page of history arrives as hundreds of parses at once -- 1.5 seconds of them in
* a twelve second scroll on a Pixel 9 Pro XL -- and on the default dispatcher that is every
* core busy, with the frame's own thread waiting for one: 21ms of `waited` at the 90th
* percentile.
*/
suspend fun warm(texts: List<String>) {
texts.forEach { text ->
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.
fences(parse).forEach { (code, language) -> highlighted(code, language) }
}
}
/** Everything these described is gone; see [ParsedReplies]. */
fun clear() {
parsed.clear()
pieces.clear()
parts.clear()
chunks.clear()
highlights.clear()
ready.clear()
}
}