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:
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
|
||||
Reference in new issue
Block a user