3 Commits
Author SHA1 Message Date
irisandClaude Fable 5.1 4bc69e8f9c Draw text on the platform directly, own the table's outer box, and parse a live reply a block at a time
Three costs left in the renderer's composition layer, taken one at a time:

The text leaf. Every paragraph went through the renderer's text composable,
which exists to place inline images and charged each text for the
possibility: a placement callback, a derived map of inline content, a
semantics group and a size animation. A paragraph with no image -- nearly
all of them -- now goes straight to BasicText, with the renderer's own rule
for a style that names no colour. One with an image keeps the old path.

The table. The renderer decided "spread or scroll" with a BoxWithConstraints,
a subcomposition. LinkedTable does it with one layout modifier placed after
the horizontal scroll: fillMaxWidth fixes the minimum to the room, the scroll
passes that minimum through while lifting the maximum, and the modifier sizes
the rows to the larger of the room and the columns' floor. Rows no longer
need a width handed to them or a row index from a composition local.

The live reply. Every delta reparsed the whole message off-thread; for a
long reply that was tens of milliseconds hundreds of times, every core busy
while the frame's thread waited. LiveParse freezes every top-level block that
a later block has started after -- markdown's block rules make that safe --
and reparses only the tail. Each piece is keyed by where it starts in the
message, so a block keeps its composition when it freezes.

Verified on the emulator: the block-kind fixture draws the same with links
opening from a paragraph and a bullet and plain text opening nothing; a
six-column table still scrolls sideways; a 58-word mixed stream of list,
fence, table and quote drew every block as it arrived, 47 tail reparses at
1.7ms mean against whole-message parses before. ktfmt, build and lint clean
but for the AGP 9.4.0 notice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 00:00:01 -04:00
irisandClaude Fable 5.1 48bb7de304 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>
2026-09-02 19:34:41 -04:00
irisandClaude Fable 5.1 a9ab9b3e5a Draw table cells as span-linked text, and hit-test links by glyph
Table rows are now ours: each cell is a LinkedText, so a link in a cell is a
span with a string annotation and one tap detector per cell rather than the
layout node Compose builds for every LinkAnnotation -- the cost the paragraph
change removed everywhere else. The renderer's outer table (width, sideways
scroll, corners, dividers) stays; the row and cell were the only parts it
offered no slot for. Cells still wrap and align to the top, with the same
semantics the renderer gave them.

Found while checking it: the hit test took the layout's nearest caret as the
glyph under the finger, so a tap on the right half of any link glyph named
the character after it and opened nothing. That was flaky in paragraphs
already; it now checks the glyph on either side of the caret.

Verified on the emulator: a linked cell and an autolink cell open their
addresses, a plain cell opens nothing, a six-column table still scrolls
sideways, and a link-free table draws as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:58:37 -04:00
12 changed files with 730 additions and 337 deletions

No files matched your search

