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>
668 lines
32 KiB
Kotlin
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()
|
|
}
|
|
}
|