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:
1 parent
801618ba0e
commit
6892dc7caf
9 files changed
+533
-161
No files matched your search
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user