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 5db4920..bb81e4a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt @@ -98,9 +98,35 @@ class TranscriptScroll(internal val scroll: ScrollState) { } internal fun height(seq: Long, height: Int) { - if (heights.put(seq, height) != height) topsStale = true + val had = heights.put(seq, height) + if (had == height) return + measuredTotal += height - (had ?: 0) + if (had == null) measuredCount++ + topsStale = true } + private var measuredTotal = 0L + private var measuredCount = 0 + + /** + * How tall to assume a row is before anything has measured it. + * + * A row that has just been paged in has no height, and something has to stand in for one or it + * cannot be placed at all. This used to be answered by keeping every unmeasured row built -- + * which meant a page of history standing up seventeen screens of markdown inside a single + * frame, measured on a Pixel 9 Pro XL as a hundred milliseconds in one go, with the frame after + * it unable to start. An estimate lets a paged-in row be a spacer like any other distant row, + * and be built when the reader actually comes near it. + * + * The average of what has been measured, because the average row in a conversation is a good + * guess at the next one and a constant is not: these run from a one-line note to a screenful. + * Being wrong is cheap and self-correcting -- the estimate is only used above the viewport, + * where the content hangs from its far end, so a correction there moves nothing on screen. + */ + private fun assumed(seq: Long): Int = + heights[seq] + ?: if (measuredCount > 0) (measuredTotal / measuredCount).toInt() else ROW_GUESS + /** * The running total of every row's top edge, recomputed at most once per frame and only after * something has actually changed height. @@ -115,7 +141,7 @@ class TranscriptScroll(internal val scroll: ScrollState) { var y = padTop order.forEachIndexed { index, seq -> out[index] = y - y += (heights[seq] ?: 0) + spacing + y += assumed(seq) + spacing } tops = out topsStale = false @@ -181,7 +207,7 @@ class TranscriptScroll(internal val scroll: ScrollState) { var y = padTop for (s in order) { if (s == seq) return y - y += (heights[s] ?: return null) + spacing + y += assumed(s) + spacing } return null } @@ -225,12 +251,14 @@ class TranscriptScroll(internal val scroll: ScrollState) { * of the height it was last measured at, so the transcript's total height does not change and * nothing under the reader moves. * - * A row that has never been measured has no height to stand in for it and is kept whatever its - * distance -- that is a row that has just been paged in, and it is about to be looked at. + * A row that has never been measured stands in at the average of those that have, so it is + * placed and judged like any other; see [assumed]. Keeping every unmeasured row instead is what + * made a page of history land as one hundred-millisecond frame -- seventeen screens of markdown + * shaped at once, because "not measured yet" described the whole page. */ fun retains(seq: Long): Boolean { val index = rowIndex[seq] ?: return true - val height = heights[seq] ?: return true + val height = assumed(seq) refreshTops() val top = tops.getOrNull(index) ?: return true val viewportTop = scroll.maxValue - scroll.value @@ -240,7 +268,7 @@ class TranscriptScroll(internal val scroll: ScrollState) { } /** The height a row was last measured at, for the spacer that stands in for it. */ - fun heightOf(seq: Long): Int = heights[seq] ?: 0 + fun heightOf(seq: Long): Int = assumed(seq) /** The height of the visible area, 0 until the first measurement. */ val viewport: Int @@ -259,7 +287,7 @@ class TranscriptScroll(internal val scroll: ScrollState) { for (s in order) { if (y > top) break found = s to y - y += (heights[s] ?: return null) + spacing + y += assumed(s) + spacing } return found?.let { (seq, rowTop) -> ScrollAnchor(seq, top - rowTop) } } @@ -429,3 +457,6 @@ private fun RetainedRow( /** How far either side of the screen a row stays built; see [TranscriptScroll.retains]. */ private const val RETAIN_SCREENS = 8 + +/** What a row is assumed to be worth before any of them have been measured. */ +private const val ROW_GUESS = 800