Stand rows up a few per frame instead of two screens at once

The counters now say which half is left. Re-recording is rare -- 157 row
display lists in a minute of scrolling, 407ms in total -- while the
frame's draw phase sits at 7.9ms. Since that phase also carries Compose's
own measurement, what is left is laying text out: shaping glyphs, on the
thread drawing the frame, and none of it timed by anything here.

The window moved in one step, so every step boundary shaped two screens
of markdown inside a single frame, a page landing did the same, and
opening a session did seventeen screens of it in one. It now walks
towards its target a couple of rows a frame. That does not make the work
cheaper and is not meant to: it stops it arriving together, which is the
difference between a frame that is late and a frame that is missed by
ten.

What is on screen is never amortised. The visible range goes up in the
frame it is needed whatever else is pending, and only the margin being
read *towards* is spread out -- so this cannot show anybody a gap, which
is the failure the last two changes in this area both had.

Diagnosis and the ordering from the ai-app-2-6d session, whose test this
follows: records small and draw high means shaping rather than recording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 03:44:14 -04:00
1 parent 9052e5f55e
commit 94b1509733
1 file changed
+64 -3
@@ -291,6 +291,50 @@ class TranscriptScroll(internal val scroll: ScrollState) {
* boundaries, where a fling would cross one every few frames. The window is eight screens
* either side and this moves it in steps of two, so the margin absorbs the staleness.
*/
/** Where the window is heading. [retained] walks towards it rather than jumping; see below. */
private var target: IntRange = IntRange.EMPTY
/** Whether [retained] is still short of [target], so somebody should keep stepping it. */
var growing: Boolean by mutableStateOf(false)
private set
/**
* Widens the built range towards its target, a few rows at a time. False when it has arrived.
*
* Standing rows up is not free and its cost is not recomposition -- it is laying the text out,
* which means shaping every glyph, and that is on the thread drawing the frame. Moving the
* window in one go meant two screens of markdown shaped inside a single frame at each step, and
* seventeen screens of it in the frame a session opens in. The platform files that under the
* frame's draw phase, which is why it never showed up in the counters here: nothing is being
* *recorded*, it is being measured.
*
* Spreading it over frames does not make it cheaper and is not meant to. It stops it arriving
* all at once, which is the difference between a frame that is late and a frame that is missed
* by ten.
*/
internal fun standUpSome(): Boolean {
if (target.isEmpty() || retained == target) {
growing = false
return false
}
var first = retained.first
var last = retained.last
var budget = STAND_UP_PER_FRAME
while (budget > 0 && (first > target.first || last < target.last)) {
if (first > target.first) {
first--
budget--
}
if (budget > 0 && last < target.last) {
last++
budget--
}
}
retained = first..last
growing = retained != target
return growing
}
internal fun trackRetained() {
// Before the comparison, not after it. Rows arriving is the case this exists to catch and
// it does not move the view: a message sent lands at the newest end, and if the range is
@@ -302,7 +346,19 @@ class TranscriptScroll(internal val scroll: ScrollState) {
val step = (scroll.viewportSize * RETAIN_STEP_SCREENS).coerceAtLeast(1)
val moved = viewportTop - rangeAt
if (retained.isEmpty() || topsVersion != rangeVersion || moved > step || moved < -step) {
retained = retainedRange(viewportTop)
target = retainedRange(viewportTop, RETAIN_SCREENS)
// Whatever is on screen goes up in this frame whatever else happens -- amortising is
// for the margin that is being read *towards*, never for the part being looked at.
val visible = retainedRange(viewportTop, 1)
retained =
if (retained.isEmpty()) visible
else
minOf(retained.first, visible.first).coerceAtLeast(target.first)..maxOf(
retained.last,
visible.last,
)
.coerceAtMost(target.last)
growing = retained != target
}
}
@@ -315,10 +371,10 @@ class TranscriptScroll(internal val scroll: ScrollState) {
* what happened. Here the nearest row to the viewport is in the range by construction, whatever
* the numbers say, so the worst a mistake can do is build too few rows or too many.
*/
private fun retainedRange(viewportTop: Int): IntRange {
private fun retainedRange(viewportTop: Int, screens: Int): IntRange {
refreshTops()
if (order.isEmpty()) return IntRange.EMPTY
val margin = (scroll.viewportSize * RETAIN_SCREENS).coerceAtLeast(1)
val margin = (scroll.viewportSize * screens).coerceAtLeast(1)
val from = viewportTop - margin
val to = viewportTop + scroll.viewportSize + margin
var first = -1
@@ -560,5 +616,10 @@ private const val RETAIN_SCREENS = 8
/** How far the view moves before the retained range is worked out again; see `trackRetained`. */
private const val RETAIN_STEP_SCREENS = 2
/**
* How many rows may be laid out in one frame while the window is catching up; see `standUpSome`.
*/
private const val STAND_UP_PER_FRAME = 2
/** What a row is assumed to be worth before any of them have been measured. */
private const val ROW_GUESS = 800