Compare commits
3
Commits
3c3b4e62fd
...
4bc69e8f9c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bc69e8f9c | ||
|
|
48bb7de304 | ||
|
|
a9ab9b3e5a |
No files matched your search
@@ -334,17 +334,32 @@ first if a remote spawn ever mangles an argument.
|
|||||||
success. The phone's half is `uniqueItems`, which every list keyed on a
|
success. The phone's half is `uniqueItems`, which every list keyed on a
|
||||||
server-chosen id goes through: a repeat there must never be able to
|
server-chosen id goes through: a repeat there must never be able to
|
||||||
close the app, whatever produced it.
|
close the app, whatever produced it.
|
||||||
|
- **A reply is drawn as pieces of one parse, never as re-parsed
|
||||||
|
substrings.** `MarkdownPieces.kt`: a `Piece` addresses a top-level block
|
||||||
|
of the message's tree, or one item of a top-level list, and every piece
|
||||||
|
is drawn from the same `State.Success` that `ParsedReplies` cached and
|
||||||
|
`warm` made. That is what bounds a lazy-list item (one paragraph, one
|
||||||
|
bullet) without parsing a message more than once, and it is why a
|
||||||
|
forty-item list of sources is forty units rather than one. The renderer
|
||||||
|
is still the parser and the environment: `MarkdownRoot` provides its
|
||||||
|
locals and `MarkdownElement` dispatches a whole block through our
|
||||||
|
component table, so paragraphs, headings and table cells are span-linked
|
||||||
|
`LinkedText` (links as spans with one tap detector per text, not a layout
|
||||||
|
node per link -- the cost that made a list of sources bumpy) and lists
|
||||||
|
are ours wherever the dispatch meets one. A heading's words are its
|
||||||
|
`ATX_CONTENT`/`SETEXT_CONTENT` child; the inline builder draws nothing
|
||||||
|
for a node type it does not know, so hand it the child.
|
||||||
- **A markdown table wraps its cells and never cuts one off.** The
|
- **A markdown table wraps its cells and never cuts one off.** The
|
||||||
renderer's own defaults draw every cell at one line with an ellipsis,
|
renderer's own defaults draw every cell at one line with an ellipsis,
|
||||||
which on a phone loses most of a table -- and an elided cell looks
|
which on a phone loses most of a table -- and an elided cell looks
|
||||||
exactly like a short one, so nothing on screen says anything was cut.
|
exactly like a short one, so nothing on screen says anything was cut.
|
||||||
`Markdown.kt` supplies its own header and row blocks with `maxLines =
|
`Markdown.kt` supplies its own rows (`LinkedTableRow`): as many lines as
|
||||||
Int.MAX_VALUE` and `TextOverflow.Clip`, cells aligned to the top of the
|
a cell needs, cells aligned to the top of the row so a two-line cell
|
||||||
row so a two-line cell does not re-centre its neighbours. Width is the
|
does not re-centre its neighbours, and each cell a `LinkedText`. Width is
|
||||||
other half: a column narrows to 136dp and no further, and past that the
|
the other half: a column narrows to 136dp and no further, and past that
|
||||||
whole table scrolls sideways rather than squeezing -- 136 because it is
|
the whole table scrolls sideways rather than squeezing -- 136 because it
|
||||||
the widest floor that still fits three columns across a phone, which is
|
is the widest floor that still fits three columns across a phone, which
|
||||||
the commonest table there is. Exercise it with the echo driver's
|
is the commonest table there is. Exercise it with the echo driver's
|
||||||
`/table N` (default six columns), which writes long cells on purpose:
|
`/table N` (default six columns), which writes long cells on purpose:
|
||||||
a fixture of tidy one-word values renders fine whether or not the
|
a fixture of tidy one-word values renders fine whether or not the
|
||||||
truncation is fixed.
|
truncation is fixed.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
adds) in a release build, so a frame cost measured on the real
|
adds) in a release build, so a frame cost measured on the real
|
||||||
device can be attributed. shell="true" limits it to profilers
|
device can be attributed. shell="true" limits it to profilers
|
||||||
run from the shell; it grants nothing to other apps. -->
|
run from the shell; it grants nothing to other apps. -->
|
||||||
<profileable android:shell="true" />
|
<profileable android:shell="true" tools:targetApi="q" />
|
||||||
<!-- adjustResize (not the system's default pan): the layout handles
|
<!-- adjustResize (not the system's default pan): the layout handles
|
||||||
the keyboard itself via imePadding(), so the window must resize
|
the keyboard itself via imePadding(), so the window must resize
|
||||||
rather than slide the top bar off screen. -->
|
rather than slide the top bar off screen. -->
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ private const val CAP = 20_000
|
|||||||
* documentation is explicit that doing that on the main thread taxes the very thing being measured.
|
* documentation is explicit that doing that on the main thread taxes the very thing being measured.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun recordFrames() {
|
fun RecordFrames() {
|
||||||
val window = LocalContext.current.activity()?.window
|
val window = LocalContext.current.activity()?.window
|
||||||
DisposableEffect(window) {
|
DisposableEffect(window) {
|
||||||
if (window == null) return@DisposableEffect onDispose {}
|
if (window == null) return@DisposableEffect onDispose {}
|
||||||
|
|||||||
@@ -1,25 +1,43 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
|
import androidx.compose.ui.semantics.CollectionInfo
|
||||||
|
import androidx.compose.ui.semantics.CollectionItemInfo
|
||||||
|
import androidx.compose.ui.semantics.collectionInfo
|
||||||
|
import androidx.compose.ui.semantics.collectionItemInfo
|
||||||
|
import androidx.compose.ui.semantics.heading
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.TextLinkStyles
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||||
|
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
||||||
import com.mikepenz.markdown.compose.components.markdownComponents
|
import com.mikepenz.markdown.compose.components.markdownComponents
|
||||||
import com.mikepenz.markdown.compose.elements.MarkdownTable
|
import com.mikepenz.markdown.compose.elements.MarkdownDivider
|
||||||
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader
|
import com.mikepenz.markdown.compose.elements.listDepth
|
||||||
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
|
|
||||||
import com.mikepenz.markdown.m3.Markdown
|
import com.mikepenz.markdown.m3.Markdown
|
||||||
import com.mikepenz.markdown.m3.elements.MarkdownCheckBox
|
import com.mikepenz.markdown.m3.elements.MarkdownCheckBox
|
||||||
import com.mikepenz.markdown.m3.markdownColor
|
import com.mikepenz.markdown.m3.markdownColor
|
||||||
@@ -31,23 +49,211 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.intellij.markdown.ast.ASTNode
|
import org.intellij.markdown.ast.ASTNode
|
||||||
|
import org.intellij.markdown.ast.findChildOfType
|
||||||
|
import org.intellij.markdown.flavours.gfm.GFMElementTypes
|
||||||
|
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An assistant's reply, rendered as the markdown it is written in.
|
* [text] drawn as its pieces, one under the other; see [Piece].
|
||||||
|
*
|
||||||
|
* [live] is the reply still arriving, and two things are different for it. Its parse is incremental
|
||||||
|
* -- see [LiveParse] -- so a delta costs a parse of the block it landed in rather than of the whole
|
||||||
|
* message. And its pieces get a layer each: when drawing is invalidated, only the piece that
|
||||||
|
* changed is re-recorded instead of the whole reply, which is worth a great deal while every delta
|
||||||
|
* invalidates the message and a finished one can be twenty-five screens tall. It is worth nothing
|
||||||
|
* once the message stops changing -- measured on a Pixel 9 Pro XL, whole rows were re-recorded 65
|
||||||
|
* times in fifty seconds of reading -- and it is not free: each layer is a layout node and a
|
||||||
|
* display list held for the life of the row, and live node count is what the per-frame cost of the
|
||||||
|
* transcript scales with.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MarkdownText(
|
||||||
|
text: String,
|
||||||
|
replies: ParsedReplies,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
live: Boolean = false,
|
||||||
|
) {
|
||||||
|
val segments =
|
||||||
|
if (live) liveSegments(text)
|
||||||
|
else remember(text) { listOf(Segment(text, 0, replies.of(text), replies.piecesOf(text))) }
|
||||||
|
Column(modifier.fillMaxWidth()) {
|
||||||
|
var previous: Piece? = null
|
||||||
|
var previousSegment: Segment? = null
|
||||||
|
segments.forEach { segment ->
|
||||||
|
MarkdownRoot(segment.parse) {
|
||||||
|
segment.pieces.forEach { piece ->
|
||||||
|
val gap =
|
||||||
|
when {
|
||||||
|
previousSegment == null -> 0.dp
|
||||||
|
previousSegment !== segment -> BLOCK_SPACING
|
||||||
|
else -> gapBefore(previous, piece)
|
||||||
|
}
|
||||||
|
// Keyed by where the piece starts in the message rather than by its position
|
||||||
|
// in this column, so a delta landing in the last block leaves every other
|
||||||
|
// piece's composition alone -- and a block keeps its key when it freezes.
|
||||||
|
key(segment.start, piece) {
|
||||||
|
MarkdownPiece(
|
||||||
|
segment.parse,
|
||||||
|
segment.text,
|
||||||
|
piece,
|
||||||
|
Modifier.padding(top = gap)
|
||||||
|
.then(if (live) Modifier.graphicsLayer() else Modifier)
|
||||||
|
.drawWithContent {
|
||||||
|
val started = System.nanoTime()
|
||||||
|
drawContent()
|
||||||
|
DebugStats.record(
|
||||||
|
"record: one block",
|
||||||
|
System.nanoTime() - started,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
previous = piece
|
||||||
|
previousSegment = segment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stretch of a message with a parse of its own: the whole of a settled message, or one block or
|
||||||
|
* the unfinished tail of a live one. [start] is where [text] begins in the message.
|
||||||
|
*/
|
||||||
|
private class Segment(val text: String, val start: Int, val parse: State, val pieces: List<Piece>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live reply's segments: parsed on the composing thread the first time the row is drawn, and
|
||||||
|
* incrementally off it for every delta afterwards.
|
||||||
|
*
|
||||||
|
* The first parse has to be inline. The renderer's own asynchronous path draws an empty loading
|
||||||
|
* slot until its result arrives, so a row is measured at nothing before it is measured at its real
|
||||||
|
* height, and the transcript above it collapses and springs back. Seen with five replies on screen
|
||||||
|
* at once, every one of them blank, the whole conversation shrunk to fit a single screen; a moment
|
||||||
|
* later it was all there again. That is the "skipping up and down" this list must never do.
|
||||||
|
*
|
||||||
|
* Every parse after the first is off the composing thread, and the row keeps drawing the parse it
|
||||||
|
* already has until the new one lands, so there is never a frame without a height. What is on
|
||||||
|
* screen is always a real prefix of the reply rather than a guess at it; it is simply one parse
|
||||||
|
* behind.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun liveSegments(text: String): List<Segment> {
|
||||||
|
val parsed = remember {
|
||||||
|
mutableStateOf(
|
||||||
|
DebugStats.timed("markdown parsed while composing") { LiveParse.whole(text) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LaunchedEffect(text) {
|
||||||
|
if (parsed.value.text == text) return@LaunchedEffect
|
||||||
|
val previous = parsed.value
|
||||||
|
parsed.value =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
DebugStats.timed("markdown reparsed while streaming") { previous.advanceTo(text) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed.value.segments
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reply still arriving, parsed a block at a time.
|
||||||
|
*
|
||||||
|
* Reparsing the whole message per delta was fine for a short reply and not for a long one: a
|
||||||
|
* twenty-five-screen reply parses in tens of milliseconds, hundreds of times, and although that ran
|
||||||
|
* off the composing thread it was every core busy while the frame's own thread waited for one.
|
||||||
|
* Markdown's blocks make the cut safe: a top-level block that another block has started *after* is
|
||||||
|
* finished -- nothing appended later can reach back into it, since a paragraph ends at the blank
|
||||||
|
* line or the block that interrupts it, a fence at its closing fence, a list at the first line that
|
||||||
|
* is neither an item nor indented under one. So every block but the last is [frozen] with the parse
|
||||||
|
* that finished it, and only the tail -- the last block and whatever has arrived since -- is parsed
|
||||||
|
* again.
|
||||||
|
*
|
||||||
|
* What the cut gives up is one thing: a reference definition arriving later than a link that uses
|
||||||
|
* it, since the frozen block's parse never sees it. The link draws as its brackets until the reply
|
||||||
|
* settles and is parsed whole by [warm], which is the same moment every other transient of
|
||||||
|
* streaming is put right.
|
||||||
|
*/
|
||||||
|
private class LiveParse(
|
||||||
|
val text: String,
|
||||||
|
private val frozen: List<Segment>,
|
||||||
|
/** How much of [text] the frozen segments cover; the tail starts here. */
|
||||||
|
private val consumed: Int,
|
||||||
|
private val tail: Segment,
|
||||||
|
) {
|
||||||
|
val segments: List<Segment>
|
||||||
|
get() = frozen + tail
|
||||||
|
|
||||||
|
fun advanceTo(next: String): LiveParse {
|
||||||
|
// Anything but an append to what was frozen -- a message replaced, a stream reset --
|
||||||
|
// starts over.
|
||||||
|
if (!next.regionMatches(0, text, 0, consumed)) return whole(next)
|
||||||
|
val tailText = next.substring(consumed)
|
||||||
|
val parse = parseMarkdown(tailText)
|
||||||
|
val all = pieces(parse)
|
||||||
|
val blocks = all.map { it.block }.distinct()
|
||||||
|
if (blocks.size <= 1 || parse !is State.Success) {
|
||||||
|
return LiveParse(next, frozen, consumed, Segment(tailText, consumed, parse, all))
|
||||||
|
}
|
||||||
|
val done =
|
||||||
|
blocks.dropLast(1).map { block ->
|
||||||
|
Segment(tailText, consumed, parse, all.filter { it.block == block })
|
||||||
|
}
|
||||||
|
val cut = parse.node.children[blocks.last()].startOffset
|
||||||
|
val rest = tailText.substring(cut)
|
||||||
|
val restParse = parseMarkdown(rest)
|
||||||
|
return LiveParse(
|
||||||
|
next,
|
||||||
|
frozen + done,
|
||||||
|
consumed + cut,
|
||||||
|
Segment(rest, consumed + cut, restParse, pieces(restParse)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun whole(text: String): LiveParse {
|
||||||
|
val parse = parseMarkdown(text)
|
||||||
|
return LiveParse(text, emptyList(), 0, Segment(text, 0, parse, pieces(parse)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One [piece] of [text], drawn on its own -- a unit of the transcript list. */
|
||||||
|
@Composable
|
||||||
|
fun MarkdownPiece(
|
||||||
|
text: String,
|
||||||
|
piece: Piece,
|
||||||
|
replies: ParsedReplies,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// Remembered so a message the flatten drew before [warm] reached it is parsed once here, not
|
||||||
|
// once per composition.
|
||||||
|
val parse = remember(text) { replies.of(text) }
|
||||||
|
MarkdownRoot(parse) { MarkdownPiece(parse, text, piece, modifier) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The renderer's own environment -- its colours, type scale, dimensions, component table and
|
||||||
|
* reference links -- around whatever draws pieces of [parse].
|
||||||
*
|
*
|
||||||
* The parsing is the library's. Markdown is somebody else's specification, and a hand-written
|
* The parsing is the library's. Markdown is somebody else's specification, and a hand-written
|
||||||
* subset of one disagrees with it at the edges -- which is where the bug reports come from, one
|
* parser would get the edge cases wrong one case at a time. So is the environment: the element
|
||||||
* case at a time. This file's whole job is the mapping onto the app's palette and type scale.
|
* composables its dispatch reaches read these locals, and providing them once here is what lets a
|
||||||
|
* piece be drawn anywhere -- in a message's column, or as one item of the transcript list.
|
||||||
|
* Everything below this is the mapping onto the app's palette and type scale.
|
||||||
*
|
*
|
||||||
* Colours come from the theme rather than from the renderer's defaults, so code, links and rules
|
* Colours come from the theme rather than from the renderer's defaults, so code, links and rules
|
||||||
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
|
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modifier) {
|
private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
|
||||||
|
if (parse !is State.Success) {
|
||||||
|
// Nothing below needs the environment; [MarkdownPiece] draws the words plainly.
|
||||||
|
content()
|
||||||
|
return
|
||||||
|
}
|
||||||
val body = MaterialTheme.typography.bodyLarge
|
val body = MaterialTheme.typography.bodyLarge
|
||||||
val parsed = parsedMarkdown(text, replies)
|
|
||||||
Markdown(
|
Markdown(
|
||||||
parsed,
|
parse,
|
||||||
colors =
|
colors =
|
||||||
markdownColor(
|
markdownColor(
|
||||||
text = MaterialTheme.colorScheme.onSurface,
|
text = MaterialTheme.colorScheme.onSurface,
|
||||||
@@ -149,117 +355,116 @@ fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modi
|
|||||||
// is the renderer's own pairing.
|
// is the renderer's own pairing.
|
||||||
text = { LinkedText(it, it.typography.text) },
|
text = { LinkedText(it, it.typography.text) },
|
||||||
paragraph = { LinkedText(it, it.typography.paragraph) },
|
paragraph = { LinkedText(it, it.typography.paragraph) },
|
||||||
heading1 = { LinkedText(it, it.typography.h1, heading = true) },
|
heading1 = { LinkedHeading(it, it.typography.h1) },
|
||||||
heading2 = { LinkedText(it, it.typography.h2, heading = true) },
|
heading2 = { LinkedHeading(it, it.typography.h2) },
|
||||||
heading3 = { LinkedText(it, it.typography.h3, heading = true) },
|
heading3 = { LinkedHeading(it, it.typography.h3) },
|
||||||
heading4 = { LinkedText(it, it.typography.h4, heading = true) },
|
heading4 = { LinkedHeading(it, it.typography.h4) },
|
||||||
heading5 = { LinkedText(it, it.typography.h5, heading = true) },
|
heading5 = { LinkedHeading(it, it.typography.h5) },
|
||||||
heading6 = { LinkedText(it, it.typography.h6, heading = true) },
|
heading6 = { LinkedHeading(it, it.typography.h6) },
|
||||||
setextHeading1 = { LinkedText(it, it.typography.h1, heading = true) },
|
setextHeading1 = { LinkedHeading(it, it.typography.h1) },
|
||||||
setextHeading2 = { LinkedText(it, it.typography.h2, heading = true) },
|
setextHeading2 = { LinkedHeading(it, it.typography.h2) },
|
||||||
table = {
|
// Lists are ours wherever the renderer's dispatch meets one -- inside a quote --
|
||||||
MarkdownTable(
|
// so they draw like the top-level ones the transcript cuts into items.
|
||||||
it.content,
|
orderedList = { MarkdownList(it.content, it.node, it.listDepth) },
|
||||||
it.node,
|
unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
|
||||||
style = it.typography.table,
|
table = { LinkedTable(it.content, it.node, it.typography.table) },
|
||||||
headerBlock = ::WrappingTableHeader,
|
),
|
||||||
rowBlock = ::WrappingTableRow,
|
modifier = Modifier,
|
||||||
|
success = { _, _, _ -> content() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A table: its rows, on the renderer's tinted, rounded background, as wide as its columns need.
|
||||||
|
*
|
||||||
|
* Each column has a floor ([markdownDimens]'s `tableCellWidth`), so the table is at least
|
||||||
|
* columns-times-floor wide; narrower than the room it has, it spreads to fill it, and wider, it
|
||||||
|
* scrolls sideways rather than squeezing. The renderer decided that with a `BoxWithConstraints`,
|
||||||
|
* which is a subcomposition; here it is one layout modifier, and the trick is where it sits.
|
||||||
|
* `fillMaxWidth` fixes the minimum width to the room available, the horizontal scroll passes that
|
||||||
|
* minimum through to its content while lifting the maximum to unbounded, and the modifier after it
|
||||||
|
* reads the minimum back as the room and sizes the rows to the larger of that and the floor. The
|
||||||
|
* scroll then has exactly the overflow to scroll, which is none when the table fits.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
|
||||||
|
val dimens = LocalMarkdownDimens.current
|
||||||
|
val colors = LocalMarkdownColors.current
|
||||||
|
val columns =
|
||||||
|
remember(node) {
|
||||||
|
node.findChildOfType(GFMElementTypes.HEADER)?.children?.count {
|
||||||
|
it.type == GFMTokenTypes.CELL
|
||||||
|
} ?: 0
|
||||||
|
}
|
||||||
|
val rows = remember(node) { node.children.count { it.type == GFMElementTypes.ROW } + 1 }
|
||||||
|
val floor = dimens.tableCellWidth * columns
|
||||||
|
Column(
|
||||||
|
Modifier.background(colors.tableBackground, RoundedCornerShape(dimens.tableCornerSize))
|
||||||
|
.semantics { collectionInfo = CollectionInfo(rowCount = rows, columnCount = columns) }
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.layout { measurable, constraints ->
|
||||||
|
val width = maxOf(constraints.minWidth, floor.roundToPx())
|
||||||
|
val placeable =
|
||||||
|
measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
|
||||||
|
layout(width, placeable.height) { placeable.place(0, 0) }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
var rowIndex = 1
|
||||||
|
node.children.forEach { child ->
|
||||||
|
when (child.type) {
|
||||||
|
GFMElementTypes.HEADER -> LinkedTableRow(content, child, style, rowIndex = 0)
|
||||||
|
GFMElementTypes.ROW -> LinkedTableRow(content, child, style, rowIndex = rowIndex++)
|
||||||
|
GFMTokenTypes.TABLE_SEPARATOR -> MarkdownDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of a table -- the header when [rowIndex] is zero -- with every cell a [LinkedText].
|
||||||
|
*
|
||||||
|
* The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means
|
||||||
|
* most of a table is simply not readable: anything past about twenty characters ends in "..." with
|
||||||
|
* no way to see the rest, and an elided cell looks like a short one, so a table of measurements
|
||||||
|
* reads as a table of plausible shorter measurements. And they draw a link in a cell as its own
|
||||||
|
* layout node, the cost [LinkedText] exists to avoid.
|
||||||
|
*
|
||||||
|
* So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell
|
||||||
|
* beside a one-line one centred the short one against the middle of the tall one and lost the line
|
||||||
|
* the reader was reading across. What the wrapping does *not* do is make a wide table fit;
|
||||||
|
* [LinkedTable] scrolls it instead, which is the right answer for too many columns -- wrapping a
|
||||||
|
* six-column table into the width of a phone would give every cell one word per line.
|
||||||
|
*
|
||||||
|
* The semantics are the renderer's: each cell is an item of the table's collection, and a header
|
||||||
|
* cell is a heading.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowIndex: Int) {
|
||||||
|
val padding = LocalMarkdownDimens.current.tableCellPadding
|
||||||
|
val header = rowIndex == 0
|
||||||
|
val cellStyle = if (header) style.copy(fontWeight = FontWeight.Bold) else style
|
||||||
|
Row(verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
row.children
|
||||||
|
.filter { it.type == GFMTokenTypes.CELL }
|
||||||
|
.forEachIndexed { column, cell ->
|
||||||
|
LinkedText(
|
||||||
|
content,
|
||||||
|
cell,
|
||||||
|
cellStyle,
|
||||||
|
Modifier.padding(padding).weight(1f).semantics {
|
||||||
|
if (header) heading()
|
||||||
|
collectionItemInfo =
|
||||||
|
CollectionItemInfo(
|
||||||
|
rowIndex = rowIndex,
|
||||||
|
rowSpan = 1,
|
||||||
|
columnIndex = column,
|
||||||
|
columnSpan = 1,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A table header, and a table row, whose cells wrap rather than being cut off.
|
|
||||||
*
|
|
||||||
* The renderer draws every cell at `maxLines = 1` with an ellipsis, which on a phone means most of
|
|
||||||
* a table is simply not readable: a column is 160dp at its narrowest, so anything past about twenty
|
|
||||||
* characters ends in "..." with no way to see the rest. Nothing about the value says it was cut,
|
|
||||||
* either -- an elided cell looks like a short one, so a table of measurements reads as a table of
|
|
||||||
* plausible shorter measurements.
|
|
||||||
*
|
|
||||||
* So: as many lines as the cell needs, and [TextOverflow.Clip] rather than an ellipsis, which now
|
|
||||||
* never has anything to hide since the height grows to fit. Cells align to the top of the row,
|
|
||||||
* because a two-line cell beside a one-line one centred the short one against the middle of the
|
|
||||||
* tall one and lost the line the reader was reading across.
|
|
||||||
*
|
|
||||||
* What the wrapping does *not* do is make a wide table fit. The renderer already gives each column
|
|
||||||
* a 160dp minimum and scrolls the whole table sideways when they do not fit the screen, which is
|
|
||||||
* the right answer for too many columns -- wrapping a six-column table into the width of a phone
|
|
||||||
* would give every cell one word per line. The two work together: the width is what the columns
|
|
||||||
* need, and the wrapping is what fills the space that width provides.
|
|
||||||
*
|
|
||||||
* Two functions rather than one because the renderer's header and row are separate composables --
|
|
||||||
* the header is bold and sizes itself to its tallest cell -- and the parameters that matter here
|
|
||||||
* are the same three in both.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun WrappingTableHeader(
|
|
||||||
content: String,
|
|
||||||
header: ASTNode,
|
|
||||||
tableWidth: Dp,
|
|
||||||
style: TextStyle,
|
|
||||||
) {
|
|
||||||
MarkdownTableHeader(
|
|
||||||
content = content,
|
|
||||||
header = header,
|
|
||||||
tableWidth = tableWidth,
|
|
||||||
style = style,
|
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
maxLines = Int.MAX_VALUE,
|
|
||||||
overflow = TextOverflow.Clip,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun WrappingTableRow(content: String, row: ASTNode, tableWidth: Dp, style: TextStyle) {
|
|
||||||
MarkdownTableRow(
|
|
||||||
content = content,
|
|
||||||
header = row,
|
|
||||||
tableWidth = tableWidth,
|
|
||||||
style = style,
|
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
maxLines = Int.MAX_VALUE,
|
|
||||||
overflow = TextOverflow.Clip,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [text] parsed: on the composing thread the first time this row is drawn, and off it every time
|
|
||||||
* afterwards.
|
|
||||||
*
|
|
||||||
* The first parse has to be inline. The renderer's own asynchronous path draws an empty loading
|
|
||||||
* slot until its result arrives, so a row is measured at nothing before it is measured at its real
|
|
||||||
* height, and the transcript above it collapses and springs back. Seen with five replies on screen
|
|
||||||
* at once, every one of them blank, the whole conversation shrunk to fit a single screen; a moment
|
|
||||||
* later it was all there again. That is the "skipping up and down" this list must never do, and no
|
|
||||||
* amount of scroll anchoring can survive a row that lies about its height first.
|
|
||||||
*
|
|
||||||
* Every parse *after* the first is a different case, and it is the one that was costing: a reply
|
|
||||||
* arrives as hundreds of deltas, each one re-parsing the whole message it has grown into. Measured
|
|
||||||
* against `/stream 200` on the emulator, that was fifty-eight parses and 78ms of main-thread work
|
|
||||||
* in three seconds, with single parses reaching 7ms -- most of a frame at 60Hz and more than one at
|
|
||||||
* 120. Those go to a background thread, and the row keeps drawing the parse it already has until
|
|
||||||
* the new one lands, so there is never a frame without a height. What is on screen is always a
|
|
||||||
* real prefix of the reply rather than a guess at it; it is simply one parse behind.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun parsedMarkdown(text: String, replies: ParsedReplies): State {
|
|
||||||
// The text each parse came from, so the first composition's is not immediately repeated.
|
|
||||||
val parsed = remember { mutableStateOf(text to replies.of(text)) }
|
|
||||||
LaunchedEffect(text) {
|
|
||||||
if (parsed.value.first == text) return@LaunchedEffect
|
|
||||||
// Not through [replies]: this is a reply still arriving, and every delta would leave
|
|
||||||
// another copy of a message that is about to be superseded.
|
|
||||||
parsed.value =
|
|
||||||
text to
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
DebugStats.timed("markdown reparsed while streaming") { parseMarkdown(text) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parsed.value.second
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -287,18 +492,15 @@ class ParsedReplies {
|
|||||||
private val parsed = ConcurrentHashMap<String, State>()
|
private val parsed = ConcurrentHashMap<String, State>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How each message divides into blocks, cached beside the parses of those blocks.
|
* How each message divides into pieces, cached beside its parse: [transcriptUnits] asks per
|
||||||
*
|
* fold, and walking the tree again each time is proportional to the message where a lookup is
|
||||||
* Here rather than in a `remember` because the answer is wanted on two threads: by [warm], to
|
* proportional to nothing.
|
||||||
* know which strings to make ready, and by the row that draws them. Finding it costs a parse of
|
|
||||||
* the whole message, so doing it twice would undo what splitting is for.
|
|
||||||
*/
|
*/
|
||||||
private val blocks = ConcurrentHashMap<String, List<String>>()
|
private val pieces = ConcurrentHashMap<String, List<Piece>>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How each message divides into prose and memory notes, cached for the same reason as
|
* How each message divides into prose and memory notes, cached for the same reason as
|
||||||
* [blocksOf]: [transcriptUnits] asks per fold, and the regex scan behind [messageParts] is
|
* [piecesOf]: the regex scan behind [messageParts] is proportional to the message.
|
||||||
* proportional to the message every time where a lookup is proportional to nothing.
|
|
||||||
*/
|
*/
|
||||||
private val parts = ConcurrentHashMap<String, List<MessagePart>>()
|
private val parts = ConcurrentHashMap<String, List<MessagePart>>()
|
||||||
|
|
||||||
@@ -306,25 +508,26 @@ class ParsedReplies {
|
|||||||
|
|
||||||
private val ready = ConcurrentHashMap.newKeySet<String>()
|
private val ready = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
fun blocksOf(text: String): List<String> =
|
/** The pieces of [text], from its parse -- made now if [warm] has not made it. */
|
||||||
blocks.computeIfAbsent(text) {
|
fun piecesOf(text: String): List<Piece> =
|
||||||
DebugStats.timed("markdown split into blocks") { markdownBlocks(it) }
|
pieces.computeIfAbsent(text) {
|
||||||
|
DebugStats.timed("markdown cut into pieces") { pieces(of(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How a long user message divides into slices; cached for the same reason as [blocksOf]. */
|
/** How a long user message divides into slices; cached for the same reason as [piecesOf]. */
|
||||||
fun chunksOf(text: String): List<String> =
|
fun chunksOf(text: String): List<String> =
|
||||||
chunks.computeIfAbsent(text) {
|
chunks.computeIfAbsent(text) {
|
||||||
DebugStats.timed("user message cut into slices") { userChunks(it) }
|
DebugStats.timed("user message cut into slices") { userChunks(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether [warm] has made everything drawing [text] as blocks will look up.
|
* Whether [warm] has made everything drawing [text] as pieces will look up.
|
||||||
*
|
*
|
||||||
* What the flatten asks before drawing a reply that way. Splitting costs a parse of the whole
|
* What the flatten asks before drawing a reply that way. Cutting costs a parse of the whole
|
||||||
* message and the flatten runs on the composing thread -- so a reply not marked yet stays
|
* message and the flatten runs on the composing thread -- so a reply not marked yet stays
|
||||||
* whole, drawing the parse it already has, until the screen has warmed it and re-flattens. An
|
* whole, drawing the parse it already has, until the screen has warmed it and re-flattens. An
|
||||||
* explicit mark rather than a peek into [blocksOf]'s cache, because a message with memory notes
|
* explicit mark rather than a peek into the parse cache, because a message with memory notes is
|
||||||
* is warmed as its *parts*: nothing ever splits its full text, and inferring readiness from the
|
* warmed as its *parts*: nothing ever parses its full text, and inferring readiness from the
|
||||||
* cache left exactly that message unsplittable forever, re-warmed on every fold.
|
* cache left exactly that message unsplittable forever, re-warmed on every fold.
|
||||||
*/
|
*/
|
||||||
fun splitReady(text: String): Boolean = text in ready
|
fun splitReady(text: String): Boolean = text in ready
|
||||||
@@ -364,7 +567,7 @@ class ParsedReplies {
|
|||||||
/** Everything these described is gone; see [ParsedReplies]. */
|
/** Everything these described is gone; see [ParsedReplies]. */
|
||||||
fun clear() {
|
fun clear() {
|
||||||
parsed.clear()
|
parsed.clear()
|
||||||
blocks.clear()
|
pieces.clear()
|
||||||
parts.clear()
|
parts.clear()
|
||||||
chunks.clear()
|
chunks.clear()
|
||||||
ready.clear()
|
ready.clear()
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.node.Ref
|
import androidx.compose.ui.node.Ref
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
@@ -17,11 +19,13 @@ import androidx.compose.ui.text.TextStyle
|
|||||||
import com.mikepenz.markdown.annotator.AnnotatorSettings
|
import com.mikepenz.markdown.annotator.AnnotatorSettings
|
||||||
import com.mikepenz.markdown.annotator.annotatorSettings
|
import com.mikepenz.markdown.annotator.annotatorSettings
|
||||||
import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString
|
import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString
|
||||||
|
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||||
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
|
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
|
||||||
import com.mikepenz.markdown.compose.elements.MarkdownText
|
import com.mikepenz.markdown.compose.elements.MarkdownText
|
||||||
import com.mikepenz.markdown.model.markdownAnnotator
|
import com.mikepenz.markdown.model.markdownAnnotator
|
||||||
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
||||||
import org.intellij.markdown.MarkdownElementTypes
|
import org.intellij.markdown.MarkdownElementTypes
|
||||||
|
import org.intellij.markdown.MarkdownTokenTypes
|
||||||
import org.intellij.markdown.ast.ASTNode
|
import org.intellij.markdown.ast.ASTNode
|
||||||
import org.intellij.markdown.ast.findChildOfType
|
import org.intellij.markdown.ast.findChildOfType
|
||||||
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
||||||
@@ -44,42 +48,91 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
|||||||
* style never defined a pressed style, so nothing visible changes.
|
* style never defined a pressed style, so nothing visible changes.
|
||||||
*
|
*
|
||||||
* Every block the renderer dispatches through its component table comes here, which includes the
|
* Every block the renderer dispatches through its component table comes here, which includes the
|
||||||
* paragraphs inside lists, quotes and alerts. Table cells do not: the table draws its own cells and
|
* paragraphs inside lists, quotes and alerts, and so does every table cell through
|
||||||
* offers no slot for them, so a link in a cell keeps the renderer's path -- correct, and dearer.
|
* [LinkedTableRow]. Reference-style links are the one kind still drawn the renderer's way; it
|
||||||
* Reference-style links stay there too; the renderer resolves those against its definitions.
|
* resolves those against its definitions.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LinkedText(model: MarkdownComponentModel, style: TextStyle, heading: Boolean = false) {
|
fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
|
||||||
|
LinkedText(model.content, model.node, style)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or
|
||||||
|
* `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it
|
||||||
|
* does not know, so handed the heading node itself it draws an empty line. Which is what this did
|
||||||
|
* for a week.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) {
|
||||||
|
val words =
|
||||||
|
model.node.findChildOfType(MarkdownTokenTypes.ATX_CONTENT)
|
||||||
|
?: model.node.findChildOfType(MarkdownTokenTypes.SETEXT_CONTENT)
|
||||||
|
?: model.node
|
||||||
|
LinkedText(model.content, words, style, Modifier.semantics { heading() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inline content of [node] within [content], drawn as [LinkedText] describes. */
|
||||||
|
@Composable
|
||||||
|
fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modifier = Modifier) {
|
||||||
val settings = plainLinkSettings()
|
val settings = plainLinkSettings()
|
||||||
val text =
|
val text =
|
||||||
remember(model.content, model.node, style) {
|
remember(content, node, style) {
|
||||||
model.content.buildMarkdownAnnotatedString(model.node, style, settings)
|
content.buildMarkdownAnnotatedString(node, style, settings)
|
||||||
}
|
}
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val layout = remember { Ref<TextLayoutResult>() }
|
val layout = remember { Ref<TextLayoutResult>() }
|
||||||
MarkdownText(
|
val tapping =
|
||||||
content = text,
|
modifier.pointerInput(text) {
|
||||||
node = model.node,
|
|
||||||
modifier =
|
|
||||||
Modifier.then(if (heading) Modifier.semantics { heading() } else Modifier).pointerInput(
|
|
||||||
text
|
|
||||||
) {
|
|
||||||
detectTapGestures { position ->
|
detectTapGestures { position ->
|
||||||
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures
|
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures
|
||||||
uriHandler.openUri(url)
|
uriHandler.openUri(url)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
// The renderer's text composable exists to place inline images, and it charges every text
|
||||||
|
// for the possibility: a placement callback, a derived map of inline content, a semantics
|
||||||
|
// group and a size animation, per paragraph. Almost no paragraph has an image, so those go
|
||||||
|
// straight to the platform text; the few that do keep the renderer's path.
|
||||||
|
if (remember(node) { node.hasImage() }) {
|
||||||
|
MarkdownText(
|
||||||
|
content = text,
|
||||||
|
node = node,
|
||||||
|
modifier = tapping,
|
||||||
style = style,
|
style = style,
|
||||||
onTextLayout = { result, _ -> layout.value = result },
|
onTextLayout = { result, _ -> layout.value = result },
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
// The renderer's own rule for a style that names no colour: the theme's text colour.
|
||||||
|
val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text
|
||||||
|
BasicText(
|
||||||
|
text = text,
|
||||||
|
modifier = tapping,
|
||||||
|
style = style,
|
||||||
|
color = { color },
|
||||||
|
onTextLayout = { layout.value = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The address under [position], if a link's glyph is there rather than merely nearest to it. */
|
private fun ASTNode.hasImage(): Boolean =
|
||||||
|
type == MarkdownElementTypes.IMAGE || children.any { it.hasImage() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address under [position], if a link's glyph is there rather than merely nearest to it.
|
||||||
|
*
|
||||||
|
* The layout answers with a caret, the boundary nearest the finger, so a tap on the right half of a
|
||||||
|
* glyph names the character after it; the glyph under the finger is the one on either side of that
|
||||||
|
* boundary whose box holds the point. Checked with the box rather than assumed, so a tap past the
|
||||||
|
* end of a line ending in a link opens nothing.
|
||||||
|
*/
|
||||||
private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset): String? {
|
private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset): String? {
|
||||||
layout ?: return null
|
layout ?: return null
|
||||||
val offset = layout.getOffsetForPosition(position)
|
val caret = layout.getOffsetForPosition(position)
|
||||||
if (offset >= length || !layout.getBoundingBox(offset).contains(position)) return null
|
val glyph =
|
||||||
return getStringAnnotations(LINK_URL, offset, offset).firstOrNull()?.item
|
(caret - 1..caret).firstOrNull {
|
||||||
|
it in 0 until length && layout.getBoundingBox(it).contains(position)
|
||||||
|
} ?: return null
|
||||||
|
return getStringAnnotations(LINK_URL, glyph, glyph + 1).firstOrNull()?.item
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val LINK_URL = "url"
|
private const val LINK_URL = "url"
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.isTraversalGroup
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||||
|
import com.mikepenz.markdown.compose.LocalMarkdownComponents
|
||||||
|
import com.mikepenz.markdown.compose.LocalMarkdownPadding
|
||||||
|
import com.mikepenz.markdown.compose.LocalMarkdownTypography
|
||||||
|
import com.mikepenz.markdown.compose.MarkdownElement
|
||||||
|
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
|
||||||
|
import com.mikepenz.markdown.model.State
|
||||||
|
import org.intellij.markdown.MarkdownElementTypes
|
||||||
|
import org.intellij.markdown.MarkdownTokenTypes
|
||||||
|
import org.intellij.markdown.ast.ASTNode
|
||||||
|
import org.intellij.markdown.ast.findChildOfType
|
||||||
|
import org.intellij.markdown.ast.getTextInNode
|
||||||
|
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One drawable piece of a parsed message: a top-level block, or one item of a top-level list.
|
||||||
|
*
|
||||||
|
* The point is the draw phase and the lazy list. A reply's display list holds every glyph of it and
|
||||||
|
* is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a
|
||||||
|
* hundred short ones; and the list composes an item whole in the frame it scrolls into, so an item
|
||||||
|
* has to be bounded for the worst frame to be. Measured on a Pixel 9 Pro XL, the tallest row still
|
||||||
|
* being drawn was 36,982px, twenty-five screens in one message. A piece is a paragraph, a fence, a
|
||||||
|
* table, one bullet: bounded, so both costs are.
|
||||||
|
*
|
||||||
|
* Cut where the parser says the blocks are, which is the whole reason this is safe: a fence, a
|
||||||
|
* table and a nested list are each one node whatever is inside them, so nothing is ever split down
|
||||||
|
* the middle. A list is the one block that is not bounded -- a reply's list of sources can be forty
|
||||||
|
* items -- so it is cut once more, into its items, and a nested list stays inside the item that
|
||||||
|
* holds it.
|
||||||
|
*
|
||||||
|
* A piece is an *address* into the message's one parse ([block] indexes the root's children, [item]
|
||||||
|
* the list items of that child) rather than a substring of the message. Every piece of a message is
|
||||||
|
* drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and a
|
||||||
|
* reference definition at its foot still resolves the links above it -- the two costs of cutting a
|
||||||
|
* message into strings and parsing each on its own.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
|
||||||
|
companion object {
|
||||||
|
const val WHOLE_BLOCK = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pieces of [parse], in reading order. Blank nodes between blocks -- the parser keeps the
|
||||||
|
* newlines -- are not pieces.
|
||||||
|
*
|
||||||
|
* A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a
|
||||||
|
* message that drew as nothing would be a hole in the transcript with no sign of what fell out.
|
||||||
|
*/
|
||||||
|
fun pieces(parse: State): List<Piece> {
|
||||||
|
val success = parse as? State.Success ?: return listOf(Piece(0))
|
||||||
|
val out = ArrayList<Piece>()
|
||||||
|
success.node.children.forEachIndexed { at, node ->
|
||||||
|
when {
|
||||||
|
node.getTextInNode(success.content).isBlank() -> {}
|
||||||
|
node.isList -> repeat(node.listItems().size) { out += Piece(at, it) }
|
||||||
|
else -> out += Piece(at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The room above [piece] when it follows [previous] in the same message: none between two items of
|
||||||
|
* one list, whose own padding already separates them, and a block's gap otherwise. The first piece
|
||||||
|
* of a message takes the message's gap, which is the caller's to know.
|
||||||
|
*/
|
||||||
|
fun gapBefore(previous: Piece?, piece: Piece): Dp =
|
||||||
|
if (previous != null && previous.block == piece.block) 0.dp else BLOCK_SPACING
|
||||||
|
|
||||||
|
/** The gap between one block of a reply and the next, wherever a reply is drawn in pieces. */
|
||||||
|
val BLOCK_SPACING: Dp = 6.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which is what carries the
|
||||||
|
* theme, the components and the reference links to the renderer's element composables.
|
||||||
|
*
|
||||||
|
* A whole block goes to the renderer's own dispatch with this app's component table, so a paragraph
|
||||||
|
* or heading is a [LinkedText], a table is [LinkedTableRow]s, and a nested list comes back here
|
||||||
|
* through [MarkdownList]. Only the list item is drawn directly, because a list item is the one
|
||||||
|
* piece the renderer has no element for.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MarkdownPiece(parse: State, text: String, piece: Piece, modifier: Modifier = Modifier) {
|
||||||
|
if (parse !is State.Success) {
|
||||||
|
// The parser threw. Nothing else in the app has seen this happen; if it does, the words
|
||||||
|
// are still worth more than a blank.
|
||||||
|
Text(text, modifier, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val node = parse.node.children[piece.block]
|
||||||
|
if (piece.item == Piece.WHOLE_BLOCK) {
|
||||||
|
Box(modifier) {
|
||||||
|
MarkdownElement(
|
||||||
|
node,
|
||||||
|
LocalMarkdownComponents.current,
|
||||||
|
parse.content,
|
||||||
|
includeSpacer = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val items = node.listItems()
|
||||||
|
MarkdownListItem(
|
||||||
|
content = parse.content,
|
||||||
|
list = node,
|
||||||
|
item = items[piece.item],
|
||||||
|
index = piece.item,
|
||||||
|
first = piece.item == 0,
|
||||||
|
last = piece.item == items.lastIndex,
|
||||||
|
depth = 0,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole list, for the places the renderer's dispatch reaches one it cannot hand to a piece: a
|
||||||
|
* list inside a quote, and the nested lists an item holds. Top-level lists never come here; they
|
||||||
|
* are drawn an item at a time as pieces.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) {
|
||||||
|
val items = list.listItems()
|
||||||
|
Column(modifier) {
|
||||||
|
items.forEachIndexed { index, item ->
|
||||||
|
MarkdownListItem(
|
||||||
|
content,
|
||||||
|
list,
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
first = index == 0,
|
||||||
|
last = index == items.lastIndex,
|
||||||
|
depth = depth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One item: its marker beside its content, laid out the way the renderer's own list does so that a
|
||||||
|
* list drawn as pieces looks exactly like one drawn whole. The list's own padding goes on its first
|
||||||
|
* and last items, since there is no list column to carry it.
|
||||||
|
*
|
||||||
|
* The marker is the renderer's bullet and number, and a checkbox for a task item. It is drawn here
|
||||||
|
* rather than by a handler because it is the thing a reader might one day want styled -- a
|
||||||
|
* different glyph per depth, a colour -- and this is the one place it is drawn.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MarkdownListItem(
|
||||||
|
content: String,
|
||||||
|
list: ASTNode,
|
||||||
|
item: ASTNode,
|
||||||
|
index: Int,
|
||||||
|
first: Boolean,
|
||||||
|
last: Boolean,
|
||||||
|
depth: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val padding = LocalMarkdownPadding.current
|
||||||
|
val typography = LocalMarkdownTypography.current
|
||||||
|
val components = LocalMarkdownComponents.current
|
||||||
|
// A task item's box sits right after the bullet: `- [ ] text`.
|
||||||
|
val checkbox = item.children.getOrNull(1)?.takeIf { it.type == GFMTokenTypes.CHECK_BOX }
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.semantics { isTraversalGroup = true }
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
start = padding.listIndent * depth,
|
||||||
|
top = padding.listItemTop + if (first) padding.list else 0.dp,
|
||||||
|
bottom = padding.listItemBottom + if (last) padding.list else 0.dp,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (checkbox != null) {
|
||||||
|
components.checkbox(MarkdownComponentModel(content, checkbox, typography))
|
||||||
|
} else if (list.type == MarkdownElementTypes.ORDERED_LIST) {
|
||||||
|
Marker("${list.startNumber(content) + index}. ", typography.ordered)
|
||||||
|
} else {
|
||||||
|
Marker("• ", typography.bullet)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
item.children.forEach { child ->
|
||||||
|
when (child.type) {
|
||||||
|
MarkdownTokenTypes.LIST_BULLET,
|
||||||
|
MarkdownTokenTypes.LIST_NUMBER,
|
||||||
|
GFMTokenTypes.CHECK_BOX -> {}
|
||||||
|
MarkdownElementTypes.ORDERED_LIST,
|
||||||
|
MarkdownElementTypes.UNORDERED_LIST -> MarkdownList(content, child, depth + 1)
|
||||||
|
else -> MarkdownElement(child, components, content, includeSpacer = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The renderer's text colour on the marker; its styles carry none of their own. */
|
||||||
|
@Composable
|
||||||
|
private fun Marker(text: String, style: TextStyle) {
|
||||||
|
BasicText(text, style = style.copy(color = LocalMarkdownColors.current.text))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ASTNode.isList: Boolean
|
||||||
|
get() = type == MarkdownElementTypes.ORDERED_LIST || type == MarkdownElementTypes.UNORDERED_LIST
|
||||||
|
|
||||||
|
private fun ASTNode.listItems(): List<ASTNode> = children.filter {
|
||||||
|
it.type == MarkdownElementTypes.LIST_ITEM
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where an ordered list counts from: the number its first item was written with. */
|
||||||
|
private fun ASTNode.startNumber(content: String): Int =
|
||||||
|
findChildOfType(MarkdownElementTypes.LIST_ITEM)
|
||||||
|
?.findChildOfType(MarkdownTokenTypes.LIST_NUMBER)
|
||||||
|
?.getTextInNode(content)
|
||||||
|
?.takeWhile(Char::isDigit)
|
||||||
|
?.toString()
|
||||||
|
?.toIntOrNull() ?: 1
|
||||||
@@ -45,13 +45,13 @@ fun AssistantMessage(
|
|||||||
val parts = remember(text) { messageParts(text) }
|
val parts = remember(text) { messageParts(text) }
|
||||||
val only = parts.singleOrNull()
|
val only = parts.singleOrNull()
|
||||||
if (only is MessagePart.Prose) {
|
if (only is MessagePart.Prose) {
|
||||||
BlockedMarkdown(only.text, replies, modifier, live)
|
MarkdownText(only.text, replies, modifier, live)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
parts.forEach { part ->
|
parts.forEach { part ->
|
||||||
when (part) {
|
when (part) {
|
||||||
is MessagePart.Prose -> BlockedMarkdown(part.text, replies, live = live)
|
is MessagePart.Prose -> MarkdownText(part.text, replies, live = live)
|
||||||
is MessagePart.Remembered ->
|
is MessagePart.Remembered ->
|
||||||
MemoryNote(part, replies, part.text in openNotes) { onToggleNote(part.text) }
|
MemoryNote(part, replies, part.text in openNotes) { onToggleNote(part.text) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.mikepenz.markdown.model.State
|
|
||||||
import com.mikepenz.markdown.model.parseMarkdown
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A message's top-level markdown blocks, cut where the parser says the blocks are.
|
|
||||||
*
|
|
||||||
* The point is the draw phase. A reply's display list holds every glyph of it, and it is
|
|
||||||
* re-recorded whenever drawing is invalidated -- so one long message is as expensive to draw as a
|
|
||||||
* hundred short ones, and skipping the rows around it cannot help while it is the one on screen.
|
|
||||||
* Measured on a Pixel 9 Pro XL: 97% of rows correctly skipped, and the tallest row still being
|
|
||||||
* drawn was 36,982px, about twenty-five screens in a single message. Cut into blocks, only the
|
|
||||||
* screen or two actually being read is ever recorded.
|
|
||||||
*
|
|
||||||
* Cut at the parser's own boundaries rather than at blank lines, which is the whole reason this is
|
|
||||||
* safe: a heading, a fenced code block, a table and a list are each one node whatever is inside
|
|
||||||
* them, so a loose list does not become five one-item lists and a fence is never split down the
|
|
||||||
* middle. Guessing at block boundaries with a line scanner gets all three of those wrong.
|
|
||||||
*
|
|
||||||
* It also bounds parsing, which was the other symptom: one message took **1.4 seconds** to parse as
|
|
||||||
* a single unit, and a block is a paragraph.
|
|
||||||
*/
|
|
||||||
fun markdownBlocks(text: String): List<String> {
|
|
||||||
// A reference definition sits at the foot of a message and is used by links above it. Parsed on
|
|
||||||
// its own each block would lose the definition, and the link would draw as literal brackets --
|
|
||||||
// so a message carrying one is kept whole. Rare enough to be worth giving up the split for.
|
|
||||||
if (REFERENCE_DEFINITION.containsMatchIn(text)) return listOf(text)
|
|
||||||
val parsed = parseMarkdown(text) as? State.Success ?: return listOf(text)
|
|
||||||
val blocks =
|
|
||||||
parsed.node.children
|
|
||||||
.map { text.substring(it.startOffset, it.endOffset) }
|
|
||||||
.filter { it.isNotBlank() }
|
|
||||||
return if (blocks.size <= 1) listOf(text) else blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `[label]: https://…` at the start of a line -- see [markdownBlocks]. */
|
|
||||||
private val REFERENCE_DEFINITION = Regex("""^ {0,3}\[[^\]]+]:\s""", RegexOption.MULTILINE)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A reply drawn a block at a time.
|
|
||||||
*
|
|
||||||
* Each block keeps its composition and its layout whichever way it is scrolled -- that is what
|
|
||||||
* stops a message being rebuilt when somebody comes back to it. The heights come from the blocks
|
|
||||||
* themselves as they are measured, so the running total is the same arrangement the list uses one
|
|
||||||
* level up.
|
|
||||||
*
|
|
||||||
* [live] is the message currently arriving, and it is the only one that gets a layer per block. A
|
|
||||||
* layer buys one thing here: when drawing is invalidated, only the block that changed is
|
|
||||||
* re-recorded instead of the whole reply. That is worth a great deal while a reply is streaming,
|
|
||||||
* because every delta invalidates the message and a finished one can be twenty-five screens tall.
|
|
||||||
* It is worth nothing once the message stops changing -- measured on a Pixel 9 Pro XL, whole rows
|
|
||||||
* were re-recorded 65 times in fifty seconds of reading -- and it is not free: each layer is a
|
|
||||||
* layout node and a display list held for the life of the row, and live node count is what the
|
|
||||||
* per-frame cost of the transcript scales with.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun BlockedMarkdown(
|
|
||||||
text: String,
|
|
||||||
replies: ParsedReplies,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
live: Boolean = false,
|
|
||||||
) {
|
|
||||||
// The first split is inline for the reason [parsedMarkdown]'s first parse is: the row must
|
|
||||||
// have its real height in its first frame. Every split after that is a delta landing, and it
|
|
||||||
// runs off the composing thread with the message drawing the split it already has until the
|
|
||||||
// new one arrives -- when this recomputed wherever composition ran, one streamed reply cost
|
|
||||||
// 815 whole-message parses and 2.9 seconds of them, a few milliseconds per delta, on the
|
|
||||||
// thread that draws. Not through [replies]: a reply mid-stream is a different text per
|
|
||||||
// delta, and each would leave a cache entry nothing reads again.
|
|
||||||
val split = remember { mutableStateOf(text to markdownBlocks(text)) }
|
|
||||||
LaunchedEffect(text) {
|
|
||||||
if (split.value.first == text) return@LaunchedEffect
|
|
||||||
split.value =
|
|
||||||
text to
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
DebugStats.timed("blocks split while streaming") { markdownBlocks(text) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val blocks = split.value.second
|
|
||||||
if (blocks.size == 1) {
|
|
||||||
MarkdownText(blocks.first(), replies, modifier)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(BLOCK_SPACING)) {
|
|
||||||
blocks.forEach { block ->
|
|
||||||
MarkdownText(
|
|
||||||
block,
|
|
||||||
replies,
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.then(if (live) Modifier.graphicsLayer() else Modifier)
|
|
||||||
.drawWithContent {
|
|
||||||
val started = System.nanoTime()
|
|
||||||
drawContent()
|
|
||||||
DebugStats.record("record: one block", System.nanoTime() - started)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The gap between one block of a reply and the next, here and in [transcriptUnits]. */
|
|
||||||
val BLOCK_SPACING = 6.dp
|
|
||||||
@@ -78,24 +78,18 @@ fun PeerHeadRow(
|
|||||||
* chose.
|
* chose.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PeerBlockRow(
|
fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggle: () -> Unit) {
|
||||||
text: String,
|
|
||||||
replies: ParsedReplies,
|
|
||||||
last: Boolean,
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
modifier.cardPiece(
|
Modifier.cardPiece(
|
||||||
top = false,
|
top = false,
|
||||||
bottom = last,
|
bottom = unit.last,
|
||||||
fill = CardDefaults.cardColors().containerColor,
|
fill = CardDefaults.cardColors().containerColor,
|
||||||
onPress = onToggle,
|
onPress = onToggle,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// The gap the card's own column used to provide between its heading and its prose, and
|
// The gap the card's own column used to provide between its heading and its prose, and
|
||||||
// between one block and the next. Uniform, because both of those were 6dp already.
|
// between one block and the next -- inside the piece, so the card's fill runs through it.
|
||||||
MarkdownText(text, replies, Modifier.padding(top = BLOCK_SPACING))
|
MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1108,7 +1108,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
// One poll for this machine's limits, read by the two things that show them: the bar under
|
// One poll for this machine's limits, read by the two things that show them: the bar under
|
||||||
// the header, and the colour of the button that opens the dialog.
|
// the header, and the colour of the button that opens the dialog.
|
||||||
val usage = rememberSessionUsage(settings, summary.setup)
|
val usage = rememberSessionUsage(settings, summary.setup)
|
||||||
recordFrames()
|
RecordFrames()
|
||||||
var usageOpen by remember { mutableStateOf(false) }
|
var usageOpen by remember { mutableStateOf(false) }
|
||||||
var settingsOpen by remember { mutableStateOf(false) }
|
var settingsOpen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -1350,7 +1350,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
},
|
},
|
||||||
) { unit ->
|
) { unit ->
|
||||||
when (unit) {
|
when (unit) {
|
||||||
is TranscriptUnit.Block -> MarkdownText(unit.text, replies)
|
is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies)
|
||||||
is TranscriptUnit.PeerHead ->
|
is TranscriptUnit.PeerHead ->
|
||||||
PeerHeadRow(
|
PeerHeadRow(
|
||||||
unit.item,
|
unit.item,
|
||||||
@@ -1358,12 +1358,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
onToggle = { togglePeer(unit.item.seq) },
|
onToggle = { togglePeer(unit.item.seq) },
|
||||||
)
|
)
|
||||||
is TranscriptUnit.PeerBlock ->
|
is TranscriptUnit.PeerBlock ->
|
||||||
PeerBlockRow(
|
PeerBlockRow(unit, replies, onToggle = { togglePeer(unit.seq) })
|
||||||
unit.text,
|
|
||||||
replies,
|
|
||||||
unit.last,
|
|
||||||
onToggle = { togglePeer(unit.seq) },
|
|
||||||
)
|
|
||||||
is TranscriptUnit.UserChunk ->
|
is TranscriptUnit.UserChunk ->
|
||||||
UserChunkRow(unit, settings, summary.id, ::openImage)
|
UserChunkRow(unit, settings, summary.id, ::openImage)
|
||||||
is TranscriptUnit.Memory ->
|
is TranscriptUnit.Memory ->
|
||||||
|
|||||||
@@ -541,12 +541,12 @@ private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
|
|||||||
* whole point: the work happens seconds before the reader reaches the rows it was done for. See
|
* whole point: the work happens seconds before the reader reaches the rows it was done for. See
|
||||||
* [ParsedReplies].
|
* [ParsedReplies].
|
||||||
*
|
*
|
||||||
* What is warmed mirrors what the rows draw, unit by unit -- prose split into its blocks, a memory
|
* What is warmed mirrors what the rows draw -- each prose part of a reply, a memory note, a peer
|
||||||
* note whole, a peer message split the same way prose is -- because a string warmed under a key no
|
* message, every one of them whole, since every piece of a message is drawn from its one parse --
|
||||||
* row ever looks up is a miss that nothing reports; see [transcriptUnits], which is the flatten
|
* because a string warmed under a key no row ever looks up is a miss that nothing reports; see
|
||||||
* this has to agree with. It reads the same [ParsedReplies.partsOf] and [ParsedReplies.blocksOf]
|
* [transcriptUnits], which is the flatten this has to agree with. It reads the same
|
||||||
* caches the flatten does, so a message is scanned once however many pages hand it back through
|
* [ParsedReplies.partsOf] cache the flatten does, so a message is scanned once however many pages
|
||||||
* here, while the whole loaded transcript crosses this on every page.
|
* hand it back through here, while the whole loaded transcript crosses this on every page.
|
||||||
*
|
*
|
||||||
* Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer
|
* Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer
|
||||||
* message was missing: this used to filter for assistant replies alone, so the one row type nobody
|
* message was missing: this used to filter for assistant replies alone, so the one row type nobody
|
||||||
@@ -557,21 +557,12 @@ suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
|
|||||||
withContext(parsingThreads) {
|
withContext(parsingThreads) {
|
||||||
val texts = rows.flatMap { row ->
|
val texts = rows.flatMap { row ->
|
||||||
when (row) {
|
when (row) {
|
||||||
is TranscriptItem.AssistantMsg ->
|
is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text }
|
||||||
replies.partsOf(row.text).flatMap { part ->
|
|
||||||
when (part) {
|
|
||||||
is MessagePart.Prose -> replies.blocksOf(part.text)
|
|
||||||
// Drawn as one MarkdownText, so its whole text is the key
|
|
||||||
// looked up.
|
|
||||||
is MessagePart.Remembered -> listOf(part.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A message from another agent is markdown too, and it is the longest thing
|
// A message from another agent is markdown too, and it is the longest thing
|
||||||
// in a transcript often enough that leaving it out was the whole of why one
|
// in a transcript often enough that leaving it out was the whole of why one
|
||||||
// cost a fifth of a second to open: it was the only markdown in the app
|
// cost a fifth of a second to open: it was the only markdown in the app
|
||||||
// parsed on the thread that draws. Its blocks, not its text, because
|
// parsed on the thread that draws.
|
||||||
// [PeerMessageRow] draws it a block at a time.
|
is TranscriptItem.PeerNote -> listOf(row.text)
|
||||||
is TranscriptItem.PeerNote -> replies.blocksOf(row.text)
|
|
||||||
else -> emptyList()
|
else -> emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,11 +49,12 @@ sealed class TranscriptUnit {
|
|||||||
get() = 0
|
get() = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One markdown block of a settled reply. */
|
/** One [Piece] of a settled reply; [text] is the prose it is a piece of. */
|
||||||
data class Block(
|
data class Block(
|
||||||
override val seq: Long,
|
override val seq: Long,
|
||||||
override val ordinal: Int,
|
override val ordinal: Int,
|
||||||
val text: String,
|
val text: String,
|
||||||
|
val piece: Piece,
|
||||||
override val gap: Dp,
|
override val gap: Dp,
|
||||||
) : TranscriptUnit() {
|
) : TranscriptUnit() {
|
||||||
override val key: Any
|
override val key: Any
|
||||||
@@ -93,12 +94,18 @@ sealed class TranscriptUnit {
|
|||||||
get() = 0
|
get() = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One markdown block of an opened peer message; [last] is the piece that closes the card. */
|
/**
|
||||||
|
* One [Piece] of an opened peer message; [last] is the piece that closes the card. Its [gap] is
|
||||||
|
* always zero -- the pieces are one card -- so the room between blocks is [spacing], drawn
|
||||||
|
* inside the piece where the card's fill covers it.
|
||||||
|
*/
|
||||||
data class PeerBlock(
|
data class PeerBlock(
|
||||||
override val seq: Long,
|
override val seq: Long,
|
||||||
override val ordinal: Int,
|
override val ordinal: Int,
|
||||||
val text: String,
|
val text: String,
|
||||||
|
val piece: Piece,
|
||||||
val last: Boolean,
|
val last: Boolean,
|
||||||
|
val spacing: Dp,
|
||||||
override val gap: Dp,
|
override val gap: Dp,
|
||||||
/** The note this block belongs to; its key, not its seq. See [TranscriptItem.PeerNote]. */
|
/** The note this block belongs to; its key, not its seq. See [TranscriptItem.PeerNote]. */
|
||||||
val note: Any,
|
val note: Any,
|
||||||
@@ -145,19 +152,19 @@ sealed class TranscriptUnit {
|
|||||||
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
|
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
|
||||||
* screen, which is what a reversed lazy list calls the start.
|
* screen, which is what a reversed lazy list calls the start.
|
||||||
*
|
*
|
||||||
* Every settled reply is cut into its blocks ([markdownBlocks], via the caches on [replies] so a
|
* Every settled reply is cut into its pieces ([pieces], via the caches on [replies] so a message is
|
||||||
* message is only ever split once), and so is an *opened* peer message -- [openNotes] is which ones
|
* only ever cut once), and so is an *opened* peer message -- [openNotes] is which ones those are,
|
||||||
* those are, which is why the flatten needs it. A shut one is a single heading and cannot be worth
|
* which is why the flatten needs it. A shut one is a single heading and cannot be worth splitting.
|
||||||
* splitting. The reply still arriving -- the newest row, until the status event that ends its turn
|
* The reply still arriving -- the newest row, until the status event that ends its turn marks it
|
||||||
* marks it [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta,
|
* [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta, and
|
||||||
* and splitting it here would parse the whole message per delta on whichever thread is composing.
|
* splitting it here would parse the whole message per delta on whichever thread is composing.
|
||||||
* [AssistantMessage]'s own streaming path already parses deltas off the main thread and gives the
|
* [AssistantMessage]'s own streaming path already parses deltas off the main thread and gives the
|
||||||
* live message a layer per block. Once settled it splits like every other reply, which is what
|
* live message a layer per piece. Once settled it splits like every other reply, which is what
|
||||||
* bounds the newest row's cost after a session ends on a long one.
|
* bounds the newest row's cost after a session ends on a long one.
|
||||||
*
|
*
|
||||||
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
|
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
|
||||||
* path: [ParsedReplies.partsOf] and [ParsedReplies.blocksOf] are lookups for any text [warm] has
|
* path: [ParsedReplies.partsOf] and [ParsedReplies.piecesOf] are lookups for any text [warm] has
|
||||||
* seen, and a miss -- the one message that just finished streaming -- costs its split exactly once.
|
* seen, and a miss -- the one message that just finished streaming -- costs its parse exactly once.
|
||||||
*/
|
*/
|
||||||
fun transcriptUnits(
|
fun transcriptUnits(
|
||||||
rows: List<TranscriptRow>,
|
rows: List<TranscriptRow>,
|
||||||
@@ -175,17 +182,21 @@ fun transcriptUnits(
|
|||||||
// No gap between the pieces: they are one card, and a card with a stripe through it is
|
// No gap between the pieces: they are one card, and a card with a stripe through it is
|
||||||
// what any spacing here would draw.
|
// what any spacing here would draw.
|
||||||
if (open) {
|
if (open) {
|
||||||
val blocks = replies.blocksOf(item.text)
|
val pieces = replies.piecesOf(item.text)
|
||||||
blocks.forEachIndexed { at, block ->
|
var previous: Piece? = null
|
||||||
|
pieces.forEachIndexed { at, piece ->
|
||||||
units +=
|
units +=
|
||||||
TranscriptUnit.PeerBlock(
|
TranscriptUnit.PeerBlock(
|
||||||
row.startSeq,
|
row.startSeq,
|
||||||
at + 1,
|
at + 1,
|
||||||
block,
|
item.text,
|
||||||
last = at == blocks.lastIndex,
|
piece,
|
||||||
|
last = at == pieces.lastIndex,
|
||||||
|
spacing = gapBefore(previous, piece),
|
||||||
gap = 0.dp,
|
gap = 0.dp,
|
||||||
note = item.key,
|
note = item.key,
|
||||||
)
|
)
|
||||||
|
previous = piece
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) {
|
} else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) {
|
||||||
@@ -210,16 +221,27 @@ fun transcriptUnits(
|
|||||||
replies.splitReady(item.text)
|
replies.splitReady(item.text)
|
||||||
) {
|
) {
|
||||||
var ordinal = 0
|
var ordinal = 0
|
||||||
fun gap() = if (ordinal == 0) rowGap else BLOCK_SPACING
|
fun gap(within: Dp) = if (ordinal == 0) rowGap else within
|
||||||
replies.partsOf(item.text).forEach { part ->
|
replies.partsOf(item.text).forEach { part ->
|
||||||
when (part) {
|
when (part) {
|
||||||
is MessagePart.Prose ->
|
is MessagePart.Prose -> {
|
||||||
replies.blocksOf(part.text).forEach { block ->
|
var previous: Piece? = null
|
||||||
units += TranscriptUnit.Block(row.startSeq, ordinal, block, gap())
|
replies.piecesOf(part.text).forEach { piece ->
|
||||||
|
units +=
|
||||||
|
TranscriptUnit.Block(
|
||||||
|
row.startSeq,
|
||||||
|
ordinal,
|
||||||
|
part.text,
|
||||||
|
piece,
|
||||||
|
gap(gapBefore(previous, piece)),
|
||||||
|
)
|
||||||
ordinal++
|
ordinal++
|
||||||
|
previous = piece
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is MessagePart.Remembered -> {
|
is MessagePart.Remembered -> {
|
||||||
units += TranscriptUnit.Memory(row.startSeq, ordinal, part, gap())
|
units +=
|
||||||
|
TranscriptUnit.Memory(row.startSeq, ordinal, part, gap(BLOCK_SPACING))
|
||||||
ordinal++
|
ordinal++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,7 +380,8 @@ private val TranscriptUnit?.kind: String
|
|||||||
get() =
|
get() =
|
||||||
when (this) {
|
when (this) {
|
||||||
null -> "the list's own"
|
null -> "the list's own"
|
||||||
is TranscriptUnit.Block -> "reply block"
|
is TranscriptUnit.Block ->
|
||||||
|
if (piece.item == Piece.WHOLE_BLOCK) "reply block" else "list item"
|
||||||
is TranscriptUnit.PeerHead -> if (open) "peer heading (open)" else "peer heading"
|
is TranscriptUnit.PeerHead -> if (open) "peer heading (open)" else "peer heading"
|
||||||
is TranscriptUnit.PeerBlock -> "peer block"
|
is TranscriptUnit.PeerBlock -> "peer block"
|
||||||
is TranscriptUnit.UserChunk -> "user slice"
|
is TranscriptUnit.UserChunk -> "user slice"
|
||||||
|
|||||||
Reference in new issue
Block a user