diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt index 779d33c..0fbc552 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt @@ -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