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
+89
-25
@@ -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,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.
|
||||
|
||||
Executable
+59
@@ -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 : //'
|
||||
Reference in new issue
Block a user