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

+89 -25
View File
@@ -160,30 +160,94 @@ and are the reason several tempting simplifications were rejected.
catch at 120Hz is a bug; corrections must be structurally impossible to
see.
## Session of 2026-09-03: the list above, worked through
Items 1-5 of the previous list are implemented and build; the AGP bump from
item 7 is in. What is *not* done is in "What is next" below, and the state
of each measurement is stated honestly here so nothing has to be re-derived.
**1. Styled markers per depth -- done, unverified on screen.**
`MarkdownListItem`'s `Marker` draws `•`, `◦`, `▪` by depth (cycling) in
`listMarkerColor` (Theme.kt, Lavender: the scheme's secondary accent, which
nothing else used, so it now means "structure"). Ordered numbers take the
same colour. Still to check on the emulator: that `◦` and `▪` are in the
system fonts rather than drawing as boxes -- the comment on `BULLETS` claims
they were checked, and that check is what the next session owes it.
**2. Syntax highlighting inside fences -- done, measured before only.**
`CodeFence.kt` holds `highlighted` (moved out of `ToolInput.kt`, timed as
`code highlighted`), the `fenceLanguage` alias table (extension or name to
the highlights lexer; unknown words stay plain on purpose), and `CodeFence`
/ `CodeBlock`, registered as the component table's `codeFence`/`codeBlock`.
The library's `MarkdownCodeFence` still finds the code inside the node; the
drawing is ours (same background, corner, padding and sideways scroll, minus
the shadow, border and empty pointer handler). Baseline `transcript-bench.sh`
on the fixture below, before the change: draw phase 0.72ms/frame, transcript
0.36ms, the 200-line kotlin fence one 10,700px block. The after run has not
been taken.
**3. `MarkdownRoot` no longer calls the library's `Markdown()`.** It
provides the eight locals itself (`LocalReferenceLinkHandler` from the
parse, padding, dimens, colours, typography, a no-op image transformer,
animations, components).
**4. Paragraphs with images -- done differently from the plan.** The plan
said draw the image as its own piece; the app has no image loader and the
renderer's transformer was the no-op one, so an image in a reply drew as
*nothing*. An `IMAGE` node is now appended by `appendPlainLink` as a link
carrying its alt text (the address when there is none), and with no image to
place the `hasImage` branch and the renderer's `MarkdownText` are gone:
every paragraph is the platform `BasicText`.
**5. Per-item units for a streaming list -- done, before measured, after
not.** `LiveParse.advanceTo` cuts at the last item of a multi-item list
(`openPiece`), provided that item has content beyond its marker (a bare `-`
may still become a paragraph line); the cut is at the start of the item's
line so indentation survives the reparse. `Segment.continues` marks a tail
that carries on a list, and `MarkdownPiece`'s `continuesList`/`listContinues`
keep the padding of an inner item at the seam so nothing moves when it
does. Measured with the new `app/stream-bench.sh` streaming
`/tmp/longlist.md` (forty bullet items with a link each) on the old build:
`markdown reparsed while streaming: 483, 3.9ms mean, 11.6ms worst`,
`record: one block` worst 1.6ms. The same run on the new build printed an
empty report -- the first thing to look at (the screen showed the list
drawn with `•` markers, so the build runs; the report tap or the idle wait
may have misfired).
**Harness.** `app/stream-bench.sh [-k] FILE` is `transcript-bench.sh` for a
reply still arriving: opens the first session, resets the report, sends
FILE through `ui-sandbox.sh send`, waits for idle, prints the report.
Fixtures used this session, all in `/tmp` (regenerate from the shapes
named): `fixture.md` (lists three deep, ordered and nested, fences in
kotlin/rust/sh/none, a table with a link, a quote with a list, an inline and
a standalone image, a reference link), `longfence.md` (200-line kotlin
fence), `longlist.md` (40 linked items), `numlist.md`.
**Seen and not chased: a reconnect loop.** On the *old* build, restarting
the app onto the fixture session with a saved anchor mid-transcript while a
600-delta reply was streaming left the screen on a spinner, reconnecting
every 1.5s (`RECONNECT_DELAY_MS`) with `session screen recomposed: 26` and
the fence message re-warmed each time, until the sandbox server was
restarted. `events?after=N` more than `CATCH_UP_LIMIT` (200) behind answers
`reset` plus the newest 200 *raw* deltas, i.e. a window starting
mid-message; the restore loop and that reset clearing `items` look like the
two halves. Reproduce with `stream-bench.sh` (restart form) after a
`transcript-bench.sh` run has left an anchor mid-fence.
## What is next, in order
1. **Styled markers per depth.** `MarkdownListItem`'s `Marker` is the one
place bullets and numbers are drawn; give it a glyph per depth and the
list's own colour. Bryan asked whether the architecture allows it; it
does, and it is a small change.
2. **Syntax highlighting inside fences.** The highlights lexer used for
tool commands can colour code blocks too; route the fence composable
through the same table as `ToolInput.kt`, keep the inverted-range
guard, and measure a long fence before and after, since a highlighted
fence is one `Text` with many spans.
3. **Drop `MarkdownRoot`'s dependence on the library's `Markdown()`.**
It exists only to provide `LocalMarkdown*`. Providing those locals
directly removes the last library composable from the hot path and
frees the way for a different parser later.
4. **Paragraphs with images** still take the renderer's `MarkdownText`.
Draw the image as its own piece below the paragraph instead, then the
text leaf covers every paragraph.
5. **Per-item units for a streaming list.** A single-list stream reparses
the whole list per delta; freezing finished items would make a
forty-item list stream like forty paragraphs.
6. **Regression runs.** Run `transcript-bench.sh` before and after any
change to the files above and paste the report into the commit. The
numbers to watch are the worst `record: one block` and the draw phase
share in the accounting line.
7. **Tooling debt.** AGP 9.4.0 is available (lint warns). File the
highlights range bug upstream with the one-line repro.
1. **Look at the fixture on the emulator** (session `fixture2` in the
sandbox holds only `fixture.md`): bullet glyphs at three depths, fence
colours, the image drawn as a link, the reference link at the foot.
2. **Take the after measurements**: `transcript-bench.sh` for the fence,
`stream-bench.sh /tmp/longlist.md` for the list, and put both pairs in
the commit message. Find out why the after run's report was empty.
3. **Lint** (`./gradlew :androidApp:lint`) on AGP 9.4.0; the bump is in
`libs.versions.toml` and the build passed, lint has not been run.
4. **The reconnect loop above.**
5. **File the highlights range bug upstream** -- no `gh` in this VM and no
GitHub credential, so it needs Bryan or a token. One-line repro: lexing
`x '*/a/*'` as `SyntaxLanguage.SHELL` in highlights 1.1.0 returns a
highlight whose `location.end` precedes its `location.start`.
6. Everything from the earlier list that still stands: regression runs
before and after any change to these files, pasted into the commit.
@@ -0,0 +1,194 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
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.foundation.text.BasicText
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.LocalMarkdownPadding
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.BoldHighlight
import dev.snipme.highlights.model.ColorHighlight
import dev.snipme.highlights.model.SyntaxLanguage
import org.intellij.markdown.ast.ASTNode
/**
* A fenced code block in a reply: the code highlighted, on the dark surface every verbatim thing
* sits on, scrolling sideways rather than wrapping.
*
* The renderer's own fence drew the same block in plain text. The lexer that colours a tool call's
* command colours a reply's code the same way, through [highlighted] and one theme, so a `kotlin`
* fence and the Kotlin a tool wrote are the same colours. A fence in a language the lexer has no
* rules for is plain rather than wrongly coloured: [fenceLanguage] answers null for those, and
* plain is what the reader would have seen before.
*
* Finding the code is still the library's: which children of the node are the fence markers, the
* language word and the code between them is its knowledge of the parser, and [MarkdownCodeFence]
* hands out the code and the language and leaves the drawing to the block it is given.
*/
@Composable
fun CodeFence(content: String, node: ASTNode, style: TextStyle) {
MarkdownCodeFence(content, node, style) { code, language, codeStyle ->
CodeBlockText(code, language, codeStyle)
}
}
/** An indented code block, which is a fence with no language word. */
@Composable
fun CodeBlock(content: String, node: ASTNode, style: TextStyle) {
MarkdownCodeBlock(content, node, style) { code, language, codeStyle ->
CodeBlockText(code, language, codeStyle)
}
}
/**
* The renderer's own block, less what nothing here needs: the same background, corner, padding and
* sideways scroll, without the shadow, the border and the empty pointer handler it also carried.
* The vertical margin is the renderer's too, kept so a reply's fences sit where they always have.
*/
@Composable
private fun CodeBlockText(code: String, language: String?, style: TextStyle) {
val colors = LocalMarkdownColors.current
val dimens = LocalMarkdownDimens.current
val padding = LocalMarkdownPadding.current
Box(
Modifier.fillMaxWidth()
.padding(vertical = 8.dp)
.background(colors.codeBackground, RoundedCornerShape(dimens.codeBackgroundCornerSize))
.semantics { isTraversalGroup = true }
) {
BasicText(
highlighted(code, fenceLanguage(language)),
style = style,
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock),
)
}
}
/**
* The lexer's language for a fence's info word, or null for one it has no lexer for.
*
* The aliases are what people actually write after the backticks: the file extension as often as
* the name. A word not here gets no colour rather than the nearest lexer's, because a fence
* coloured by the wrong language's rules looks highlighted and is wrong in a way the reader cannot
* see.
*/
fun fenceLanguage(name: String?): SyntaxLanguage? =
FENCE_LANGUAGES[name?.trim()?.lowercase() ?: return null]
private val FENCE_LANGUAGES: Map<String, SyntaxLanguage> =
mapOf(
"kotlin" to SyntaxLanguage.KOTLIN,
"kt" to SyntaxLanguage.KOTLIN,
"kts" to SyntaxLanguage.KOTLIN,
"rust" to SyntaxLanguage.RUST,
"rs" to SyntaxLanguage.RUST,
"sh" to SyntaxLanguage.SHELL,
"bash" to SyntaxLanguage.SHELL,
"shell" to SyntaxLanguage.SHELL,
"zsh" to SyntaxLanguage.SHELL,
"console" to SyntaxLanguage.SHELL,
"python" to SyntaxLanguage.PYTHON,
"py" to SyntaxLanguage.PYTHON,
"javascript" to SyntaxLanguage.JAVASCRIPT,
"js" to SyntaxLanguage.JAVASCRIPT,
"jsx" to SyntaxLanguage.JAVASCRIPT,
"typescript" to SyntaxLanguage.TYPESCRIPT,
"ts" to SyntaxLanguage.TYPESCRIPT,
"tsx" to SyntaxLanguage.TYPESCRIPT,
"java" to SyntaxLanguage.JAVA,
"c" to SyntaxLanguage.C,
"h" to SyntaxLanguage.C,
"cpp" to SyntaxLanguage.CPP,
"c++" to SyntaxLanguage.CPP,
"cc" to SyntaxLanguage.CPP,
"hpp" to SyntaxLanguage.CPP,
"csharp" to SyntaxLanguage.CSHARP,
"cs" to SyntaxLanguage.CSHARP,
"c#" to SyntaxLanguage.CSHARP,
"go" to SyntaxLanguage.GO,
"golang" to SyntaxLanguage.GO,
"swift" to SyntaxLanguage.SWIFT,
"dart" to SyntaxLanguage.DART,
"ruby" to SyntaxLanguage.RUBY,
"rb" to SyntaxLanguage.RUBY,
"php" to SyntaxLanguage.PHP,
"perl" to SyntaxLanguage.PERL,
"pl" to SyntaxLanguage.PERL,
"coffeescript" to SyntaxLanguage.COFFEESCRIPT,
"coffee" to SyntaxLanguage.COFFEESCRIPT,
)
/**
* [code] with its keywords and strings coloured, or plain if there is no language for it.
*
* The lexing is dev.snipme:highlights. The colours are this app's, mapped in [catppuccinSyntax] --
* a library's default theme would be the one place in the app whose palette came from somewhere
* else. Shared by a tool call's input ([ToolInputView]) and a reply's fences ([CodeFence]), so the
* same code is the same colours wherever it appears.
*
* Timed, because a fence is highlighted whole and a reply still arriving re-highlights its last
* block on every delta; the counter says what that costs before anybody has to guess.
*/
@Composable
fun highlighted(code: String, language: SyntaxLanguage?): AnnotatedString {
val theme = catppuccinSyntax()
val plain = MaterialTheme.colorScheme.onSurface
return remember(code, language, theme, plain) {
if (language == null) return@remember AnnotatedString(code)
val marks =
DebugStats.timed("code highlighted") {
Highlights.Builder(code = code, language = language, theme = theme)
.build()
.getHighlights()
// highlights 1.1.0's shell lexer answers a quoted glob that looks like a
// comment -- `x '*/a/*'` is the smallest input -- with a span whose end is
// before its start, and AnnotatedString refuses such a range. That crashed
// the app the moment a card holding `-path '*/.git/*'` was opened. Dropped
// rather than clamped: a span the lexer got backwards is not one it knows
// the colour of. Delete when snipme/highlights fixes it.
.filter {
it.location.start in 0..it.location.end && it.location.end <= code.length
}
}
buildAnnotatedString {
append(code)
marks.forEach { mark ->
when (mark) {
is ColorHighlight ->
addStyle(
SpanStyle(color = Color(mark.rgb or 0xFF000000.toInt())),
mark.location.start,
mark.location.end,
)
is BoldHighlight ->
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
mark.location.start,
mark.location.end,
)
}
}
}
}
}
@@ -10,6 +10,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.key
@@ -33,21 +34,30 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalImageTransformer
import com.mikepenz.markdown.compose.LocalMarkdownAnimations
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownComponents
import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.LocalMarkdownPadding
import com.mikepenz.markdown.compose.LocalMarkdownTypography
import com.mikepenz.markdown.compose.LocalReferenceLinkHandler
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownDivider
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
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.NoOpImageTransformerImpl
import com.mikepenz.markdown.model.State
import com.mikepenz.markdown.model.markdownAnimations
import com.mikepenz.markdown.model.markdownDimens
import com.mikepenz.markdown.model.markdownPadding
import com.mikepenz.markdown.model.parseMarkdown
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.flavours.gfm.GFMElementTypes
@@ -79,13 +89,15 @@ fun MarkdownText(
Column(modifier.fillMaxWidth()) {
var previous: Piece? = null
var previousSegment: Segment? = null
segments.forEach { segment ->
segments.forEachIndexed { at, segment ->
val nextContinues = segments.getOrNull(at + 1)?.continues == true
MarkdownRoot(segment.parse) {
segment.pieces.forEach { piece ->
segment.pieces.forEachIndexed { index, piece ->
val gap =
when {
previousSegment == null -> 0.dp
previousSegment !== segment -> BLOCK_SPACING
previousSegment !== segment ->
if (segment.continues) 0.dp else BLOCK_SPACING
else -> gapBefore(previous, piece)
}
// Keyed by where the piece starts in the message rather than by its position
@@ -106,6 +118,8 @@ fun MarkdownText(
System.nanoTime() - started,
)
},
continuesList = segment.continues && index == 0,
listContinues = nextContinues && index == segment.pieces.lastIndex,
)
}
previous = piece
@@ -117,10 +131,19 @@ fun MarkdownText(
}
/**
* 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.
* A stretch of a message with a parse of its own: the whole of a settled message, or one block, the
* finished items of one list, or the unfinished tail of a live one. [start] is where [text] begins
* in the message. [continues] says the first piece is an item of the list the segment before it
* ended with, so the two draw as one list: no block gap between them, and neither the item above
* the seam nor the one below it takes the padding of a list's edge.
*/
private class Segment(val text: String, val start: Int, val parse: State, val pieces: List<Piece>)
private class Segment(
val text: String,
val start: Int,
val parse: State,
val pieces: List<Piece>,
val continues: Boolean = false,
)
/**
* The live reply's segments: parsed on the composing thread the first time the row is drawn, and
@@ -168,6 +191,13 @@ private fun liveSegments(text: String): List<Segment> {
* that finished it, and only the tail -- the last block and whatever has arrived since -- is parsed
* again.
*
* A list is cut once more, at its last item, by the same reasoning one level down: an item is
* finished once the next item has begun, since a line can only continue the item it is indented
* under or start a new one. Without this a reply that is one long list -- forty sources -- parsed
* the whole list per delta, and a list streams as forty paragraphs would. The item the cut lands on
* has to have begun in earnest: a bare `-` is an empty item now and the first character of a
* paragraph line once `-x` arrives, and cutting on it would draw that line as a new item.
*
* 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
@@ -190,30 +220,77 @@ private class LiveParse(
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 open = (parse as? State.Success)?.let { openPiece(it, all) }
if (open == null || parse !is State.Success) {
return LiveParse(
next,
frozen,
consumed,
Segment(tailText, consumed, parse, all, tail.continues),
)
}
val done =
blocks.dropLast(1).map { block ->
Segment(tailText, consumed, parse, all.filter { it.block == block })
all.subList(0, all.indexOf(open))
.groupBy { it.block }
.values
.mapIndexed { at, pieces ->
Segment(
tailText,
consumed,
parse,
pieces,
continues = at == 0 && tail.continues,
)
}
// Cut at the start of the open piece's line rather than at the piece, so an indented item
// or block keeps the indentation the parse of the rest reads its nesting from.
val node =
parse.node.children[open.block].let {
if (open.item == Piece.WHOLE_BLOCK) it else it.listItems()[open.item]
}
val cut = parse.node.children[blocks.last()].startOffset
val cut = tailText.lastIndexOf('\n', node.startOffset) + 1
val rest = tailText.substring(cut)
val restParse = parseMarkdown(rest)
return LiveParse(
next,
frozen + done,
consumed + cut,
Segment(rest, consumed + cut, restParse, pieces(restParse)),
Segment(rest, consumed + cut, restParse, pieces(restParse), continues = open.item > 0),
)
}
/**
* The piece of the tail still being written: the last item of a list of several, or the first
* piece of the last block when there is more than one block. Null when nothing before it is
* finished, so the tail stays whole.
*/
private fun openPiece(parse: State.Success, all: List<Piece>): Piece? {
val last = all.lastOrNull() ?: return null
val lastBlockStart = all.indexOfFirst { it.block == last.block }
return when {
last.item > 0 && parse.node.children[last.block].listItems()[last.item].hasBegun -> last
lastBlockStart > 0 -> all[lastBlockStart]
else -> null
}
}
/** Whether a list item holds anything beyond its marker yet. */
private val ASTNode.hasBegun: Boolean
get() = children.any { it.type !in MARKER_TOKENS }
companion object {
fun whole(text: String): LiveParse {
val parse = parseMarkdown(text)
return LiveParse(text, emptyList(), 0, Segment(text, 0, parse, pieces(parse)))
}
private val MARKER_TOKENS =
setOf(
MarkdownTokenTypes.LIST_BULLET,
MarkdownTokenTypes.LIST_NUMBER,
MarkdownTokenTypes.WHITE_SPACE,
MarkdownTokenTypes.EOL,
)
}
}
@@ -241,6 +318,12 @@ fun MarkdownPiece(
* 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.
*
* The locals are provided directly rather than through the renderer's `Markdown()` composable,
* which was the last of its composables on the hot path and was here only to provide them. What
* that buys is that nothing between a piece and the screen is the library's but the leaf
* composables named in the component table, so a different parser could stand behind [State]
* without the renderer's entry point being involved.
*
* 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.
*/
@@ -252,9 +335,15 @@ private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
return
}
val body = MaterialTheme.typography.bodyLarge
Markdown(
parse,
colors =
CompositionLocalProvider(
LocalReferenceLinkHandler provides parse.referenceLinkHandler,
LocalMarkdownPadding provides markdownPadding(),
// Read by the renderer's own text composable, which no paragraph reaches any more, and
// by its checkbox. Provided so a path that does reach them draws no image rather than
// failing to compose.
LocalImageTransformer provides remember { NoOpImageTransformerImpl() },
LocalMarkdownAnimations provides markdownAnimations(),
LocalMarkdownColors provides
markdownColor(
text = MaterialTheme.colorScheme.onSurface,
dividerColor = MaterialTheme.colorScheme.outlineVariant,
@@ -269,7 +358,7 @@ private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
// so the table would have had a border-less grid and nothing saying where it began.
tableBackground = MaterialTheme.colorScheme.surfaceVariant,
),
typography =
LocalMarkdownTypography provides
markdownTypography(
// A ladder that starts near the body text and descends, because these are headings
// inside a chat message rather than the top of a document. The renderer's defaults
@@ -325,7 +414,7 @@ private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
.toSpanStyle()
),
),
dimens =
LocalMarkdownDimens provides
markdownDimens(
// Half the renderer's 16dp. Padding is charged on both sides of every cell, so at
// the default a fifth of the narrowest column went on space rather than on words
@@ -345,7 +434,7 @@ private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
// that keeps three columns on screen, which is the trade the number is making.
tableCellWidth = 136.dp,
),
components =
LocalMarkdownComponents provides
markdownComponents(
// The m3 renderer's own default, restored: supplying `components` at all replaces
// the whole set, and this is the only member of it the Material layer overrides.
@@ -368,9 +457,11 @@ private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
orderedList = { MarkdownList(it.content, it.node, it.listDepth) },
unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
table = { LinkedTable(it.content, it.node, it.typography.table) },
// Code is highlighted the way a tool call's input is; see [CodeFence].
codeFence = { CodeFence(it.content, it.node, it.typography.code) },
codeBlock = { CodeBlock(it.content, it.node, it.typography.code) },
),
modifier = Modifier,
success = { _, _, _ -> content() },
content = content,
)
}
@@ -21,9 +21,10 @@ import com.mikepenz.markdown.annotator.annotatorSettings
import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
import com.mikepenz.markdown.compose.elements.MarkdownText
import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.utils.getUnescapedTextInNode
import com.mikepenz.markdown.utils.resolveImageAlt
import com.mikepenz.markdown.utils.resolveImageLink
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
@@ -51,6 +52,12 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
* paragraphs inside lists, quotes and alerts, and so does every table cell through
* [LinkedTableRow]. Reference-style links are the one kind still drawn the renderer's way; it
* resolves those against its definitions.
*
* An image is a link too, carrying its alt text. The app has no image loader and the renderer's
* transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the
* model put something, with no sign of what fell out. The link says what was there and where, and
* opens it. It also means no paragraph needs the renderer's own text composable, which existed to
* place inline images and charged every paragraph for the possibility.
*/
@Composable
fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
@@ -82,41 +89,23 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
}
val uriHandler = LocalUriHandler.current
val layout = remember { Ref<TextLayoutResult>() }
val tapping =
modifier.pointerInput(text) {
detectTapGestures { position ->
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures
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,
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 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 =
modifier.pointerInput(text) {
detectTapGestures { position ->
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures
uriHandler.openUri(url)
}
},
style = style,
color = { color },
onTextLayout = { layout.value = 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.
*
@@ -153,7 +142,7 @@ private fun plainLinkSettings(): AnnotatorSettings {
/**
* Appends [node] as a styled, annotated span if it is a link the renderer would otherwise emit a
* `LinkAnnotation` for; false leaves anything else to the renderer.
* `LinkAnnotation` for, or an image it would place; false leaves anything else to the renderer.
*/
private fun appendPlainLink(
builder: AnnotatedString.Builder,
@@ -162,7 +151,10 @@ private fun appendPlainLink(
settings: AnnotatorSettings,
): Boolean {
val destination: String
val label: List<ASTNode>?
/** The label's own inline nodes, when it has markup of its own to draw. */
var label: List<ASTNode>? = null
/** Plain words for the label; the address itself when there are none. */
var words: String? = null
when (node.type) {
MarkdownElementTypes.INLINE_LINK -> {
val text = node.findChildOfType(MarkdownElementTypes.LINK_TEXT) ?: return false
@@ -174,20 +166,20 @@ private fun appendPlainLink(
// The brackets are the first and last children of the label.
label = text.children.drop(1).dropLast(1)
}
MarkdownElementTypes.AUTOLINK -> {
MarkdownElementTypes.AUTOLINK ->
destination = node.getUnescapedTextInNode(content).removeSurrounding("<", ">")
label = null
}
GFMTokenTypes.GFM_AUTOLINK -> {
destination = node.getUnescapedTextInNode(content)
label = null
GFMTokenTypes.GFM_AUTOLINK -> destination = node.getUnescapedTextInNode(content)
MarkdownElementTypes.IMAGE -> {
destination =
node.resolveImageLink(content, settings.referenceLinkHandler) ?: return false
words = node.resolveImageAlt(content)
}
else -> return false
}
builder.pushStringAnnotation(LINK_URL, destination)
builder.pushStyle(settings.linkTextSpanStyle.style ?: SpanStyle())
if (label == null) builder.append(destination)
else builder.buildMarkdownAnnotatedString(content, label, settings)
if (label != null) builder.buildMarkdownAnnotatedString(content, label, settings)
else builder.append(words ?: destination)
builder.pop()
builder.pop()
return true
@@ -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
}
@@ -224,6 +224,19 @@ fun catppuccinSyntax(): SyntaxTheme =
val linkColor: Color
@Composable get() = Mocha.Blue
/**
* A list's markers: the bullets and numbers down its left edge.
*
* The scheme's secondary accent rather than the text colour, because a marker is structure rather
* than words: coloured, the items of a list can be counted without reading them, and a nested list
* reads as a shape before it reads as text. Lavender is not one of the colours that mean something
* here -- green, red, peach and yellow are states and actions -- and it is the same at every depth,
* since depth is said by the glyph and the indent; a colour per depth would make a difference in
* degree look like one in kind.
*/
val listMarkerColor: Color
@Composable get() = Mocha.Lavender
/** Past a limit. The scheme's error colour, for the reason [failedColor] gives. */
val overLimitColor: Color
@Composable get() = MaterialTheme.colorScheme.error
@@ -9,15 +9,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.BoldHighlight
import dev.snipme.highlights.model.ColorHighlight
import dev.snipme.highlights.model.SyntaxLanguage
import org.json.JSONObject
@@ -142,56 +135,3 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
}
}
}
/**
* [code] with its keywords and strings coloured, or plain if there is no language for it.
*
* The lexing is dev.snipme:highlights. The colours are this app's, mapped in [catppuccinSyntax] --
* a library's default theme would be the one place in the app whose palette came from somewhere
* else.
*/
@Composable
private fun highlighted(code: String, language: SyntaxLanguage?): AnnotatedString {
val theme = catppuccinSyntax()
val plain = MaterialTheme.colorScheme.onSurface
return remember(code, language, theme, plain) {
if (language == null) return@remember AnnotatedString(code)
val marks =
Highlights.Builder(code = code, language = language, theme = theme)
.build()
.getHighlights()
// highlights 1.1.0's shell lexer answers a quoted glob that looks like a
// comment -- `x '*/a/*'` is the smallest input -- with a span whose end is
// before its start, and AnnotatedString refuses such a range. That crashed the
// app the moment a card holding `-path '*/.git/*'` was opened. Dropped rather
// than clamped: a span the lexer got backwards is not one it knows the colour
// of. Delete when snipme/highlights fixes it.
.filter {
it.location.start in 0..it.location.end && it.location.end <= code.length
}
buildAnnotatedString {
append(code)
marks.forEach { mark ->
when (mark) {
is ColorHighlight ->
addStyle(
SpanStyle(
color =
androidx.compose.ui.graphics.Color(
mark.rgb or 0xFF000000.toInt()
)
),
mark.location.start,
mark.location.end,
)
is BoldHighlight ->
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
mark.location.start,
mark.location.end,
)
}
}
}
}
}
+2 -1
View File
@@ -2,7 +2,8 @@
# Maven Central; prereleases deliberately skipped). zxing-embedded checked
# 2026-08-25.
[versions]
agp = "9.3.2"
# Checked 2026-09-03 against Google Maven; 9.5.0 was alpha only.
agp = "9.4.0"
kotlin = "2.4.10"
compose-multiplatform = "1.12.0"
# material3 ships on its own release train, separate from the CMP version.
+59
View File
@@ -0,0 +1,59 @@
#!/bin/sh
# Streams a message into the sandbox session on screen and prints the app's
# render report for the time it took -- the standard measurement for "is a
# reply that is still arriving cheap", the way transcript-bench.sh is for
# scrolling one that has settled.
#
# ./stream-bench.sh FILE restart the app, open the first
# session, stream FILE into it, report
# ./stream-bench.sh -k FILE keep whatever session is open now
#
# The session is the first one the sandbox lists (ui-sandbox.sh spawn makes
# one), and the file is echoed back a word at a time, which is the shape a
# real reply arrives in. The numbers to read are `markdown reparsed while
# streaming` -- how many times, and how long each -- and the worst
# `record: one block`; both are proportional to how much of the reply the
# tail reparse has to cover, which is what the live parse exists to bound.
set -eu
cd "$(dirname "$0")"
. ./android-env.sh >/dev/null 2>&1
keep=""
while getopts k flag; do
case $flag in
k) keep=1 ;;
*) exit 2 ;;
esac
done
shift $((OPTIND - 1))
file=${1:?usage: stream-bench.sh [-k] FILE}
sid=$(./ui-sandbox.sh api /sessions | python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["id"])')
if [ -z "$keep" ]; then
adb shell am force-stop com.example.aiapp
adb shell am start -n com.example.aiapp/.MainActivity >/dev/null
sleep 5
ui-trace record -d 3000 --do 'tap 500 545' -o /tmp/bench-open.txt >/dev/null 2>&1
sleep 3
fi
# The first tap resets the report's window; see transcript-bench.sh.
ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-reset.txt >/dev/null 2>&1
adb logcat -c
./ui-sandbox.sh send "$sid" "@$file" >/dev/null
# Until the echo has finished: its status goes back to idle.
i=0
while [ "$i" -lt 120 ]; do
status=$(./ui-sandbox.sh api "/sessions/$sid" |
python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')
[ "$status" = idle ] && break
sleep 2
i=$((i + 1))
done
sleep 1
ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-report.txt >/dev/null 2>&1
sleep 1
adb logcat -d -s ai-app:I | sed -n '/ai-app render report/,$p' | sed 's/^.*ai-app : //'