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