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