Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt
T
irisandClaude Opus 5 41df8bb76a Stop re-measuring the transcript at the keyboard, and seed the window
Three things, all of them the same shape: work done because something was
asked a question at the wrong moment.

The keyboard. The layout modifier passed `minHeight = viewportSize` down
to its child, so every frame of the IME animation changed the child's
constraints -- and changed constraints are exactly what defeats the
early-return in `MeasurePassDelegate.remeasure`. The whole transcript was
re-measured on the way up, measured on a Pixel 9 Pro XL as 250
measurements averaging 2.8ms with a 54.5ms worst. The minimum is applied
to what this node reports now, not to what it asks its child for, so the
child early-returns; the content is placed against the bottom to keep the
anchoring the minimum existed for. It only ever bites on a conversation
shorter than the screen, which was never the case paying for it.

The flicker. `retained` starts empty, so on the composition that
introduces the rows every one of them is outside the window, the whole
transcript collapses to a single spacer, and it draws blank for a frame.
Seeded now -- at the newest end on open, and around the anchor on a
restore. The restore case is the one that bites: `placed()` jumps the view
during placement, and a window recomputed there only schedules a
recomposition, so the destination would draw as spacer for a frame or two
after drawing ungates. Seeded by pixels rather than by a row count,
because a run of tool calls is eight rows and less than half a screen.

The block layers. Every paragraph of every reply had a layer, which was
right when whole rows were re-recording constantly and one row's display
list was 36,982px tall. Re-recording is rare now -- 65 whole rows in fifty
seconds of reading -- and a layer costs a layout node and a display list
held for the life of the row, against the node count the per-frame cost
scales with. They go to the message still arriving, which is the only one
whose drawing is invalidated often enough to want the granularity.

Verified on the emulator against the case none of this was written for: a
two-message conversation far shorter than the viewport still hangs from
the composer, with the keyboard both up (messages at y1756-1940, composer
2109) and down (936-1120 against 1289).

Mechanism for the block layers and the hole in the restore seeding both
from the ai-app-2-6d session; the layers are not re-positioned per frame
as I had it, they are baked into the row's display list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:24:19 -04:00

749 lines
36 KiB
Kotlin

