Draw a reply one block at a time, and skip the blocks off screen
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 skipping the rows around it cannot help while it is the one on screen. That was the whole of the remaining draw cost: 97% of rows correctly skipped, and the tallest one still drawn was 36,982px -- about twenty-five screens in a single message. So a message is cut into its top-level blocks and each is drawn, or not, on its own. The cut comes from the parser's own boundaries rather than from a line scanner looking for blank lines, which is what makes it safe: a heading, a table, a fenced block 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. Checked against a real reply whose list items are separated by blank lines -- it still draws as one list with its bullets aligned, which is the case a blank-line split gets wrong. It bounds parsing too, which was the other symptom in the same reading: one message took 1.4 seconds to parse as a single unit, and a block is a paragraph. The row and the message each know half of where a block is, so they meet at an interface declared where it is used: the list supplies the row's position, the message supplies the block's offset inside it, and drawing a message does not have to know it is inside a transcript. What this cannot divide is a single node, and a long fenced code block is one -- so the report now also carries the tallest *drawn block*, which is the number that says whether splitting bounded anything. This emulator's tallest row is one such fence, which is why its own figures do not move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dffa365e54
commit
981856e046
5 files changed
+203
-9
No files matched your search
@@ -169,6 +169,17 @@ private fun parsedMarkdown(text: String, replies: ParsedReplies): State {
|
||||
class ParsedReplies {
|
||||
private val parsed = ConcurrentHashMap<String, State>()
|
||||
|
||||
/**
|
||||
* How each message divides into blocks, cached beside the parses of those blocks.
|
||||
*
|
||||
* Here rather than in a `remember` because the answer is wanted on two threads: by [warm], to
|
||||
* 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>>()
|
||||
|
||||
fun blocksOf(text: String): List<String> = blocks.computeIfAbsent(text) { markdownBlocks(it) }
|
||||
|
||||
/** The parse of [text] -- the one made ahead, or one made now. */
|
||||
fun of(text: String): State =
|
||||
parsed[text]?.also { DebugStats.count("markdown ready") }
|
||||
|
||||
@@ -30,13 +30,13 @@ fun AssistantMessage(text: String, replies: ParsedReplies, modifier: Modifier =
|
||||
val parts = remember(text) { partsOf(text) }
|
||||
val only = parts.singleOrNull()
|
||||
if (only is MessagePart.Prose) {
|
||||
MarkdownText(only.text, replies, modifier)
|
||||
BlockedMarkdown(only.text, replies, modifier)
|
||||
return
|
||||
}
|
||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
parts.forEach { part ->
|
||||
when (part) {
|
||||
is MessagePart.Prose -> MarkdownText(part.text, replies)
|
||||
is MessagePart.Prose -> BlockedMarkdown(part.text, replies)
|
||||
is MessagePart.Remembered -> MemoryNote(part, replies)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.model.State
|
||||
import com.mikepenz.markdown.model.parseMarkdown
|
||||
|
||||
/**
|
||||
* Whether a piece of a row is close enough to the screen to be worth drawing.
|
||||
*
|
||||
* Defined here, where it is needed, and implemented by the list that knows where rows are -- so
|
||||
* drawing a message does not have to know it is inside a transcript, and a message drawn anywhere
|
||||
* else simply draws all of itself.
|
||||
*
|
||||
* The offsets are the row's own: a block's top measured from the top of the message it is part of.
|
||||
*/
|
||||
interface RowWindow {
|
||||
fun visible(top: Int, height: Int): Boolean
|
||||
}
|
||||
|
||||
val LocalRowWindow = compositionLocalOf<RowWindow?> { null }
|
||||
|
||||
/**
|
||||
* 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, with the blocks that are off screen not drawn at all.
|
||||
*
|
||||
* 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 -- and only the drawing is skipped.
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
fun BlockedMarkdown(text: String, replies: ParsedReplies, modifier: Modifier = Modifier) {
|
||||
val blocks = remember(text) { replies.blocksOf(text) }
|
||||
if (blocks.size == 1) {
|
||||
MarkdownText(blocks.first(), replies, modifier)
|
||||
return
|
||||
}
|
||||
val window = LocalRowWindow.current
|
||||
val spacing = with(LocalDensity.current) { BLOCK_SPACING.roundToPx() }
|
||||
val offsets = remember(blocks, spacing) { BlockOffsets(blocks.size, spacing) }
|
||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(BLOCK_SPACING)) {
|
||||
blocks.forEachIndexed { index, block ->
|
||||
Box(
|
||||
Modifier.fillMaxWidth()
|
||||
.onSizeChanged { offsets.measured(index, it.height) }
|
||||
.drawWithContent {
|
||||
val near =
|
||||
window == null ||
|
||||
window.visible(offsets.top(index), offsets.height(index))
|
||||
if (near) {
|
||||
DebugStats.count("block drawn")
|
||||
// The number that says whether splitting bounded anything. A row can
|
||||
// stay enormous and be fine, so long as no single *drawn* piece of it
|
||||
// is -- and one node the parser will not divide, a long fenced code
|
||||
// block above all, stays one piece however tall it is.
|
||||
DebugStats.atLeast(
|
||||
"tallest drawn block px",
|
||||
offsets.height(index).toLong(),
|
||||
)
|
||||
drawContent()
|
||||
} else {
|
||||
DebugStats.count("block skipped")
|
||||
}
|
||||
}
|
||||
) {
|
||||
MarkdownText(block, replies)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each block of one message sits inside it, added up from the heights before it.
|
||||
*
|
||||
* The same shape as the list's own bookkeeping and for the same reason: adding the heights up on
|
||||
* every lookup would be quadratic, and the lookup happens once per block per frame.
|
||||
*/
|
||||
private class BlockOffsets(count: Int, private val spacing: Int) {
|
||||
private val heights = IntArray(count)
|
||||
private var tops = IntArray(count)
|
||||
private var stale = true
|
||||
|
||||
fun measured(index: Int, height: Int) {
|
||||
if (index in heights.indices && heights[index] != height) {
|
||||
heights[index] = height
|
||||
stale = true
|
||||
}
|
||||
}
|
||||
|
||||
fun height(index: Int) = heights.getOrElse(index) { 0 }
|
||||
|
||||
fun top(index: Int): Int {
|
||||
if (stale) {
|
||||
var y = 0
|
||||
for (i in heights.indices) {
|
||||
tops[i] = y
|
||||
y += heights[i] + spacing
|
||||
}
|
||||
stale = false
|
||||
}
|
||||
return tops.getOrElse(index) { 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/** The gap between one block of a reply and the next. */
|
||||
private val BLOCK_SPACING = 6.dp
|
||||
@@ -578,7 +578,10 @@ private suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
|
||||
// the work it finds stays one page's worth. Off the calling thread it is nobody's frame.
|
||||
withContext(Dispatchers.Default) {
|
||||
val texts =
|
||||
rows.filterIsInstance<TranscriptItem.AssistantMsg>().flatMap { markdownIn(it.text) }
|
||||
rows
|
||||
.filterIsInstance<TranscriptItem.AssistantMsg>()
|
||||
.flatMap { markdownIn(it.text) }
|
||||
.flatMap { replies.blocksOf(it) }
|
||||
if (texts.isNotEmpty()) replies.warm(texts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
@@ -136,11 +137,7 @@ class TranscriptScroll(internal val scroll: ScrollState) {
|
||||
val height = heights[seq] ?: return true
|
||||
refreshTops()
|
||||
val top = tops.getOrNull(index) ?: return true
|
||||
val viewportTop = scroll.maxValue - scroll.value
|
||||
val margin = scroll.viewportSize
|
||||
val near =
|
||||
top + height >= viewportTop - margin &&
|
||||
top <= viewportTop + scroll.viewportSize + margin
|
||||
val near = near(top, height)
|
||||
// Counted so that "drawing costs too much" can be told apart from "drawing was skipped and
|
||||
// still costs too much". They need opposite fixes: the first is a row that should not have
|
||||
// been drawn, the second is a single row too tall to record cheaply -- and one enormous
|
||||
@@ -237,6 +234,36 @@ class TranscriptScroll(internal val scroll: ScrollState) {
|
||||
val roomAbove: Int
|
||||
get() = scroll.maxValue - scroll.value
|
||||
|
||||
/**
|
||||
* What a block inside the row named by [seq] should ask to find out whether it is on screen.
|
||||
*
|
||||
* The list is the only thing that knows where a row sits, and a message being drawn is the only
|
||||
* thing that knows where its blocks sit inside it, so the two meet at [RowWindow]: the row
|
||||
* supplies the base and the message supplies the offset. Remembered per row by the caller,
|
||||
* because it is captured by every block's draw.
|
||||
*/
|
||||
fun windowFor(seq: Long): RowWindow =
|
||||
object : RowWindow {
|
||||
override fun visible(top: Int, height: Int): Boolean {
|
||||
val base = rowTop(seq) ?: return true
|
||||
return near(base + top, height)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rowTop(seq: Long): Int? {
|
||||
val index = rowIndex[seq] ?: return null
|
||||
refreshTops()
|
||||
return tops.getOrNull(index)
|
||||
}
|
||||
|
||||
/** Whether a span of content, in content coordinates, is within a screen of the viewport. */
|
||||
private fun near(top: Int, height: Int): Boolean {
|
||||
val viewportTop = scroll.maxValue - scroll.value
|
||||
val margin = scroll.viewportSize
|
||||
return top + height >= viewportTop - margin &&
|
||||
top <= viewportTop + scroll.viewportSize + margin
|
||||
}
|
||||
|
||||
/** The height of the visible area, 0 until the first measurement. */
|
||||
val viewport: Int
|
||||
get() = scroll.viewportSize
|
||||
@@ -362,7 +389,10 @@ fun TranscriptColumn(
|
||||
// the draw phase, so moving the list invalidates drawing and nothing else.
|
||||
.drawWithContent { if (state.onScreen(item.startSeq)) drawContent() }
|
||||
) {
|
||||
row(item)
|
||||
// So a block of a long reply can ask the same question the row just answered,
|
||||
// about its own part of it; see [RowWindow].
|
||||
val window = remember(state, item.startSeq) { state.windowFor(item.startSeq) }
|
||||
CompositionLocalProvider(LocalRowWindow provides window) { row(item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user