Draw replies as pieces of one parse, and lists an item at a time

A settled reply used to be cut into block *strings*, each parsed on its own
and each a unit of the lazy list; the live reply split the same way with a
whole-message parse per delta on top. Now a message is parsed once, and a
Piece addresses a top-level block of that tree -- or one item of a
top-level list, which was the one block still unbounded: a list of forty
sources was one item composed whole in the frame it scrolled into. Units,
the live reply's column and peer messages all draw from the same parse,
so warm parses each message once instead of once per block, a delta costs
one background parse instead of two, and a reference definition at the
foot of a message resolves again because nothing is parsed apart from it.

The renderer keeps parsing and providing its environment; MarkdownRoot
wraps that around a piece, and a whole block still goes through its
dispatch with our component table. List items are drawn here, with the
renderer's own paddings so a split list looks like an unsplit one, and
lists inside quotes come to the same code through the table -- the marker
is drawn in one place, which is what a styled bullet would need later.

Found on the way: a heading's words are a child of the heading node, and
the inline builder draws nothing for a node type it does not know, so the
span-link path had been drawing headings empty. LinkedHeading hands it the
content child.

Lint: profileable's shell attribute scoped to API 29 where it exists, and
recordFrames renamed to the composable convention. What remains is the
AGP 9.4.0 notice.

Verified on the emulator against a fixture of every block kind (headings,
nested and ordered lists with a start number, task items, a quote holding
a list, a fence, a rule, a table with a linked cell, a setext heading), a
forty-item list which the render report now shows as per-item units, a
reply streamed live (34 deltas: 34 background reparses, one warm at
settle, no crash), and the older link fixture. ktfmt, build and lint run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-02 19:34:41 -04:00
1 parent a9ab9b3e5a
commit 48bb7de304
12 files changed
+457 -221

No files matched your search

@@ -1,16 +1,21 @@
package com.example.aiapp
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.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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.semantics.CollectionItemInfo
import androidx.compose.ui.semantics.collectionItemInfo
import androidx.compose.ui.semantics.heading
@@ -27,6 +32,7 @@ import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.LocalTableRowIndex
import com.mikepenz.markdown.compose.elements.MarkdownTable
import com.mikepenz.markdown.compose.elements.listDepth
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.elements.MarkdownCheckBox
import com.mikepenz.markdown.m3.markdownColor
@@ -50,12 +56,91 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
* Colours come from the theme rather than from the renderer's defaults, so code, links and rules
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
*/
/**
* [text] drawn as its pieces, one under the other; see [Piece]. [live] is the reply still arriving,
* and it is the only message whose pieces get a layer each. A layer buys one thing here: when
* drawing is invalidated, only the piece that changed is re-recorded instead of the whole reply.
* That is worth a great deal while a reply is streaming, because every delta invalidates the
* message and a finished one can be twenty-five screens tall. It is worth nothing once the message
* stops changing -- measured on a Pixel 9 Pro XL, whole rows were re-recorded 65 times in fifty
* seconds of reading -- 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 per-frame cost of the transcript scales
* with.
*/
@Composable
fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modifier) {
fun MarkdownText(
text: String,
replies: ParsedReplies,
modifier: Modifier = Modifier,
live: Boolean = false,
) {
val parse = parsedMarkdown(text, replies)
val pieces = remember(parse) { pieces(parse) }
MarkdownRoot(parse) {
Column(modifier.fillMaxWidth()) {
var previous: Piece? = null
pieces.forEach { piece ->
// Keyed by address rather than by position in this column, so a delta that
// lands in the last paragraph leaves every other piece's composition alone.
key(piece) {
MarkdownPiece(
parse,
text,
piece,
Modifier.padding(
top = if (previous == null) 0.dp else gapBefore(previous, piece)
)
.then(if (live) Modifier.graphicsLayer() else Modifier)
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record("record: one block", System.nanoTime() - started)
},
)
}
previous = piece
}
}
}
}
/** 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) { 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.
* Everything below this is the mapping onto the app's palette and type scale.
*
* Colours come from the theme rather than from the renderer's defaults, so code, links and rules
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
*/
@Composable
private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
if (parse !is State.Success) {
// Nothing below needs the environment; [MarkdownPiece] draws the words plainly.
content()
return
}
val body = MaterialTheme.typography.bodyLarge
val parsed = parsedMarkdown(text, replies)
Markdown(
parsed,
parse,
colors =
markdownColor(
text = MaterialTheme.colorScheme.onSurface,
@@ -157,14 +242,18 @@ fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modi
// is the renderer's own pairing.
text = { LinkedText(it, it.typography.text) },
paragraph = { LinkedText(it, it.typography.paragraph) },
heading1 = { LinkedText(it, it.typography.h1, heading = true) },
heading2 = { LinkedText(it, it.typography.h2, heading = true) },
heading3 = { LinkedText(it, it.typography.h3, heading = true) },
heading4 = { LinkedText(it, it.typography.h4, heading = true) },
heading5 = { LinkedText(it, it.typography.h5, heading = true) },
heading6 = { LinkedText(it, it.typography.h6, heading = true) },
setextHeading1 = { LinkedText(it, it.typography.h1, heading = true) },
setextHeading2 = { LinkedText(it, it.typography.h2, heading = true) },
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 = {
MarkdownTable(
it.content,
@@ -179,7 +268,8 @@ fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modi
)
},
),
modifier = modifier,
modifier = Modifier,
success = { _, _, _ -> content() },
)
}
@@ -299,18 +389,15 @@ class ParsedReplies {
private val parsed = ConcurrentHashMap<String, State>()
/**
* How each message divides into blocks, cached beside the parses of those blocks.
*
* Here rather than in a `remember` because the answer is wanted on two threads: by [warm], to
* know which strings to make ready, and by the row that draws them. Finding it costs a parse of
* the whole message, so doing it twice would undo what splitting is for.
* 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 where a lookup is
* proportional to nothing.
*/
private val blocks = ConcurrentHashMap<String, List<String>>()
private val pieces = ConcurrentHashMap<String, List<Piece>>()
/**
* How each message divides into prose and memory notes, cached for the same reason as
* [blocksOf]: [transcriptUnits] asks per fold, and the regex scan behind [messageParts] is
* proportional to the message every time where a lookup is proportional to nothing.
* [piecesOf]: the regex scan behind [messageParts] is proportional to the message.
*/
private val parts = ConcurrentHashMap<String, List<MessagePart>>()
@@ -318,25 +405,26 @@ class ParsedReplies {
private val ready = ConcurrentHashMap.newKeySet<String>()
fun blocksOf(text: String): List<String> =
blocks.computeIfAbsent(text) {
DebugStats.timed("markdown split into blocks") { markdownBlocks(it) }
/** 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 [blocksOf]. */
/** 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 blocks will look up.
* Whether [warm] has made everything drawing [text] as pieces will look up.
*
* What the flatten asks before drawing a reply that way. Splitting costs a parse of the whole
* 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, drawing the parse it already has, until the screen has warmed it and re-flattens. An
* explicit mark rather than a peek into [blocksOf]'s cache, because a message with memory notes
* is warmed as its *parts*: nothing ever splits its full text, and inferring readiness from the
* 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, re-warmed on every fold.
*/
fun splitReady(text: String): Boolean = text in ready
@@ -376,7 +464,7 @@ class ParsedReplies {
/** Everything these described is gone; see [ParsedReplies]. */
fun clear() {
parsed.clear()
blocks.clear()
pieces.clear()
parts.clear()
chunks.clear()
ready.clear()