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:
irisandClaude Opus 5 committed 2026-08-31 02:39:42 -04:00
1 parent dffa365e54
commit 981856e046
5 files changed
+203 -9

No files matched your search

@@ -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) }
}
}
}