Colour list markers by depth, highlight fences, draw images as links, and stream a list an item at a time

Items 1-5 of TRANSCRIPT_RENDERING.md's list, plus the AGP 9.4.0 bump from 7.
MarkdownRoot provides the renderer's locals itself instead of calling its
Markdown() composable; fences and indented blocks go through CodeFence.kt,
which shares the tool-input highlighter and a fence-language alias table;
an image in a paragraph is a link carrying its alt text, so every paragraph
is now platform text; LiveParse freezes the finished items of the tail list
so a forty-item list streams as forty paragraphs would.

Measured before, on the emulator (report from transcript-bench.sh over the
200-line fence fixture): draw phase 0.72ms per frame, transcript 0.36ms.
stream-bench.sh (new) streaming forty linked bullets on the old build:
markdown reparsed while streaming 483, 3.9ms mean, 11.6ms worst; record:
one block worst 1.6ms. The after runs, the on-screen check of the glyphs
and lint are recorded as owed in the doc's "What is next".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-03 13:49:51 -04:00
1 parent 801618ba0e
commit 6892dc7caf
9 files changed
+533 -161

No files matched your search

@@ -16,7 +16,6 @@ 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
@@ -98,9 +97,20 @@ val BLOCK_SPACING: Dp = 6.dp
* 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.
*
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply (see
* `LiveParse`): an item that is the first or last of its own parse but not of the list the reader
* sees keeps an inner item's padding, so nothing moves when the seam between segments does.
*/
@Composable
fun MarkdownPiece(parse: State, text: String, piece: Piece, modifier: Modifier = Modifier) {
fun MarkdownPiece(
parse: State,
text: String,
piece: Piece,
modifier: Modifier = Modifier,
continuesList: Boolean = false,
listContinues: Boolean = false,
) {
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.
@@ -124,8 +134,8 @@ fun MarkdownPiece(parse: State, text: String, piece: Piece, modifier: Modifier =
list = node,
item = items[piece.item],
index = piece.item,
first = piece.item == 0,
last = piece.item == items.lastIndex,
first = piece.item == 0 && !continuesList,
last = piece.item == items.lastIndex && !listContinues,
depth = 0,
modifier = modifier,
)
@@ -195,7 +205,7 @@ private fun MarkdownListItem(
} else if (list.type == MarkdownElementTypes.ORDERED_LIST) {
Marker("${list.startNumber(content) + index}. ", typography.ordered)
} else {
Marker("", typography.bullet)
Marker(BULLETS[depth % BULLETS.size], typography.bullet)
}
Column {
item.children.forEach { child ->
@@ -212,16 +222,24 @@ private fun MarkdownListItem(
}
}
/** The renderer's text colour on the marker; its styles carry none of their own. */
/** The marker in [listMarkerColor]; the renderer's styles carry no colour of their own. */
@Composable
private fun Marker(text: String, style: TextStyle) {
BasicText(text, style = style.copy(color = LocalMarkdownColors.current.text))
BasicText(text, style = style.copy(color = listMarkerColor))
}
private val ASTNode.isList: Boolean
/**
* The bullet at each depth, cycling past the third: a disc, a ring, a square -- the ladder a
* browser draws, so a nested list is told from its parent by the glyph as well as by the indent.
* Checked on the emulator's system fonts, which is what makes them safe to rely on; a glyph the
* platform lacks draws as a box, and that check is the price of adding one here.
*/
private val BULLETS = listOf("", "", "")
internal val ASTNode.isList: Boolean
get() = type == MarkdownElementTypes.ORDERED_LIST || type == MarkdownElementTypes.UNORDERED_LIST
private fun ASTNode.listItems(): List<ASTNode> = children.filter {
internal fun ASTNode.listItems(): List<ASTNode> = children.filter {
it.type == MarkdownElementTypes.LIST_ITEM
}