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
@@ -129,7 +129,7 @@ private const val CAP = 20_000
|
||||
* documentation is explicit that doing that on the main thread taxes the very thing being measured.
|
||||
*/
|
||||
@Composable
|
||||
fun recordFrames() {
|
||||
fun RecordFrames() {
|
||||
val window = LocalContext.current.activity()?.window
|
||||
DisposableEffect(window) {
|
||||
if (window == null) return@DisposableEffect onDispose {}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.mikepenz.markdown.compose.elements.MarkdownText
|
||||
import com.mikepenz.markdown.model.markdownAnnotator
|
||||
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
||||
import org.intellij.markdown.MarkdownElementTypes
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
import org.intellij.markdown.ast.findChildOfType
|
||||
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
||||
@@ -49,13 +50,23 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
||||
* resolves those against its definitions.
|
||||
*/
|
||||
@Composable
|
||||
fun LinkedText(model: MarkdownComponentModel, style: TextStyle, heading: Boolean = false) {
|
||||
LinkedText(
|
||||
model.content,
|
||||
model.node,
|
||||
style,
|
||||
if (heading) Modifier.semantics { heading() } else Modifier,
|
||||
)
|
||||
fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
|
||||
LinkedText(model.content, model.node, style)
|
||||
}
|
||||
|
||||
/**
|
||||
* A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or
|
||||
* `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it
|
||||
* does not know, so handed the heading node itself it draws an empty line. Which is what this did
|
||||
* for a week.
|
||||
*/
|
||||
@Composable
|
||||
fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) {
|
||||
val words =
|
||||
model.node.findChildOfType(MarkdownTokenTypes.ATX_CONTENT)
|
||||
?: model.node.findChildOfType(MarkdownTokenTypes.SETEXT_CONTENT)
|
||||
?: model.node
|
||||
LinkedText(model.content, words, style, Modifier.semantics { heading() })
|
||||
}
|
||||
|
||||
/** The inline content of [node] within [content], drawn as [LinkedText] describes. */
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.text.BasicText
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.isTraversalGroup
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownComponents
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownPadding
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownTypography
|
||||
import com.mikepenz.markdown.compose.MarkdownElement
|
||||
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
|
||||
import com.mikepenz.markdown.model.State
|
||||
import org.intellij.markdown.MarkdownElementTypes
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
import org.intellij.markdown.ast.findChildOfType
|
||||
import org.intellij.markdown.ast.getTextInNode
|
||||
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
||||
|
||||
/**
|
||||
* One drawable piece of a parsed message: a top-level block, or one item of a top-level list.
|
||||
*
|
||||
* The point is the draw phase and the lazy list. A reply's display list holds every glyph of it and
|
||||
* is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a
|
||||
* hundred short ones; and the list composes an item whole in the frame it scrolls into, so an item
|
||||
* has to be bounded for the worst frame to be. Measured on a Pixel 9 Pro XL, the tallest row still
|
||||
* being drawn was 36,982px, twenty-five screens in one message. A piece is a paragraph, a fence, a
|
||||
* table, one bullet: bounded, so both costs are.
|
||||
*
|
||||
* Cut where the parser says the blocks are, which is the whole reason this is safe: a fence, a
|
||||
* table and a nested list are each one node whatever is inside them, so nothing is ever split down
|
||||
* the middle. A list is the one block that is not bounded -- a reply's list of sources can be forty
|
||||
* items -- so it is cut once more, into its items, and a nested list stays inside the item that
|
||||
* holds it.
|
||||
*
|
||||
* A piece is an *address* into the message's one parse ([block] indexes the root's children, [item]
|
||||
* the list items of that child) rather than a substring of the message. Every piece of a message is
|
||||
* drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and a
|
||||
* reference definition at its foot still resolves the links above it -- the two costs of cutting a
|
||||
* message into strings and parsing each on its own.
|
||||
*/
|
||||
@Immutable
|
||||
data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
|
||||
companion object {
|
||||
const val WHOLE_BLOCK = -1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pieces of [parse], in reading order. Blank nodes between blocks -- the parser keeps the
|
||||
* newlines -- are not pieces.
|
||||
*
|
||||
* A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a
|
||||
* message that drew as nothing would be a hole in the transcript with no sign of what fell out.
|
||||
*/
|
||||
fun pieces(parse: State): List<Piece> {
|
||||
val success = parse as? State.Success ?: return listOf(Piece(0))
|
||||
val out = ArrayList<Piece>()
|
||||
success.node.children.forEachIndexed { at, node ->
|
||||
when {
|
||||
node.getTextInNode(success.content).isBlank() -> {}
|
||||
node.isList -> repeat(node.listItems().size) { out += Piece(at, it) }
|
||||
else -> out += Piece(at)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The room above [piece] when it follows [previous] in the same message: none between two items of
|
||||
* one list, whose own padding already separates them, and a block's gap otherwise. The first piece
|
||||
* of a message takes the message's gap, which is the caller's to know.
|
||||
*/
|
||||
fun gapBefore(previous: Piece?, piece: Piece): Dp =
|
||||
if (previous != null && previous.block == piece.block) 0.dp else BLOCK_SPACING
|
||||
|
||||
/** The gap between one block of a reply and the next, wherever a reply is drawn in pieces. */
|
||||
val BLOCK_SPACING: Dp = 6.dp
|
||||
|
||||
/**
|
||||
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which is what carries the
|
||||
* theme, the components and the reference links to the renderer's element composables.
|
||||
*
|
||||
* A whole block goes to the renderer's own dispatch with this app's component table, so a paragraph
|
||||
* or heading is a [LinkedText], a table is [LinkedTableRow]s, and a nested list comes back here
|
||||
* through [MarkdownList]. Only the list item is drawn directly, because a list item is the one
|
||||
* piece the renderer has no element for.
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownPiece(parse: State, text: String, piece: Piece, modifier: Modifier = Modifier) {
|
||||
if (parse !is State.Success) {
|
||||
// The parser threw. Nothing else in the app has seen this happen; if it does, the words
|
||||
// are still worth more than a blank.
|
||||
Text(text, modifier, style = MaterialTheme.typography.bodyLarge)
|
||||
return
|
||||
}
|
||||
val node = parse.node.children[piece.block]
|
||||
if (piece.item == Piece.WHOLE_BLOCK) {
|
||||
Box(modifier) {
|
||||
MarkdownElement(
|
||||
node,
|
||||
LocalMarkdownComponents.current,
|
||||
parse.content,
|
||||
includeSpacer = false,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val items = node.listItems()
|
||||
MarkdownListItem(
|
||||
content = parse.content,
|
||||
list = node,
|
||||
item = items[piece.item],
|
||||
index = piece.item,
|
||||
first = piece.item == 0,
|
||||
last = piece.item == items.lastIndex,
|
||||
depth = 0,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole list, for the places the renderer's dispatch reaches one it cannot hand to a piece: a
|
||||
* list inside a quote, and the nested lists an item holds. Top-level lists never come here; they
|
||||
* are drawn an item at a time as pieces.
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) {
|
||||
val items = list.listItems()
|
||||
Column(modifier) {
|
||||
items.forEachIndexed { index, item ->
|
||||
MarkdownListItem(
|
||||
content,
|
||||
list,
|
||||
item,
|
||||
index,
|
||||
first = index == 0,
|
||||
last = index == items.lastIndex,
|
||||
depth = depth,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One item: its marker beside its content, laid out the way the renderer's own list does so that a
|
||||
* list drawn as pieces looks exactly like one drawn whole. The list's own padding goes on its first
|
||||
* and last items, since there is no list column to carry it.
|
||||
*
|
||||
* The marker is the renderer's bullet and number, and a checkbox for a task item. It is drawn here
|
||||
* rather than by a handler because it is the thing a reader might one day want styled -- a
|
||||
* different glyph per depth, a colour -- and this is the one place it is drawn.
|
||||
*/
|
||||
@Composable
|
||||
private fun MarkdownListItem(
|
||||
content: String,
|
||||
list: ASTNode,
|
||||
item: ASTNode,
|
||||
index: Int,
|
||||
first: Boolean,
|
||||
last: Boolean,
|
||||
depth: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val padding = LocalMarkdownPadding.current
|
||||
val typography = LocalMarkdownTypography.current
|
||||
val components = LocalMarkdownComponents.current
|
||||
// A task item's box sits right after the bullet: `- [ ] text`.
|
||||
val checkbox = item.children.getOrNull(1)?.takeIf { it.type == GFMTokenTypes.CHECK_BOX }
|
||||
Row(
|
||||
modifier
|
||||
.semantics { isTraversalGroup = true }
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = padding.listIndent * depth,
|
||||
top = padding.listItemTop + if (first) padding.list else 0.dp,
|
||||
bottom = padding.listItemBottom + if (last) padding.list else 0.dp,
|
||||
)
|
||||
) {
|
||||
if (checkbox != null) {
|
||||
components.checkbox(MarkdownComponentModel(content, checkbox, typography))
|
||||
} else if (list.type == MarkdownElementTypes.ORDERED_LIST) {
|
||||
Marker("${list.startNumber(content) + index}. ", typography.ordered)
|
||||
} else {
|
||||
Marker("• ", typography.bullet)
|
||||
}
|
||||
Column {
|
||||
item.children.forEach { child ->
|
||||
when (child.type) {
|
||||
MarkdownTokenTypes.LIST_BULLET,
|
||||
MarkdownTokenTypes.LIST_NUMBER,
|
||||
GFMTokenTypes.CHECK_BOX -> {}
|
||||
MarkdownElementTypes.ORDERED_LIST,
|
||||
MarkdownElementTypes.UNORDERED_LIST -> MarkdownList(content, child, depth + 1)
|
||||
else -> MarkdownElement(child, components, content, includeSpacer = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The renderer's text colour on the marker; its styles carry none of their own. */
|
||||
@Composable
|
||||
private fun Marker(text: String, style: TextStyle) {
|
||||
BasicText(text, style = style.copy(color = LocalMarkdownColors.current.text))
|
||||
}
|
||||
|
||||
private val ASTNode.isList: Boolean
|
||||
get() = type == MarkdownElementTypes.ORDERED_LIST || type == MarkdownElementTypes.UNORDERED_LIST
|
||||
|
||||
private fun ASTNode.listItems(): List<ASTNode> = children.filter {
|
||||
it.type == MarkdownElementTypes.LIST_ITEM
|
||||
}
|
||||
|
||||
/** Where an ordered list counts from: the number its first item was written with. */
|
||||
private fun ASTNode.startNumber(content: String): Int =
|
||||
findChildOfType(MarkdownElementTypes.LIST_ITEM)
|
||||
?.findChildOfType(MarkdownTokenTypes.LIST_NUMBER)
|
||||
?.getTextInNode(content)
|
||||
?.takeWhile(Char::isDigit)
|
||||
?.toString()
|
||||
?.toIntOrNull() ?: 1
|
||||
@@ -45,13 +45,13 @@ fun AssistantMessage(
|
||||
val parts = remember(text) { messageParts(text) }
|
||||
val only = parts.singleOrNull()
|
||||
if (only is MessagePart.Prose) {
|
||||
BlockedMarkdown(only.text, replies, modifier, live)
|
||||
MarkdownText(only.text, replies, modifier, live)
|
||||
return
|
||||
}
|
||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
parts.forEach { part ->
|
||||
when (part) {
|
||||
is MessagePart.Prose -> BlockedMarkdown(part.text, replies, live = live)
|
||||
is MessagePart.Prose -> MarkdownText(part.text, replies, live = live)
|
||||
is MessagePart.Remembered ->
|
||||
MemoryNote(part, replies, part.text in openNotes) { onToggleNote(part.text) }
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.model.State
|
||||
import com.mikepenz.markdown.model.parseMarkdown
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* A message's top-level markdown blocks, cut where the parser says the blocks are.
|
||||
*
|
||||
* The point is the draw phase. A reply's display list holds every glyph of it, and it is
|
||||
* re-recorded whenever drawing is invalidated -- so one long message is as expensive to draw as a
|
||||
* hundred short ones, and skipping the rows around it cannot help while it is the one on screen.
|
||||
* Measured on a Pixel 9 Pro XL: 97% of rows correctly skipped, and the tallest row still being
|
||||
* drawn was 36,982px, about twenty-five screens in a single message. Cut into blocks, only the
|
||||
* screen or two actually being read is ever recorded.
|
||||
*
|
||||
* Cut at the parser's own boundaries rather than at blank lines, which is the whole reason this is
|
||||
* safe: a heading, a fenced code block, a table and a list are each one node whatever is inside
|
||||
* them, so a loose list does not become five one-item lists and a fence is never split down the
|
||||
* middle. Guessing at block boundaries with a line scanner gets all three of those wrong.
|
||||
*
|
||||
* It also bounds parsing, which was the other symptom: one message took **1.4 seconds** to parse as
|
||||
* a single unit, and a block is a paragraph.
|
||||
*/
|
||||
fun markdownBlocks(text: String): List<String> {
|
||||
// A reference definition sits at the foot of a message and is used by links above it. Parsed on
|
||||
// its own each block would lose the definition, and the link would draw as literal brackets --
|
||||
// so a message carrying one is kept whole. Rare enough to be worth giving up the split for.
|
||||
if (REFERENCE_DEFINITION.containsMatchIn(text)) return listOf(text)
|
||||
val parsed = parseMarkdown(text) as? State.Success ?: return listOf(text)
|
||||
val blocks =
|
||||
parsed.node.children
|
||||
.map { text.substring(it.startOffset, it.endOffset) }
|
||||
.filter { it.isNotBlank() }
|
||||
return if (blocks.size <= 1) listOf(text) else blocks
|
||||
}
|
||||
|
||||
/** `[label]: https://…` at the start of a line -- see [markdownBlocks]. */
|
||||
private val REFERENCE_DEFINITION = Regex("""^ {0,3}\[[^\]]+]:\s""", RegexOption.MULTILINE)
|
||||
|
||||
/**
|
||||
* A reply drawn a block at a time.
|
||||
*
|
||||
* Each block keeps its composition and its layout whichever way it is scrolled -- that is what
|
||||
* stops a message being rebuilt when somebody comes back to it. The heights come from the blocks
|
||||
* themselves as they are measured, so the running total is the same arrangement the list uses one
|
||||
* level up.
|
||||
*
|
||||
* [live] is the message currently arriving, and it is the only one that gets a layer per block. A
|
||||
* layer buys one thing here: when drawing is invalidated, only the block 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 BlockedMarkdown(
|
||||
text: String,
|
||||
replies: ParsedReplies,
|
||||
modifier: Modifier = Modifier,
|
||||
live: Boolean = false,
|
||||
) {
|
||||
// The first split is inline for the reason [parsedMarkdown]'s first parse is: the row must
|
||||
// have its real height in its first frame. Every split after that is a delta landing, and it
|
||||
// runs off the composing thread with the message drawing the split it already has until the
|
||||
// new one arrives -- when this recomputed wherever composition ran, one streamed reply cost
|
||||
// 815 whole-message parses and 2.9 seconds of them, a few milliseconds per delta, on the
|
||||
// thread that draws. Not through [replies]: a reply mid-stream is a different text per
|
||||
// delta, and each would leave a cache entry nothing reads again.
|
||||
val split = remember { mutableStateOf(text to markdownBlocks(text)) }
|
||||
LaunchedEffect(text) {
|
||||
if (split.value.first == text) return@LaunchedEffect
|
||||
split.value =
|
||||
text to
|
||||
withContext(Dispatchers.Default) {
|
||||
DebugStats.timed("blocks split while streaming") { markdownBlocks(text) }
|
||||
}
|
||||
}
|
||||
val blocks = split.value.second
|
||||
if (blocks.size == 1) {
|
||||
MarkdownText(blocks.first(), replies, modifier)
|
||||
return
|
||||
}
|
||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(BLOCK_SPACING)) {
|
||||
blocks.forEach { block ->
|
||||
MarkdownText(
|
||||
block,
|
||||
replies,
|
||||
Modifier.fillMaxWidth()
|
||||
.then(if (live) Modifier.graphicsLayer() else Modifier)
|
||||
.drawWithContent {
|
||||
val started = System.nanoTime()
|
||||
drawContent()
|
||||
DebugStats.record("record: one block", System.nanoTime() - started)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The gap between one block of a reply and the next, here and in [transcriptUnits]. */
|
||||
val BLOCK_SPACING = 6.dp
|
||||
@@ -78,24 +78,18 @@ fun PeerHeadRow(
|
||||
* chose.
|
||||
*/
|
||||
@Composable
|
||||
fun PeerBlockRow(
|
||||
text: String,
|
||||
replies: ParsedReplies,
|
||||
last: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggle: () -> Unit) {
|
||||
Column(
|
||||
modifier.cardPiece(
|
||||
Modifier.cardPiece(
|
||||
top = false,
|
||||
bottom = last,
|
||||
bottom = unit.last,
|
||||
fill = CardDefaults.cardColors().containerColor,
|
||||
onPress = onToggle,
|
||||
)
|
||||
) {
|
||||
// The gap the card's own column used to provide between its heading and its prose, and
|
||||
// between one block and the next. Uniform, because both of those were 6dp already.
|
||||
MarkdownText(text, replies, Modifier.padding(top = BLOCK_SPACING))
|
||||
// between one block and the next -- inside the piece, so the card's fill runs through it.
|
||||
MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1108,7 +1108,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// One poll for this machine's limits, read by the two things that show them: the bar under
|
||||
// the header, and the colour of the button that opens the dialog.
|
||||
val usage = rememberSessionUsage(settings, summary.setup)
|
||||
recordFrames()
|
||||
RecordFrames()
|
||||
var usageOpen by remember { mutableStateOf(false) }
|
||||
var settingsOpen by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -1350,7 +1350,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
},
|
||||
) { unit ->
|
||||
when (unit) {
|
||||
is TranscriptUnit.Block -> MarkdownText(unit.text, replies)
|
||||
is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies)
|
||||
is TranscriptUnit.PeerHead ->
|
||||
PeerHeadRow(
|
||||
unit.item,
|
||||
@@ -1358,12 +1358,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
onToggle = { togglePeer(unit.item.seq) },
|
||||
)
|
||||
is TranscriptUnit.PeerBlock ->
|
||||
PeerBlockRow(
|
||||
unit.text,
|
||||
replies,
|
||||
unit.last,
|
||||
onToggle = { togglePeer(unit.seq) },
|
||||
)
|
||||
PeerBlockRow(unit, replies, onToggle = { togglePeer(unit.seq) })
|
||||
is TranscriptUnit.UserChunk ->
|
||||
UserChunkRow(unit, settings, summary.id, ::openImage)
|
||||
is TranscriptUnit.Memory ->
|
||||
|
||||
@@ -541,12 +541,12 @@ private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
|
||||
* whole point: the work happens seconds before the reader reaches the rows it was done for. See
|
||||
* [ParsedReplies].
|
||||
*
|
||||
* What is warmed mirrors what the rows draw, unit by unit -- prose split into its blocks, a memory
|
||||
* note whole, a peer message split the same way prose is -- because a string warmed under a key no
|
||||
* row ever looks up is a miss that nothing reports; see [transcriptUnits], which is the flatten
|
||||
* this has to agree with. It reads the same [ParsedReplies.partsOf] and [ParsedReplies.blocksOf]
|
||||
* caches the flatten does, so a message is scanned once however many pages hand it back through
|
||||
* here, while the whole loaded transcript crosses this on every page.
|
||||
* What is warmed mirrors what the rows draw -- each prose part of a reply, a memory note, a peer
|
||||
* message, every one of them whole, since every piece of a message is drawn from its one parse --
|
||||
* because a string warmed under a key no row ever looks up is a miss that nothing reports; see
|
||||
* [transcriptUnits], which is the flatten this has to agree with. It reads the same
|
||||
* [ParsedReplies.partsOf] cache the flatten does, so a message is scanned once however many pages
|
||||
* hand it back through here, while the whole loaded transcript crosses this on every page.
|
||||
*
|
||||
* Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer
|
||||
* message was missing: this used to filter for assistant replies alone, so the one row type nobody
|
||||
@@ -557,21 +557,12 @@ suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
|
||||
withContext(parsingThreads) {
|
||||
val texts = rows.flatMap { row ->
|
||||
when (row) {
|
||||
is TranscriptItem.AssistantMsg ->
|
||||
replies.partsOf(row.text).flatMap { part ->
|
||||
when (part) {
|
||||
is MessagePart.Prose -> replies.blocksOf(part.text)
|
||||
// Drawn as one MarkdownText, so its whole text is the key
|
||||
// looked up.
|
||||
is MessagePart.Remembered -> listOf(part.text)
|
||||
}
|
||||
}
|
||||
is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text }
|
||||
// A message from another agent is markdown too, and it is the longest thing
|
||||
// in a transcript often enough that leaving it out was the whole of why one
|
||||
// cost a fifth of a second to open: it was the only markdown in the app
|
||||
// parsed on the thread that draws. Its blocks, not its text, because
|
||||
// [PeerMessageRow] draws it a block at a time.
|
||||
is TranscriptItem.PeerNote -> replies.blocksOf(row.text)
|
||||
// parsed on the thread that draws.
|
||||
is TranscriptItem.PeerNote -> listOf(row.text)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,11 +49,12 @@ sealed class TranscriptUnit {
|
||||
get() = 0
|
||||
}
|
||||
|
||||
/** One markdown block of a settled reply. */
|
||||
/** One [Piece] of a settled reply; [text] is the prose it is a piece of. */
|
||||
data class Block(
|
||||
override val seq: Long,
|
||||
override val ordinal: Int,
|
||||
val text: String,
|
||||
val piece: Piece,
|
||||
override val gap: Dp,
|
||||
) : TranscriptUnit() {
|
||||
override val key: Any
|
||||
@@ -93,12 +94,18 @@ sealed class TranscriptUnit {
|
||||
get() = 0
|
||||
}
|
||||
|
||||
/** One markdown block of an opened peer message; [last] is the piece that closes the card. */
|
||||
/**
|
||||
* One [Piece] of an opened peer message; [last] is the piece that closes the card. Its [gap] is
|
||||
* always zero -- the pieces are one card -- so the room between blocks is [spacing], drawn
|
||||
* inside the piece where the card's fill covers it.
|
||||
*/
|
||||
data class PeerBlock(
|
||||
override val seq: Long,
|
||||
override val ordinal: Int,
|
||||
val text: String,
|
||||
val piece: Piece,
|
||||
val last: Boolean,
|
||||
val spacing: Dp,
|
||||
override val gap: Dp,
|
||||
/** The note this block belongs to; its key, not its seq. See [TranscriptItem.PeerNote]. */
|
||||
val note: Any,
|
||||
@@ -145,19 +152,19 @@ sealed class TranscriptUnit {
|
||||
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
|
||||
* screen, which is what a reversed lazy list calls the start.
|
||||
*
|
||||
* Every settled reply is cut into its blocks ([markdownBlocks], via the caches on [replies] so a
|
||||
* message is only ever split once), and so is an *opened* peer message -- [openNotes] is which ones
|
||||
* those are, which is why the flatten needs it. A shut one is a single heading and cannot be worth
|
||||
* splitting. The reply still arriving -- the newest row, until the status event that ends its turn
|
||||
* marks it [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta,
|
||||
* and splitting it here would parse the whole message per delta on whichever thread is composing.
|
||||
* Every settled reply is cut into its pieces ([pieces], via the caches on [replies] so a message is
|
||||
* only ever cut once), and so is an *opened* peer message -- [openNotes] is which ones those are,
|
||||
* which is why the flatten needs it. A shut one is a single heading and cannot be worth splitting.
|
||||
* The reply still arriving -- the newest row, until the status event that ends its turn marks it
|
||||
* [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta, and
|
||||
* splitting it here would parse the whole message per delta on whichever thread is composing.
|
||||
* [AssistantMessage]'s own streaming path already parses deltas off the main thread and gives the
|
||||
* live message a layer per block. Once settled it splits like every other reply, which is what
|
||||
* live message a layer per piece. Once settled it splits like every other reply, which is what
|
||||
* bounds the newest row's cost after a session ends on a long one.
|
||||
*
|
||||
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
|
||||
* path: [ParsedReplies.partsOf] and [ParsedReplies.blocksOf] are lookups for any text [warm] has
|
||||
* seen, and a miss -- the one message that just finished streaming -- costs its split exactly once.
|
||||
* path: [ParsedReplies.partsOf] and [ParsedReplies.piecesOf] are lookups for any text [warm] has
|
||||
* seen, and a miss -- the one message that just finished streaming -- costs its parse exactly once.
|
||||
*/
|
||||
fun transcriptUnits(
|
||||
rows: List<TranscriptRow>,
|
||||
@@ -175,17 +182,21 @@ fun transcriptUnits(
|
||||
// No gap between the pieces: they are one card, and a card with a stripe through it is
|
||||
// what any spacing here would draw.
|
||||
if (open) {
|
||||
val blocks = replies.blocksOf(item.text)
|
||||
blocks.forEachIndexed { at, block ->
|
||||
val pieces = replies.piecesOf(item.text)
|
||||
var previous: Piece? = null
|
||||
pieces.forEachIndexed { at, piece ->
|
||||
units +=
|
||||
TranscriptUnit.PeerBlock(
|
||||
row.startSeq,
|
||||
at + 1,
|
||||
block,
|
||||
last = at == blocks.lastIndex,
|
||||
item.text,
|
||||
piece,
|
||||
last = at == pieces.lastIndex,
|
||||
spacing = gapBefore(previous, piece),
|
||||
gap = 0.dp,
|
||||
note = item.key,
|
||||
)
|
||||
previous = piece
|
||||
}
|
||||
}
|
||||
} else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) {
|
||||
@@ -210,16 +221,27 @@ fun transcriptUnits(
|
||||
replies.splitReady(item.text)
|
||||
) {
|
||||
var ordinal = 0
|
||||
fun gap() = if (ordinal == 0) rowGap else BLOCK_SPACING
|
||||
fun gap(within: Dp) = if (ordinal == 0) rowGap else within
|
||||
replies.partsOf(item.text).forEach { part ->
|
||||
when (part) {
|
||||
is MessagePart.Prose ->
|
||||
replies.blocksOf(part.text).forEach { block ->
|
||||
units += TranscriptUnit.Block(row.startSeq, ordinal, block, gap())
|
||||
is MessagePart.Prose -> {
|
||||
var previous: Piece? = null
|
||||
replies.piecesOf(part.text).forEach { piece ->
|
||||
units +=
|
||||
TranscriptUnit.Block(
|
||||
row.startSeq,
|
||||
ordinal,
|
||||
part.text,
|
||||
piece,
|
||||
gap(gapBefore(previous, piece)),
|
||||
)
|
||||
ordinal++
|
||||
previous = piece
|
||||
}
|
||||
}
|
||||
is MessagePart.Remembered -> {
|
||||
units += TranscriptUnit.Memory(row.startSeq, ordinal, part, gap())
|
||||
units +=
|
||||
TranscriptUnit.Memory(row.startSeq, ordinal, part, gap(BLOCK_SPACING))
|
||||
ordinal++
|
||||
}
|
||||
}
|
||||
@@ -358,7 +380,8 @@ private val TranscriptUnit?.kind: String
|
||||
get() =
|
||||
when (this) {
|
||||
null -> "the list's own"
|
||||
is TranscriptUnit.Block -> "reply block"
|
||||
is TranscriptUnit.Block ->
|
||||
if (piece.item == Piece.WHOLE_BLOCK) "reply block" else "list item"
|
||||
is TranscriptUnit.PeerHead -> if (open) "peer heading (open)" else "peer heading"
|
||||
is TranscriptUnit.PeerBlock -> "peer block"
|
||||
is TranscriptUnit.UserChunk -> "user slice"
|
||||
|
||||
Reference in new issue
Block a user