package com.example.aiapp
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* The transcript's scroll position, addressed the way the transcript itself is: a row, and how far
* that row has been scrolled past the top of the viewport.
*
* Pixels are the unit underneath because this list is a plain [Column] rather than a lazy one --
* every loaded row is composed, measured and kept, so there is a real height for every row whether
* or not it is on screen, and no index has to stand in for one. What that buys is that scrolling
* back over a message never rebuilds it: a Compose node that is still alive and whose constraints
* have not changed is not re-measured at all (`MeasurePassDelegate.remeasure` returns early), so
* the text keeps the layout it was given. A lazy list disposes a row the moment it leaves the
* viewport, and the markdown tree, the measured lines and the cached paragraph go with it -- which
* is the lag when scrolling back over history that has already been read.
*
* The content is laid out oldest-first and scrolled in reverse, which is what makes the two
* insertions this list actually gets free rather than corrected:
*
* - `reverseScrolling` places the content by its *end*, so `value == 0` is the newest message and
* growing the content at the far end -- a page of older history landing -- moves nothing on
* screen. `maxValue` grows and `value` is left alone.
* - A new message extends the same end the viewport is pinned to, so following it is not an effect
* that has to notice and correct: at `value == 0` the newest content is simply what is on screen.
* The keyboard opening is the same case from the other side -- the viewport shrinks, `maxValue`
* grows, and the newest message is still against the bottom.
*
* Both of those were scroll corrections in the lazy version, each with a comment explaining a way
* it had been seen to fire at the wrong moment.
*/
@Stable
class TranscriptScroll(internal val scroll: ScrollState) {
/**
* How tall each row is, by the seq that names it, and the order they are drawn in.
*
* Heights rather than positions, and that is the whole difference between this costing nothing
* and costing the frame. A position is only correct for one layout, so keeping one per row
* meant a callback per row per frame once the list stopped disposing them -- the transcript
* lagged the moment a second page was loaded, and worse with each page after. A height changes
* when its row changes and not otherwise, and a position can be added up from heights at the
* two moments anything actually needs one: saving where the reader is, and putting it back.
*
* Keyed on [TranscriptRow.startSeq] rather than on the row's display key for the reason the
* anchor is: a tool run is renamed when the newest page starts somewhere new, and a position
* recorded against the old name is never found again.
*/
private val heights = HashMap<Long, Int>()
private var order: List<Long> = emptyList()
private var spacing = 0
private var padTop = 0
/** Each row's top edge, added up from the heights before it; see [refreshTops]. */
private var tops = IntArray(0)
private var rowIndex = HashMap<Long, Int>()
private var topsStale = true
/** How many rows the list is drawing, so the window notices one arriving. */
var rowCount: Int by mutableIntStateOf(0)
private set
internal fun laidOut(order: List<Long>, spacing: Int, padTop: Int) {
this.order = order
rowCount = order.size
this.spacing = spacing
this.padTop = padTop
rowIndex = HashMap(order.size)
order.forEachIndexed { index, seq -> rowIndex[seq] = index }
topsStale = true
// Something has to be built before the first frame, or the first frame is blank. The window
// is worked out from the scroll position, and there is no scroll position until this has
// been laid out once -- so on the composition that introduces the rows, every one of them
// is outside an empty window, the whole transcript collapses to a single spacer, and the
// reader sees it flicker before the real window arrives a frame later. The newest end is
// the right guess because that is where the transcript opens; [restore] seeds the other
// case, where the reader is being put back somewhere else entirely.
if (retained.isEmpty() && order.isNotEmpty()) seedAround(order.lastIndex)
}
/**
* Builds a screenful of rows around [index], for the frames before there is a window.
*
* Sized in pixels rather than in rows, because a row is anything from a one-line note to a
* screenful and "the last eight" is a different amount of transcript every time -- a run of
* tool calls is eight rows and less than half a screen, which would seed a window too small to
* cover the viewport and flicker anyway, which is the thing being fixed.
*/
private fun seedAround(index: Int) {
if (order.isEmpty()) return
val budget = (scroll.viewportSize * SEED_SCREENS).coerceAtLeast(ROW_GUESS)
var first = index.coerceIn(0, order.lastIndex)
var last = first
var built = assumed(order[first])
while (built < budget && (first > 0 || last < order.lastIndex)) {
// Downwards first: an anchor names the row at the *top* of the viewport, so the rows
// after it are the ones the reader is about to be looking at.
if (last < order.lastIndex) built += assumed(order[++last])
if (built < budget && first > 0) built += assumed(order[--first])
}
retained = first..last
}
internal fun height(seq: Long, height: Int) {
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.
*
* Adding it up per row per lookup would be quadratic, and the lookup happens once per row per
* frame -- so on a long transcript the thing meant to save the frame would have been the thing
* costing it.
*/
private fun refreshTops() {
// These are offsets within the content, and the content is what the column measured -- so
// they are only positions on screen while the content is at least as tall as the viewport.
// Below that the layout modifier reports the viewport's height and places the content
// against the bottom of it, and every top here is short by the difference. Nothing depends
// on it today, because a conversation shorter than the screen is entirely retained and has
// nowhere to scroll to, but a reader of this arithmetic should know it is assuming that.
if (!topsStale) return
val out = IntArray(order.size)
var y = padTop
order.forEachIndexed { index, seq ->
out[index] = y
y += assumed(seq) + spacing
}
tops = out
topsStale = false
topsVersion++
}
/** Where the last touch went down, in the content's own coordinates. */
private var tapY = 0f
internal fun touched(y: Float) {
tapY = y
}
/**
* Whether the last touch landed in the top half of the row named by [seq], which is the end
* that row should hold when it changes height.
*
* One detector for the whole list rather than one per row, and one lookup at the moment of the
* tap rather than a position kept current for every row. Both of the obvious arrangements cost
* a callback per row per frame once the list stopped disposing rows -- an
* `onGloballyPositioned` to know where a row is, or a gesture detector on each row to catch its
* own touches -- and together they were most of the frame: on a deep transcript they took a
* scroll from 4% of frames over budget to 47%. Neither is needed. The content knows where it
* was touched, the heights say where each row starts, and the sum is only wanted when somebody
* actually taps.
*/
fun tappedHigh(seq: Long): Boolean {
val top = topOf(seq) ?: return false
val height = heights[seq] ?: return false
return tapY < top + height / 2f
}
/**
* A position waiting to be put back, applied by the layout that first places the content.
*
* Held here rather than applied by whoever loaded the row because the pixels do not exist yet
* at that point: a plain column has no height for a row until it has been measured. Applying it
* from the placement is what makes the restore frame-exact -- the transcript is drawn where it
* was left rather than drawn at the newest end and then moved, which is a journey the reader
* would see. [settling] is the same fact asked the other way, and gates drawing.
*/
var pending: ScrollAnchor? by mutableStateOf(null)
private set
/** Whether a saved position is still being put back, so nothing should be drawn yet. */
val settling: Boolean
get() = pending != null
/**
* The content has been placed: put back a waiting position, if its row is there to hold it.
*
* Once per layout rather than once per row. Both numbers it needs are the scroll container's
* own, and those are written during measure -- so by placement they describe this layout.
*/
internal fun placed() {
val anchor = pending ?: return
val rowTop = topOf(anchor.seq) ?: return
scroll.dispatchRawDelta((scroll.maxValue - rowTop - anchor.offset - scroll.value).toFloat())
pending = null
// The jump lands somewhere the window was not computed for, and this is the last chance
// before the frame that draws. It cannot build anything by itself -- writing state during
// placement only schedules a recomposition -- which is why [restore] seeds the destination
// in advance; this widens the seed to the full window rather than replacing it.
trackRetained()
}
/** Where a row's top edge sits inside the content, or null if it has not been measured. */
private fun topOf(seq: Long): Int? {
var y = padTop
for (s in order) {
if (s == seq) return y
y += assumed(s) + spacing
}
return null
}
/** Everything these described is gone -- a stream reset, or a different session. */
fun clear() = heights.clear()
/** Whether the newest message is on screen. See the class comment: the newest end is zero. */
val atNewest: Boolean
get() = scroll.value == 0
/**
* How much unread history sits above the viewport, in pixels.
*
* This is the paging question asked in the unit it is actually about. Counting rows could not
* answer it: a row is anything from one line to a screenful, so "three rows back" is a
* different amount of reading depending on which three, and the count that mattered -- how far
* somebody can scroll before running out -- was never what was measured.
*/
val roomAbove: Int
get() = scroll.maxValue - scroll.value
private fun rowTop(seq: Long): Int? {
val index = rowIndex[seq] ?: return null
refreshTops()
return tops.getOrNull(index)
}
/**
* Whether the row named by [seq] is close enough to be worth keeping composed at all.
*
* The window that could not be avoided. Keeping every loaded row alive is what makes scrolling
* back over a message cost nothing, and it is also the thing whose cost grows with the
* conversation rather than with the screen -- measured on a Pixel 9 Pro XL as a step: smooth
* with one page loaded, worse at the next, worse again at the one after, with the frame going
* into the draw phase while almost nothing was being recorded.
*
* Eight screens either side is about sixteen times what a lazy list keeps, which is the whole
* point: everything somebody has just read stays built, and only a deliberate journey back
* through the conversation pays to rebuild anything. A row outside it is replaced by a spacer
* 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 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.
*/
/**
* Which rows are built, as one piece of state every row reads rather than a question every row
* asks.
*
* Each row used to hold a `derivedStateOf` over the scroll position. That reads correctly and
* costs the same shape as the draw-phase culling it replaced: every one of those derived states
* is invalidated by every scroll frame and has to be re-evaluated to find out whether its
* answer changed, so the per-frame work grew with the number of loaded rows again -- this time
* landing in the recomposition pass, which the platform reports as the frame's animation phase.
* Measured on a Pixel 9 Pro XL at 153 rows: 11.6ms of it at the median, against an 8.3ms frame.
*
* One shared range costs one evaluation a frame, and rows are only disturbed when it actually
* moves.
*/
var retained: IntRange by mutableStateOf(IntRange.EMPTY)
private set
/**
* How tall rows [run] stand in for as a single spacer, the gaps between them included.
*
* The gaps have to be counted here because collapsing a run removes children from the column,
* and `Arrangement.spacedBy` puts a gap between children rather than after each one -- so a run
* of `k` rows drawn as one spacer is `k - 1` gaps shorter than the rows were unless it says so.
* Getting that wrong does not look like a spacing bug; it shortens the content, which moves
* everything the reader is looking at.
*/
fun runHeight(run: IntRange): Int {
if (run.isEmpty() || order.isEmpty()) return 0
var total = 0
for (index in run) total += assumed(order[index])
return total + (run.last - run.first) * spacing
}
/**
* [retained] clamped to a list of [count] rows, or empty if nothing should be built yet.
*
* The range is worked out against the order from the last layout, and a page of history landing
* changes that order in the same composition that reads this -- so an index from before it can
* be past the end. Clamping here rather than at the read sites keeps one answer to it.
*/
fun window(count: Int): IntRange {
if (count == 0 || retained.isEmpty()) return IntRange.EMPTY
val first = retained.first.coerceIn(0, count - 1)
val last = retained.last.coerceIn(first, count - 1)
return first..last
}
/**
* Recomputes [retained] if the view has moved far enough to be worth it.
*
* Deliberately lazy about it. Every row reads the range, so every change to it recomposes all
* of them -- which is affordable once every couple of screens and is not affordable at the row
* 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
// not recomputed the new row is outside it and stands in as a spacer of its guessed height
// -- a screen of blank between the last message and the box it was typed in. The version
// it is compared against is only bumped by this call, so asking first meant never noticing.
refreshTops()
val viewportTop = scroll.maxValue - scroll.value
val step = (scroll.viewportSize * RETAIN_STEP_SCREENS).coerceAtLeast(1)
val moved = viewportTop - rangeAt
if (retained.isEmpty() || topsVersion != rangeVersion || moved > step || moved < -step) {
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
}
}
/**
* Which rows stay built, as a range of indices rather than a test each row makes for itself.
*
* A range can be guaranteed non-empty and a per-row test cannot, which is the point. Every row
* answering independently means a fault in the arithmetic stands *all* of them down at once and
* leaves a blank transcript with nothing measured, so nothing to correct it -- which is exactly
* 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, screens: Int): IntRange {
refreshTops()
if (order.isEmpty()) return IntRange.EMPTY
val margin = (scroll.viewportSize * screens).coerceAtLeast(1)
val from = viewportTop - margin
val to = viewportTop + scroll.viewportSize + margin
var first = -1
var last = -1
var nearest = 0
var nearestGap = Int.MAX_VALUE
order.forEachIndexed { index, seq ->
val top = tops[index]
val bottom = top + assumed(seq)
if (bottom >= from && top <= to) {
if (first < 0) first = index
last = index
}
val gap = if (bottom < viewportTop) viewportTop - bottom else top - viewportTop
if (gap in 0..<nearestGap) {
nearestGap = gap
nearest = index
}
}
if (first < 0) {
first = nearest
last = nearest
}
rangeAt = viewportTop
rangeVersion = topsVersion
return first..last
}
private var rangeAt = Int.MIN_VALUE
private var rangeVersion = -1
private var topsVersion = 0
/** The height of the visible area, 0 until the first measurement. */
val viewport: Int
get() = scroll.viewportSize
/** How tall everything loaded is, which is what a plain column has to hold laid out at once. */
val contentHeight: Int
get() = scroll.maxValue + scroll.viewportSize
/** Where the reader is now, or null before anything has been laid out. */
fun anchor(): ScrollAnchor? {
val top = scroll.maxValue - scroll.value
// The row covering the top of the viewport: the last one that starts at or above it.
var y = padTop
var found: Pair<Long, Int>? = null
for (s in order) {
if (y > top) break
found = s to y
y += assumed(s) + spacing
}
return found?.let { (seq, rowTop) -> ScrollAnchor(seq, top - rowTop) }
}
/**
* Asks for [anchor] to be put back by the layout that places its row; see [pending].
*
* The caller is responsible for the row being loaded. Nothing here can wait for one that never
* arrives, and a position held open for it would leave the transcript blank -- so a row that is
* not in the transcript any more is [giveUp]'s case, not this one.
*/
fun restore(anchor: ScrollAnchor) {
pending = anchor
// The destination, built now rather than discovered after the jump. [placed] moves the
// view during placement, and a window recomputed there only schedules a recomposition --
// so the rows it would build arrive a frame after the frame that ungates drawing, and the
// reader is shown the place they were put back to as blank spacer before it fills in.
// Seeded here it is built by the composition that the jump is measured in.
rowIndex[anchor.seq]?.let { seedAround(it) }
}
/** Draw where we are instead: the anchored row is not in this transcript any more. */
fun giveUp() {
pending = null
}
/**
* Scrolls by [pixels] without animating, from wherever the caller is.
*
* Used to hold a row's top edge still when it changes height. Growth already goes upward
* because the content hangs from its end, so the bottom edge is held for free and this is the
* other case; see `holdTopEdge`.
*/
fun by(pixels: Int) {
scroll.dispatchRawDelta(pixels.toFloat())
}
}
@Composable
fun rememberTranscriptScroll(key: Any?): TranscriptScroll {
val scroll = rememberScrollState()
return remember(key) { TranscriptScroll(scroll) }
}
/**
* Every loaded row, composed and kept. See [TranscriptScroll] for why this is not a lazy list.
*
* [viewportHeight] is passed in rather than measured here because a scrollable child is measured
* with no height bound at all, so the content cannot ask how tall the visible area is. It is what
* holds a conversation shorter than the screen against the bottom, where the composer is, instead
* of leaving a gap under it that cannot be scrolled away.
*/
@Composable
fun TranscriptColumn(
rows: List<TranscriptRow>,
state: TranscriptScroll,
contentPadding: PaddingValues,
spacing: Dp,
modifier: Modifier = Modifier,
below: @Composable () -> Unit,
row: @Composable (TranscriptRow) -> Unit,
) {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
// The order and the gaps, so a row's position can be added up from heights when one is wanted.
// Recomputed only when the rows change, which is what keeps every frame free of it.
remember(rows, spacing, contentPadding, density) {
state.laidOut(
rows.map { it.startSeq },
with(density) { spacing.roundToPx() },
with(density) { contentPadding.calculateTopPadding().roundToPx() },
)
layoutDirection
}
// One evaluation of the window a frame, for the whole list; see [TranscriptScroll.retained].
// The row count is in here because a row arriving is the one case that does not move the view:
// without it a sent message falls outside the window and stands in as a spacer, which is a
// screen of blank between the last message and the box it was typed in.
LaunchedEffect(state) {
snapshotFlow { Triple(state.scroll.value, state.scroll.maxValue, state.rowCount) }
.collect { state.trackRetained() }
}
// Walks the window towards its target a few rows a frame; see [TranscriptScroll.standUpSome].
// Driven from a frame callback rather than a plain loop so the rows stand up between frames
// instead of all inside one, which is the entire point of doing it gradually.
LaunchedEffect(state) {
snapshotFlow { state.growing }
.collect {
while (state.growing) {
withFrameNanos {}
state.standUpSome()
}
}
}
Column(
modifier
.verticalScroll(state.scroll, reverseScrolling = true)
.padding(contentPadding)
// As tall as the visible area at least, so a conversation shorter than the screen sits
// against the composer rather than leaving a gap under it that cannot be scrolled away.
//
// Applied to what this node *reports* rather than to what it asks its child for, and
// that distinction is the whole cost of opening the keyboard. Passed down as a minimum
// height, it changed the child's constraints on every frame of the IME animation --
// and changed constraints are exactly what defeats the early-return in
// `MeasurePassDelegate.remeasure`, so the entire transcript was re-measured thirty
// times on the way up. Measured on a Pixel 9 Pro XL as 250 measurements averaging
// 2.8ms with a 54.5ms worst. The child is measured with the constraints it already
// had, so it early-returns, and the minimum is applied here where it belongs. It only
// ever bites on a conversation shorter than the screen, which is not the case that was
// paying for it.
//
// Timed in two halves because the frame's draw phase is where Compose's measurement
// lands, and "draw is high while nothing is being recorded" does not say which half.
// Measuring the transcript and placing it are different costs with different fixes:
// one is shaping text that has changed, the other is O(rows) whether or not anything
// has.
.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
DebugStats.record("measure: the whole transcript", System.nanoTime() - started)
val height = maxOf(placeable.height, state.scroll.viewportSize)
layout(placeable.width, height) {
val placing = System.nanoTime()
// From the bottom, because that is the end the conversation hangs from.
placeable.place(0, height - placeable.height)
DebugStats.record("place: the whole transcript", System.nanoTime() - placing)
}
}
.fillMaxWidth()
// Once for the whole list, not once per row: this is where a saved position is put
// back, and by placement the scroll container's own measurements describe this layout.
// Only while there is a position waiting to be put back. `onPlaced` is the one hook
// here that would otherwise run on every frame, and it has nothing to do on all but
// the two frames of a restore.
.then(if (state.settling) Modifier.onPlaced { state.placed() } else Modifier)
// One gesture detector for the whole list; see [TranscriptScroll.tappedHigh]. On the
// initial pass and consuming nothing, so every control inside still gets the gesture
// exactly as it would have.
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record("draw: the whole transcript", System.nanoTime() - started)
}
.pointerInput(Unit) {
awaitEachGesture {
state.touched(
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
.position
.y
)
}
},
verticalArrangement = Arrangement.spacedBy(spacing, Alignment.Bottom),
) {
// Everything outside the window is two spacers rather than one per row, and that is what
// stops the cost of a frame growing with the conversation. A stood-down row was still a
// layout node, and the framework's own per-frame bookkeeping after a scroll -- the position
// dispatch, the cached screen rect each node keeps -- walks live nodes rather than visible
// ones. So the transcript got slower with every page loaded even though the extra rows drew
// nothing at all: on a Pixel 9 Pro XL, a flat 7.7ms in the frame's draw phase while our own
// recording accounted for a fortieth of it. Two spacers, because [retained] is a range: the
// rows that are not in it are always one run before it and one run after.
val window = state.window(rows.size)
val ahead = if (window.isEmpty()) rows.indices else 0..<window.first
val behind = if (window.isEmpty()) IntRange.EMPTY else (window.last + 1)..<rows.size
key("above") { StoodDownRun(state, ahead) }
for (index in window) {
val item = rows[index]
// Keyed so that a row keeps its composition -- and so the state inside it, an open
// tool call, stays with the row rather than with the position.
key(item.key) { RetainedRow(state, item, row) }
}
key("below") { StoodDownRun(state, behind) }
below()
}
}
/** The gap between rows, and the room around the whole conversation. */
val TRANSCRIPT_SPACING: Dp = 8.dp
val TRANSCRIPT_PADDING: PaddingValues = PaddingValues(16.dp)
/**
* One row, built because it is near the screen.
*
* Whether a row is built is decided by the list rather than by the row, which is what lets the rows
* that are not built stop being nodes; see the window in [TranscriptColumn]. The cost of that is
* that the list's body recomposes when the window moves, which is why the window is deliberately
* lazy about moving -- see [TranscriptScroll.trackRetained].
*/
@Composable
private fun StoodDownRun(state: TranscriptScroll, run: IntRange) {
if (run.isEmpty()) return
DebugStats.count("rows stood down as one spacer")
Spacer(
Modifier.fillMaxWidth().height(with(LocalDensity.current) { state.runHeight(run).toDp() })
)
}
@Composable
private fun RetainedRow(
state: TranscriptScroll,
item: TranscriptRow,
row: @Composable (TranscriptRow) -> Unit,
) {
Column(
Modifier.fillMaxWidth()
// A layer of its own, which is the piece of a lazy list this had not rebuilt.
//
// Without one a row's glyphs are recorded into its parent's display list, and that
// list is re-recorded every frame the parent is invalidated -- which, while the list
// is scrolling, is every frame. With one the row is recorded once and afterwards moved
// by a transform, and the render thread culls the ones off screen itself.
//
// This replaces draw-phase culling that read the scroll position from inside every
// row's drawing. That arrangement could not win, and the two are mutually exclusive:
// reading a scroll position during draw invalidates the drawing it is in, so it
// re-recorded every row on every frame in order to decide most of them need not be
// drawn. Keeping both would have bought the cost of the first and none of the second.
.graphicsLayer()
.onSizeChanged { state.height(item.startSeq, it.height) }
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record("record: one row", System.nanoTime() - started)
}
) {
row(item)
}
}
/** How far either side of the screen a row stays built; see [TranscriptScroll.retains]. */
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
/**
* How much is built before there is a scroll position to work a window out from; see `seedAround`.
*/
private const val SEED_SCREENS = 2
/** What a row is assumed to be worth before any of them have been measured. */
private const val ROW_GUESS = 800