+22 -7
View File
@@ -334,17 +334,32 @@ first if a remote spawn ever mangles an argument.
success. The phone's half is `uniqueItems`, which every list keyed on a success. The phone's half is `uniqueItems`, which every list keyed on a
server-chosen id goes through: a repeat there must never be able to server-chosen id goes through: a repeat there must never be able to
close the app, whatever produced it. close the app, whatever produced it.
- **A reply is drawn as pieces of one parse, never as re-parsed
substrings.** `MarkdownPieces.kt`: a `Piece` addresses a top-level block
of the message's tree, or one item of a top-level list, and every piece
is drawn from the same `State.Success` that `ParsedReplies` cached and
`warm` made. That is what bounds a lazy-list item (one paragraph, one
bullet) without parsing a message more than once, and it is why a
forty-item list of sources is forty units rather than one. The renderer
is still the parser and the environment: `MarkdownRoot` provides its
locals and `MarkdownElement` dispatches a whole block through our
component table, so paragraphs, headings and table cells are span-linked
`LinkedText` (links as spans with one tap detector per text, not a layout
node per link -- the cost that made a list of sources bumpy) and lists
are ours wherever the dispatch meets one. A heading's words are its
`ATX_CONTENT`/`SETEXT_CONTENT` child; the inline builder draws nothing
for a node type it does not know, so hand it the child.
- **A markdown table wraps its cells and never cuts one off.** The - **A markdown table wraps its cells and never cuts one off.** The
renderer's own defaults draw every cell at one line with an ellipsis, renderer's own defaults draw every cell at one line with an ellipsis,
which on a phone loses most of a table -- and an elided cell looks which on a phone loses most of a table -- and an elided cell looks
exactly like a short one, so nothing on screen says anything was cut. exactly like a short one, so nothing on screen says anything was cut.
`Markdown.kt` supplies its own header and row blocks with `maxLines = `Markdown.kt` supplies its own rows (`LinkedTableRow`): as many lines as
Int.MAX_VALUE` and `TextOverflow.Clip`, cells aligned to the top of the a cell needs, cells aligned to the top of the row so a two-line cell
row so a two-line cell does not re-centre its neighbours. Width is the does not re-centre its neighbours, and each cell a `LinkedText`. Width is
other half: a column narrows to 136dp and no further, and past that the the other half: a column narrows to 136dp and no further, and past that
whole table scrolls sideways rather than squeezing -- 136 because it is the whole table scrolls sideways rather than squeezing -- 136 because it
the widest floor that still fits three columns across a phone, which is is the widest floor that still fits three columns across a phone, which
the commonest table there is. Exercise it with the echo driver's is the commonest table there is. Exercise it with the echo driver's
`/table N` (default six columns), which writes long cells on purpose: `/table N` (default six columns), which writes long cells on purpose:
a fixture of tidy one-word values renders fine whether or not the a fixture of tidy one-word values renders fine whether or not the
truncation is fixed. truncation is fixed.
+1 -1
View File
@@ -34,7 +34,7 @@
adds) in a release build, so a frame cost measured on the real adds) in a release build, so a frame cost measured on the real
device can be attributed. shell="true" limits it to profilers device can be attributed. shell="true" limits it to profilers
run from the shell; it grants nothing to other apps. --> run from the shell; it grants nothing to other apps. -->
<profileable android:shell="true" /> <profileable android:shell="true" tools:targetApi="q" />
<!-- adjustResize (not the system's default pan): the layout handles <!-- adjustResize (not the system's default pan): the layout handles
the keyboard itself via imePadding(), so the window must resize the keyboard itself via imePadding(), so the window must resize
rather than slide the top bar off screen. --> rather than slide the top bar off screen. -->
@@ -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. * documentation is explicit that doing that on the main thread taxes the very thing being measured.
*/ */
@Composable @Composable
fun recordFrames() { fun RecordFrames() {
val window = LocalContext.current.activity()?.window val window = LocalContext.current.activity()?.window
DisposableEffect(window) { DisposableEffect(window) {
if (window == null) return@DisposableEffect onDispose {} if (window == null) return@DisposableEffect onDispose {}
@@ -1,25 +1,43 @@
package com.example.aiapp 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.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily 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.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.components.markdownComponents import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownTable import com.mikepenz.markdown.compose.elements.MarkdownDivider
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader import com.mikepenz.markdown.compose.elements.listDepth
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.elements.MarkdownCheckBox import com.mikepenz.markdown.m3.elements.MarkdownCheckBox
import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownColor
@@ -31,23 +49,211 @@ import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.intellij.markdown.ast.ASTNode 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
/** /**
* An assistant's reply, rendered as the markdown it is written in. * [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: when drawing is invalidated, only the piece that
* changed is re-recorded instead of the whole reply, which is worth a great deal while 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,
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.forEach { segment ->
MarkdownRoot(segment.parse) {
segment.pieces.forEach { piece ->
val gap =
when {
previousSegment == null -> 0.dp
previousSegment !== segment -> 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,
)
},
)
}
previous = piece
previousSegment = segment
}
}
}
}
}
/**
* A stretch of a message with a parse of its own: the whole of a settled message, or one block or
* the unfinished tail of a live one. [start] is where [text] begins in the message.
*/
private class Segment(val text: String, val start: Int, val parse: State, val pieces: List<Piece>)
/**
* 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, every one of them blank, the whole conversation shrunk to fit a single screen; a moment
* later it was all there again. That is the "skipping up and down" this list must never do.
*
* 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. What is on
* screen is always a real prefix of the reply rather than a guess at it; it is simply one parse
* behind.
*/
@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, since a paragraph ends at the blank
* line or the block that interrupts it, a fence at its closing fence, a list at the first line that
* is neither an item nor indented under one. So every block but the last is [frozen] with the parse
* that finished it, and only the tail -- the last block and whatever has arrived since -- is parsed
* again.
*
* What the cut gives up is one thing: a reference definition arriving later than a link that uses
* it, since the frozen block's parse never sees it. The link draws as its brackets until the reply
* settles and is parsed whole by [warm], which is the same moment every other transient of
* streaming is put right.
*/
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 blocks = all.map { it.block }.distinct()
if (blocks.size <= 1 || parse !is State.Success) {
return LiveParse(next, frozen, consumed, Segment(tailText, consumed, parse, all))
}
val done =
blocks.dropLast(1).map { block ->
Segment(tailText, consumed, parse, all.filter { it.block == block })
}
val cut = parse.node.children[blocks.last()].startOffset
val rest = tailText.substring(cut)
val restParse = parseMarkdown(rest)
return LiveParse(
next,
frozen + done,
consumed + cut,
Segment(rest, consumed + cut, restParse, pieces(restParse)),
)
}
companion object {
fun whole(text: String): LiveParse {
val parse = parseMarkdown(text)
return LiveParse(text, emptyList(), 0, Segment(text, 0, parse, pieces(parse)))
}
}
}
/** 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 * The parsing is the library's. Markdown is somebody else's specification, and a hand-written
* subset of one disagrees with it at the edges -- which is where the bug reports come from, one * parser would get the edge cases wrong one case at a time. So is the environment: the element
* case at a time. This file's whole job is the mapping onto the app's palette and type scale. * 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 * 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. * are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
*/ */
@Composable @Composable
fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modifier) { 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 body = MaterialTheme.typography.bodyLarge
val parsed = parsedMarkdown(text, replies)
Markdown( Markdown(
parsed, parse,
colors = colors =
markdownColor( markdownColor(
text = MaterialTheme.colorScheme.onSurface, text = MaterialTheme.colorScheme.onSurface,
@@ -149,117 +355,116 @@ fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modi
// is the renderer's own pairing. // is the renderer's own pairing.
text = { LinkedText(it, it.typography.text) }, text = { LinkedText(it, it.typography.text) },
paragraph = { LinkedText(it, it.typography.paragraph) }, paragraph = { LinkedText(it, it.typography.paragraph) },
heading1 = { LinkedText(it, it.typography.h1, heading = true) }, heading1 = { LinkedHeading(it, it.typography.h1) },
heading2 = { LinkedText(it, it.typography.h2, heading = true) }, heading2 = { LinkedHeading(it, it.typography.h2) },
heading3 = { LinkedText(it, it.typography.h3, heading = true) }, heading3 = { LinkedHeading(it, it.typography.h3) },
heading4 = { LinkedText(it, it.typography.h4, heading = true) }, heading4 = { LinkedHeading(it, it.typography.h4) },
heading5 = { LinkedText(it, it.typography.h5, heading = true) }, heading5 = { LinkedHeading(it, it.typography.h5) },
heading6 = { LinkedText(it, it.typography.h6, heading = true) }, heading6 = { LinkedHeading(it, it.typography.h6) },
setextHeading1 = { LinkedText(it, it.typography.h1, heading = true) }, setextHeading1 = { LinkedHeading(it, it.typography.h1) },
setextHeading2 = { LinkedText(it, it.typography.h2, heading = true) }, setextHeading2 = { LinkedHeading(it, it.typography.h2) },
table = { // Lists are ours wherever the renderer's dispatch meets one -- inside a quote --
MarkdownTable( // so they draw like the top-level ones the transcript cuts into items.
it.content, orderedList = { MarkdownList(it.content, it.node, it.listDepth) },
it.node, unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
style = it.typography.table, table = { LinkedTable(it.content, it.node, it.typography.table) },
headerBlock = ::WrappingTableHeader, ),
rowBlock = ::WrappingTableRow, modifier = Modifier,
success = { _, _, _ -> content() },
)
}
/**
* A table: its rows, on the renderer's tinted, rounded background, as wide as its columns need.
*
* Each column has a floor ([markdownDimens]'s `tableCellWidth`), 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, and the trick is where it sits.
* `fillMaxWidth` fixes the minimum width to the room available, the horizontal scroll passes that
* minimum through to its content while lifting the maximum to unbounded, and the modifier after it
* reads the minimum back as the room and sizes the rows to the larger of that and the floor. The
* scroll then has exactly the overflow to scroll, which is none when the table fits.
*/
@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: anything past about twenty characters ends in "..." with
* no way to see the rest, and 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 and lost the line
* the reader was reading across. What the wrapping does *not* do is make a wide table fit;
* [LinkedTable] scrolls it instead, which is the right answer for too many columns -- wrapping a
* six-column table into the width of a phone would give every cell one word per line.
*
* The semantics are the renderer's: each cell is an item of the table's collection, and a header
* cell is a heading.
*/
@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,
) )
}, },
),
modifier = modifier,
) )
} }
/**
* A table header, and a table row, whose cells wrap rather than being cut off.
*
* The renderer draws every cell at `maxLines = 1` with an ellipsis, which on a phone means most of
* a table is simply not readable: a column is 160dp at its narrowest, so anything past about twenty
* characters ends in "..." with no way to see the rest. Nothing about the value says it was cut,
* either -- an elided cell looks like a short one, so a table of measurements reads as a table of
* plausible shorter measurements.
*
* So: as many lines as the cell needs, and [TextOverflow.Clip] rather than an ellipsis, which now
* never has anything to hide since the height grows to fit. Cells align 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 and lost the line the reader was reading across.
*
* What the wrapping does *not* do is make a wide table fit. The renderer already gives each column
* a 160dp minimum and scrolls the whole table sideways when they do not fit the screen, which is
* the right answer for too many columns -- wrapping a six-column table into the width of a phone
* would give every cell one word per line. The two work together: the width is what the columns
* need, and the wrapping is what fills the space that width provides.
*
* Two functions rather than one because the renderer's header and row are separate composables --
* the header is bold and sizes itself to its tallest cell -- and the parameters that matter here
* are the same three in both.
*/
@Composable
private fun WrappingTableHeader(
content: String,
header: ASTNode,
tableWidth: Dp,
style: TextStyle,
) {
MarkdownTableHeader(
content = content,
header = header,
tableWidth = tableWidth,
style = style,
verticalAlignment = Alignment.Top,
maxLines = Int.MAX_VALUE,
overflow = TextOverflow.Clip,
)
} }
@Composable
private fun WrappingTableRow(content: String, row: ASTNode, tableWidth: Dp, style: TextStyle) {
MarkdownTableRow(
content = content,
header = row,
tableWidth = tableWidth,
style = style,
verticalAlignment = Alignment.Top,
maxLines = Int.MAX_VALUE,
overflow = TextOverflow.Clip,
)
}
/**
* [text] parsed: on the composing thread the first time this row is drawn, and off it every time
* 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, every one of them blank, the whole conversation shrunk to fit a single screen; a moment
* later it was all there again. That is the "skipping up and down" this list must never do, and no
* amount of scroll anchoring can survive a row that lies about its height first.
*
* Every parse *after* the first is a different case, and it is the one that was costing: a reply
* arrives as hundreds of deltas, each one re-parsing the whole message it has grown into. Measured
* against `/stream 200` on the emulator, that was fifty-eight parses and 78ms of main-thread work
* in three seconds, with single parses reaching 7ms -- most of a frame at 60Hz and more than one at
* 120. Those go to a background 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. What is on screen is always a
* real prefix of the reply rather than a guess at it; it is simply one parse behind.
*/
@Composable
private fun parsedMarkdown(text: String, replies: ParsedReplies): State {
// The text each parse came from, so the first composition's is not immediately repeated.
val parsed = remember { mutableStateOf(text to replies.of(text)) }
LaunchedEffect(text) {
if (parsed.value.first == text) return@LaunchedEffect
// Not through [replies]: this is a reply still arriving, and every delta would leave
// another copy of a message that is about to be superseded.
parsed.value =
text to
withContext(Dispatchers.Default) {
DebugStats.timed("markdown reparsed while streaming") { parseMarkdown(text) }
}
}
return parsed.value.second
} }
/** /**
@@ -287,18 +492,15 @@ class ParsedReplies {
private val parsed = ConcurrentHashMap<String, State>() private val parsed = ConcurrentHashMap<String, State>()
/** /**
* How each message divides into blocks, cached beside the parses of those blocks. * 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
* Here rather than in a `remember` because the answer is wanted on two threads: by [warm], to * proportional to nothing.
* 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.
*/ */
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 * 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 * [piecesOf]: the regex scan behind [messageParts] is proportional to the message.
* proportional to the message every time where a lookup is proportional to nothing.
*/ */
private val parts = ConcurrentHashMap<String, List<MessagePart>>() private val parts = ConcurrentHashMap<String, List<MessagePart>>()
@@ -306,25 +508,26 @@ class ParsedReplies {
private val ready = ConcurrentHashMap.newKeySet<String>() private val ready = ConcurrentHashMap.newKeySet<String>()
fun blocksOf(text: String): List<String> = /** The pieces of [text], from its parse -- made now if [warm] has not made it. */
blocks.computeIfAbsent(text) { fun piecesOf(text: String): List<Piece> =
DebugStats.timed("markdown split into blocks") { markdownBlocks(it) } 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> = fun chunksOf(text: String): List<String> =
chunks.computeIfAbsent(text) { chunks.computeIfAbsent(text) {
DebugStats.timed("user message cut into slices") { userChunks(it) } 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 * 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 * 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 * explicit mark rather than a peek into the parse cache, because a message with memory notes is
* is warmed as its *parts*: nothing ever splits its full text, and inferring readiness from the * 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. * cache left exactly that message unsplittable forever, re-warmed on every fold.
*/ */
fun splitReady(text: String): Boolean = text in ready fun splitReady(text: String): Boolean = text in ready
@@ -364,7 +567,7 @@ class ParsedReplies {
/** Everything these described is gone; see [ParsedReplies]. */ /** Everything these described is gone; see [ParsedReplies]. */
fun clear() { fun clear() {
parsed.clear() parsed.clear()
blocks.clear() pieces.clear()
parts.clear() parts.clear()
chunks.clear() chunks.clear()
ready.clear() ready.clear()
@@ -1,10 +1,12 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.node.Ref import androidx.compose.ui.node.Ref
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
@@ -17,11 +19,13 @@ import androidx.compose.ui.text.TextStyle
import com.mikepenz.markdown.annotator.AnnotatorSettings import com.mikepenz.markdown.annotator.AnnotatorSettings
import com.mikepenz.markdown.annotator.annotatorSettings import com.mikepenz.markdown.annotator.annotatorSettings
import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.components.MarkdownComponentModel import com.mikepenz.markdown.compose.components.MarkdownComponentModel
import com.mikepenz.markdown.compose.elements.MarkdownText import com.mikepenz.markdown.compose.elements.MarkdownText
import com.mikepenz.markdown.model.markdownAnnotator import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.utils.getUnescapedTextInNode import com.mikepenz.markdown.utils.getUnescapedTextInNode
import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.flavours.gfm.GFMTokenTypes import org.intellij.markdown.flavours.gfm.GFMTokenTypes
@@ -44,42 +48,91 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
* style never defined a pressed style, so nothing visible changes. * style never defined a pressed style, so nothing visible changes.
* *
* Every block the renderer dispatches through its component table comes here, which includes the * Every block the renderer dispatches through its component table comes here, which includes the
* paragraphs inside lists, quotes and alerts. Table cells do not: the table draws its own cells and * paragraphs inside lists, quotes and alerts, and so does every table cell through
* offers no slot for them, so a link in a cell keeps the renderer's path -- correct, and dearer. * [LinkedTableRow]. Reference-style links are the one kind still drawn the renderer's way; it
* Reference-style links stay there too; the renderer resolves those against its definitions. * resolves those against its definitions.
*/ */
@Composable @Composable
fun LinkedText(model: MarkdownComponentModel, style: TextStyle, heading: Boolean = false) { 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. */
@Composable
fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modifier = Modifier) {
val settings = plainLinkSettings() val settings = plainLinkSettings()
val text = val text =
remember(model.content, model.node, style) { remember(content, node, style) {
model.content.buildMarkdownAnnotatedString(model.node, style, settings) content.buildMarkdownAnnotatedString(node, style, settings)
} }
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val layout = remember { Ref<TextLayoutResult>() } val layout = remember { Ref<TextLayoutResult>() }
MarkdownText( val tapping =
content = text, modifier.pointerInput(text) {
node = model.node,
modifier =
Modifier.then(if (heading) Modifier.semantics { heading() } else Modifier).pointerInput(
text
) {
detectTapGestures { position -> detectTapGestures { position ->
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures val url = text.linkAt(layout.value, position) ?: return@detectTapGestures
uriHandler.openUri(url) uriHandler.openUri(url)
} }
}, }
// The renderer's text composable exists to place inline images, and it charges every text
// for the possibility: a placement callback, a derived map of inline content, a semantics
// group and a size animation, per paragraph. Almost no paragraph has an image, so those go
// straight to the platform text; the few that do keep the renderer's path.
if (remember(node) { node.hasImage() }) {
MarkdownText(
content = text,
node = node,
modifier = tapping,
style = style, style = style,
onTextLayout = { result, _ -> layout.value = result }, onTextLayout = { result, _ -> layout.value = result },
) )
} else {
// The renderer's own rule for a style that names no colour: the theme's text colour.
val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text
BasicText(
text = text,
modifier = tapping,
style = style,
color = { color },
onTextLayout = { layout.value = it },
)
}
} }
/** The address under [position], if a link's glyph is there rather than merely nearest to it. */ private fun ASTNode.hasImage(): Boolean =
type == MarkdownElementTypes.IMAGE || children.any { it.hasImage() }
/**
* The address under [position], if a link's glyph is there rather than merely nearest to it.
*
* The layout answers with a caret, the boundary nearest the finger, so a tap on the right half of a
* glyph names the character after it; the glyph under the finger is the one on either side of that
* boundary whose box holds the point. Checked with the box rather than assumed, so a tap past the
* end of a line ending in a link opens nothing.
*/
private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset): String? { private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset): String? {
layout ?: return null layout ?: return null
val offset = layout.getOffsetForPosition(position) val caret = layout.getOffsetForPosition(position)
if (offset >= length || !layout.getBoundingBox(offset).contains(position)) return null val glyph =
return getStringAnnotations(LINK_URL, offset, offset).firstOrNull()?.item (caret - 1..caret).firstOrNull {
it in 0 until length && layout.getBoundingBox(it).contains(position)
} ?: return null
return getStringAnnotations(LINK_URL, glyph, glyph + 1).firstOrNull()?.item
} }
private const val LINK_URL = "url" private const val LINK_URL = "url"
@@ -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 parts = remember(text) { messageParts(text) }
val only = parts.singleOrNull() val only = parts.singleOrNull()
if (only is MessagePart.Prose) { if (only is MessagePart.Prose) {
BlockedMarkdown(only.text, replies, modifier, live) MarkdownText(only.text, replies, modifier, live)
return return
} }
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
parts.forEach { part -> parts.forEach { part ->
when (part) { when (part) {
is MessagePart.Prose -> BlockedMarkdown(part.text, replies, live = live) is MessagePart.Prose -> MarkdownText(part.text, replies, live = live)
is MessagePart.Remembered -> is MessagePart.Remembered ->
MemoryNote(part, replies, part.text in openNotes) { onToggleNote(part.text) } 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. * chose.
*/ */
@Composable @Composable
fun PeerBlockRow( fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggle: () -> Unit) {
text: String,
replies: ParsedReplies,
last: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Column( Column(
modifier.cardPiece( Modifier.cardPiece(
top = false, top = false,
bottom = last, bottom = unit.last,
fill = CardDefaults.cardColors().containerColor, fill = CardDefaults.cardColors().containerColor,
onPress = onToggle, onPress = onToggle,
) )
) { ) {
// The gap the card's own column used to provide between its heading and its prose, and // 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. // between one block and the next -- inside the piece, so the card's fill runs through it.
MarkdownText(text, replies, Modifier.padding(top = BLOCK_SPACING)) 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 // 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. // the header, and the colour of the button that opens the dialog.
val usage = rememberSessionUsage(settings, summary.setup) val usage = rememberSessionUsage(settings, summary.setup)
recordFrames() RecordFrames()
var usageOpen by remember { mutableStateOf(false) } var usageOpen by remember { mutableStateOf(false) }
var settingsOpen by remember { mutableStateOf(false) } var settingsOpen by remember { mutableStateOf(false) }
@@ -1350,7 +1350,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
}, },
) { unit -> ) { unit ->
when (unit) { when (unit) {
is TranscriptUnit.Block -> MarkdownText(unit.text, replies) is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies)
is TranscriptUnit.PeerHead -> is TranscriptUnit.PeerHead ->
PeerHeadRow( PeerHeadRow(
unit.item, unit.item,
@@ -1358,12 +1358,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
onToggle = { togglePeer(unit.item.seq) }, onToggle = { togglePeer(unit.item.seq) },
) )
is TranscriptUnit.PeerBlock -> is TranscriptUnit.PeerBlock ->
PeerBlockRow( PeerBlockRow(unit, replies, onToggle = { togglePeer(unit.seq) })
unit.text,
replies,
unit.last,
onToggle = { togglePeer(unit.seq) },
)
is TranscriptUnit.UserChunk -> is TranscriptUnit.UserChunk ->
UserChunkRow(unit, settings, summary.id, ::openImage) UserChunkRow(unit, settings, summary.id, ::openImage)
is TranscriptUnit.Memory -> 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 * whole point: the work happens seconds before the reader reaches the rows it was done for. See
* [ParsedReplies]. * [ParsedReplies].
* *
* What is warmed mirrors what the rows draw, unit by unit -- prose split into its blocks, a memory * What is warmed mirrors what the rows draw -- each prose part of a reply, a memory note, a peer
* note whole, a peer message split the same way prose is -- because a string warmed under a key no * message, every one of them whole, since every piece of a message is drawn from its one parse --
* row ever looks up is a miss that nothing reports; see [transcriptUnits], which is the flatten * because a string warmed under a key no row ever looks up is a miss that nothing reports; see
* this has to agree with. It reads the same [ParsedReplies.partsOf] and [ParsedReplies.blocksOf] * [transcriptUnits], which is the flatten this has to agree with. It reads the same
* caches the flatten does, so a message is scanned once however many pages hand it back through * [ParsedReplies.partsOf] cache the flatten does, so a message is scanned once however many pages
* here, while the whole loaded transcript crosses this on every page. * 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 * 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 * 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) { withContext(parsingThreads) {
val texts = rows.flatMap { row -> val texts = rows.flatMap { row ->
when (row) { when (row) {
is TranscriptItem.AssistantMsg -> is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text }
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)
}
}
// A message from another agent is markdown too, and it is the longest thing // 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 // 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 // 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 // parsed on the thread that draws.
// [PeerMessageRow] draws it a block at a time. is TranscriptItem.PeerNote -> listOf(row.text)
is TranscriptItem.PeerNote -> replies.blocksOf(row.text)
else -> emptyList() else -> emptyList()
} }
} }
@@ -49,11 +49,12 @@ sealed class TranscriptUnit {
get() = 0 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( data class Block(
override val seq: Long, override val seq: Long,
override val ordinal: Int, override val ordinal: Int,
val text: String, val text: String,
val piece: Piece,
override val gap: Dp, override val gap: Dp,
) : TranscriptUnit() { ) : TranscriptUnit() {
override val key: Any override val key: Any
@@ -93,12 +94,18 @@ sealed class TranscriptUnit {
get() = 0 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( data class PeerBlock(
override val seq: Long, override val seq: Long,
override val ordinal: Int, override val ordinal: Int,
val text: String, val text: String,
val piece: Piece,
val last: Boolean, val last: Boolean,
val spacing: Dp,
override val gap: Dp, override val gap: Dp,
/** The note this block belongs to; its key, not its seq. See [TranscriptItem.PeerNote]. */ /** The note this block belongs to; its key, not its seq. See [TranscriptItem.PeerNote]. */
val note: Any, 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 * 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. * 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 * Every settled reply is cut into its pieces ([pieces], via the caches on [replies] so a message is
* message is only ever split once), and so is an *opened* peer message -- [openNotes] is which ones * only ever cut once), and so is an *opened* peer message -- [openNotes] is which ones those are,
* those are, which is why the flatten needs it. A shut one is a single heading and cannot be worth * which is why the flatten needs it. A shut one is a single heading and cannot be worth splitting.
* splitting. The reply still arriving -- the newest row, until the status event that ends its turn * The reply still arriving -- the newest row, until the status event that ends its turn marks it
* marks it [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta, * [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta, and
* and splitting it here would parse the whole message per delta on whichever thread is composing. * 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 * [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. * 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 * 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 * 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 split exactly once. * seen, and a miss -- the one message that just finished streaming -- costs its parse exactly once.
*/ */
fun transcriptUnits( fun transcriptUnits(
rows: List<TranscriptRow>, 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 // No gap between the pieces: they are one card, and a card with a stripe through it is
// what any spacing here would draw. // what any spacing here would draw.
if (open) { if (open) {
val blocks = replies.blocksOf(item.text) val pieces = replies.piecesOf(item.text)
blocks.forEachIndexed { at, block -> var previous: Piece? = null
pieces.forEachIndexed { at, piece ->
units += units +=
TranscriptUnit.PeerBlock( TranscriptUnit.PeerBlock(
row.startSeq, row.startSeq,
at + 1, at + 1,
block, item.text,
last = at == blocks.lastIndex, piece,
last = at == pieces.lastIndex,
spacing = gapBefore(previous, piece),
gap = 0.dp, gap = 0.dp,
note = item.key, note = item.key,
) )
previous = piece
} }
} }
} else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) { } else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) {
@@ -210,16 +221,27 @@ fun transcriptUnits(
replies.splitReady(item.text) replies.splitReady(item.text)
) { ) {
var ordinal = 0 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 -> replies.partsOf(item.text).forEach { part ->
when (part) { when (part) {
is MessagePart.Prose -> is MessagePart.Prose -> {
replies.blocksOf(part.text).forEach { block -> var previous: Piece? = null
units += TranscriptUnit.Block(row.startSeq, ordinal, block, gap()) replies.piecesOf(part.text).forEach { piece ->
units +=
TranscriptUnit.Block(
row.startSeq,
ordinal,
part.text,
piece,
gap(gapBefore(previous, piece)),
)
ordinal++ ordinal++
previous = piece
}
} }
is MessagePart.Remembered -> { is MessagePart.Remembered -> {
units += TranscriptUnit.Memory(row.startSeq, ordinal, part, gap()) units +=
TranscriptUnit.Memory(row.startSeq, ordinal, part, gap(BLOCK_SPACING))
ordinal++ ordinal++
} }
} }
@@ -358,7 +380,8 @@ private val TranscriptUnit?.kind: String
get() = get() =
when (this) { when (this) {
null -> "the list's own" 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.PeerHead -> if (open) "peer heading (open)" else "peer heading"
is TranscriptUnit.PeerBlock -> "peer block" is TranscriptUnit.PeerBlock -> "peer block"
is TranscriptUnit.UserChunk -> "user slice" is TranscriptUnit.UserChunk -> "user slice"