Redesign span layout around retained placement

This commit is contained in:
iris committed 2026-09-09 15:11:57 -04:00
1 parent ae0af8f5e3
commit 0aa03cf621
30 files changed
+501 -1321

No files matched your search

+8 -57
View File
@@ -12,49 +12,6 @@ and six phone-report sections went on 2026-09-08 for that reason.
## Fix
- [ ] **`Span` draws every child twice, and making the first one a
measurement moves the layout.** Found 2026-09-09; the safe half landed
and this is the part that needs a decision.
`Span::draw`'s phase 1 draws each child at `UiRegion::FULL` purely to
learn its length along the axis, then phase 2 draws it at its real
share. The provisional slot is the whole span, so it is wrong by
construction for every child, and the doubling compounds through nested
spans: one streamed frame of the bench fixture made **1,083
`Widget::draw` calls over 113 distinct widgets** before `ScrollArea`'s
probe became a `Painter::measure`, and **453** after, with 102 of the
113 still at 4 draws (2 real, 2 measurements).
Changing phase 1 to `painter.measure(child, UiRegion::FULL)` takes it
to 2, and **it renders differently**: 28,771 pixels, and the headless
phone shot shows the transcript shifted a few pixels vertically. The
layout is intact -- panels, code fences and text all draw correctly --
so this is a position difference, not a broken frame, but which of the
two is *right* was not established and it must be before this lands.
The mechanism to check first. With phase 1 drawing, the child's
`active.region` is the full span when phase 2 asks, so phase 2 always
finds a different size and does a real redraw. With phase 1 measuring,
`active.region` is still *last frame's* share, which usually matches,
so phase 2 takes `draw_inner`'s `mov` branch -- one `move_offsets`
write instead of a redraw, which is the intended win. But `mov`
accumulates (`entry.delta += delta`) where a redraw recomputes from
scratch, so the suspicion is float drift that phase 1's unconditional
redraw was hiding. If that is it, the fix is in `mov`, not in `Span`.
Iris's framing, which is the target shape (2026-09-09): *"if you draw
one child in a list of fixed sized children, then you know immediately
where the second one must be and shouldn't need to probe its size
again. The only time redraws should actually be needed are if you're
using a `rest` length, where you don't know how long it's gonna be
until you draw everything else first, and so you have to move things.
Even then it should just be moving, not redrawing."* So the endpoint is
no probe phase at all for `abs` children -- draw each in turn at
`[cursor, end]`, read its length, advance the cursor -- with a `rest`
child forcing a reposition pass over what follows it rather than a
redraw. `Aligned` already has exactly this shape (one draw, then
`Painter::reposition`) and is the example to copy.
- [ ] **A row moving because the list grew should be one `move_offsets`
write, and today it is a redraw.** Found 2026-09-09 by
`scripts/rigs/ui-profile`'s `arena_churn` and left for whoever picks
@@ -62,8 +19,8 @@ and six phone-report sections went on 2026-09-08 for that reason.
half.
The measurement. Over the bench fixture's 401 streamed deltas, the
instance arena uploads **72.7%** of itself per frame, and that number
*is* the floor -- those entries genuinely differ, so no amount of
instance arena uploads **71.9%** of itself per frame against a
**71.8%** floor -- those entries genuinely differ, so no amount of
better dirty-tracking touches it. The control that says it is wrong is
the fling phase on the same screen and the same content: it moves the
same primitives every frame and uploads **3.3%**, because a scroll
@@ -71,18 +28,12 @@ and six phone-report sections went on 2026-09-08 for that reason.
the subtree (LAYOUT.md section 2) instead of rewriting every
primitive's absolute region.
What is different about the streaming path. The list is pinned to the
newest end, so a growing reply pushes every row above it up by the
amount the last row grew. That is a translation of an already-drawn
subtree -- exactly what `move_offsets` is for -- but it arrives as a
new offered region per row, and `draw_inner`'s fast path only takes
`mov` when `active.region.size() == region.size()`. Worth checking
first: whether `LazySpan::place` is offering each row a region whose
*size* differs (it computes `edges(height)` fresh each frame, so an
identical height should compare equal -- unless a float differs in the
last bit, which the `// TODO: epsilon?` beside that comparison already
suspects), or whether something upstream marks the rows dirty so the
fast path is skipped entirely.
The list is pinned to the newest end, so a growing reply pushes every
earlier row up. `draw_inner` now treats sub-pixel size differences as a
move and the span refactor removed nearly all repeated draws, but the
instance floor remains 71.8%. The remaining question is why those
translations still rewrite primitive regions instead of stopping at
the rows' move slots.
Done looks like: `arena_churn`'s `what_a_streamed_reply_uploads` shows
stream instances in the same range as the fling's, and its `whole`
+27 -196
View File
@@ -25,16 +25,10 @@ having been carried out.
```rust
pub trait Widget: Any {
/// Draw within `painter.region()` (the space the parent offered) and
/// report how much of it was actually used, per axis.
fn draw(&mut self, painter: &mut Painter) -> Size;
/// True if `draw`'s output (both the primitives it writes and the
/// `Size` it returns) is the same for any `painter.region()` of the
/// same *content* -- an icon, a fixed-size rect, an already-decoded
/// image at its natural size. Default `false` (redraw on any change to
/// the offered region) because assuming independence wrongly produces
/// a stale draw; a widget must opt in.
fn size_hint(&self, axis: Axis) -> Option<Len> { None }
fn is_size_independent(&self) -> bool {
false
}
@@ -55,17 +49,9 @@ own comment: "this literally copies draw so that the lengths are correctly
set in the context, which makes this slow and not cool." Folding sizing into
`draw` deletes that duplicate simulation, not just moves it.
**No single-draw alternative was found that does less work per frame.** The
two-method trait was checked against three properties a real screen needs —
a row placing children in sequence, a widget centering on its own content,
and wrapped text — and in every one, `draw` already has to visit the child
to get a size that is *this specific one's* answer, which today's
`desired_width`/`desired_height` re-derive by re-running (a shrunk copy of)
the same layout the draw pass will do again. So the two-method trait is not
"measure once, draw once" in the general case; it is "measure once per axis,
then draw once," i.e. up to three visits per widget per frame, against one
under the design here. The single-draw model is therefore adopted as
proposed, not merely accepted as a preference.
`size_hint` is not a second layout pass. It is an optional exact answer for
an axis the widget declares without painter context or child access. A
lying hint fails a debug assertion when the widget is drawn.
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
@@ -304,35 +290,18 @@ new caching is needed here; the two now-redundant call sites
disappear, which is a second `render` avoided per frame per text widget
that is being measured by a parent.
**"Parent wants the child's height before deciding the width it will
offer"** — the genuinely circular case named in the brief, e.g. a column
that sizes its own width to its widest child, where that child is wrapped
text whose height (which the column's *own* height depends on) depends on
the width the column has not yet decided. This is not solvable in one pass
for the same reason it is not solvable in CSS shrink-to-fit with wrapped
content: the two axes' answers are mutually dependent. `Span::desired_ortho`
(`span.rs:98-136`) already hits exactly this today and already resolves it
by an explicit second, throwaway pass (its own comment: "this literally
copies draw ... which makes this slow and not cool"). The design keeps that
resolution, made explicit rather than accidental: `Painter` gets
`Span` has no size-only pass. It first reads exact, context-free
`Widget::size_hint(axis)` values. It then draws unknown fixed children
forward from the current cursor, retaining what they paint. Once every
length is known, flexible space is allocated and `Painter::place` moves
each retained child into its final box. A child is redrawn only when that
box changes the size it was drawn for.
```rust
/// Draw `child` at a provisional region to learn its size under one
/// axis's worth of assumption, discard everything it wrote, then draw it
/// again at the region that assumption produced. For the rare parent that
/// cannot pick an offered size without already knowing the answer.
/// Twice the cost of one `draw`; every other case in this file avoids it.
pub fn draw_twice(&mut self, child: &StrongWidget, first: UiRegion, second: impl FnOnce(Size) -> UiRegion) -> Size;
```
implemented as: draw at `first`, record `Size`, remove the widget and its
subtree the same way a resize-triggered redraw already does (`draw_inner`'s
"if not \[same region\], maintain resize and track old children," `:97-100`,
which already frees the old primitives before redrawing) — reusing that
path rather than adding a second one — draw again at `second(size)`, return
the final `Size`. It is opt-in and named for its cost, so a widget only
pays it if it is the one that needs it; `Span`'s cross-axis case is the one
call site converted to it, replacing the hand-rolled duplicate loop.
Hints are optional and affect cost, never correctness. `Sized` can report
its declared axis without inspecting its child, which covers the important
`.height(rest())` case. A debug assertion compares every hint with the
eventual `draw` result. Widgets whose answer depends on shaping or on a
child return `None`.
### 5. Caching and invalidation
@@ -426,7 +395,7 @@ impl Widget for Aligned {
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
(None, None) => full,
};
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
painter.place(&self.inner, region);
used
}
}
@@ -435,9 +404,9 @@ impl Widget for Aligned {
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
return type from `()` to `Size`, carrying the child's `draw` result back —
the only signature change needed to let a parent see what its child used.
`Painter::reposition` is new, computing the delta between where a child
was actually drawn and where it belongs and calling the O(1) `mov` from
§2. `SizeCtx` and `Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
`Painter::place` moves an already-drawn child when its used area fits the
target box, and redraws it when the target changes its size. `SizeCtx` and
`Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
180-182`) are deleted — nothing calls `desired_len` any more, so there is
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
@@ -455,17 +424,10 @@ deletes the `SizeCtx` copies, keeping the `Painter` ones.
transcript scroll must not pay every frame) but kept for resize-shaped
changes (§3) where the content's own region field, not an ancestor
chain, is what has to change.
- **A second, size-only trait method kept alongside `draw`** (e.g.
`fn size_hint(&self) -> Option<Size>` as a fast path some widgets could
implement to skip a draw when a cheap answer exists) — considered and
rejected: it reintroduces exactly the "two names for one concept" split
this change removes, for a saving `is_size_independent` (§1, §3b)
already covers for the cases where it would actually help (fixed-size
leaves). A widget whose size is cheap to compute but whose *drawing* is
not (unlikely in this codebase's widget set, but conceivable) is better
served by that widget caching its own draw output internally — exactly
the pattern `TextView::render` already uses (§4) — than by a second
trait method every implementor has to reason about.
- **A general measurement API** — rejected because it walks the same nested
tree again. The narrow `size_hint(axis)` contract is exact,
context-free, and optional; it exists only for sizes a widget already
declares itself.
- **Passing `available` as an explicit parameter to `draw`** (mirroring
Masonry's `layout(&mut self, ctx, bc: &BoxConstraints) -> Size`, the
yardstick per AGENTS.md) — rejected as redundant with `Painter::region()`,
@@ -478,137 +440,6 @@ deletes the `SizeCtx` copies, keeping the `Painter` ones.
but still not O(1), and the shader-side chain costs nothing extra to get
the better bound.
## Deviations found during implementation (2026-09-04)
Five corrections this file's first draft did not anticipate, each found by
`iris/run-headless.sh tabs --shot` disagreeing with a pixel-identical
pre-change screenshot (pass condition 1) and traced with `eprintln!` in
`draw_inner`/`reposition` — not by reasoning about the design in the
abstract. Recorded here rather than silently fixed in place, per the code
rules' escape-hatch requirement.
1. **`Aligned`'s provisional draw must call `painter.widget`, not
`widget_within(&self.inner, painter.region())`.** §6's original text drew
the sample as the latter. `widget_within` composes its `region` argument
as *local*, `UiRegion::FULL`-relative coordinates against
`painter.region()` (exactly what `UiRegion::FULL.within(&self.region) ==
self.region` relies on); handing it `painter.region()` itself —
already-resolved, window-relative coordinates — composes that frame a
second time. For the root widget this is silently the identity (its
region already is `[0,1]`), which is why it can look correct in a
trivial case and only breaks once something is nested — i.e. always, in
practice. Symptom: a centered child rendered at a wildly wrong offset
nested more than one level deep. Fixed by using `painter.widget`, which
hands the child `self.region` unmodified, with no second composition.
2. **A widget that reports a size smaller than its offered region must
actually paint at that size, anchored top-left of what it was given —
not fill the full offered region while merely *reporting* a smaller
number.** `Sized` and `MaxSize` both had exactly this bug: their
`desired_width`/`desired_height` predecessors capped the *reported*
value but their `draw` bodies called `painter.widget(&self.inner)`
unconstrained, which was harmless under the old two-pass model (a parent
always queried the size *before* drawing, so by the time `draw` ran the
offered region already matched) but wrong under `Aligned`'s new
provisional-draw-then-reposition pattern, which offers the *whole*
region on the first, learning pass. Symptom: a `.sized((100, 100))` rect
rendered stretched to fill its whole row instead of a 100×100 square.
Fixed by having both widgets carve the declared sub-region (`UiSpan`
sized to the axis's `Len`, anchored at `AxisAlign::Neg`) out of whatever
they were offered before drawing the child in it. `Image` needed the
same treatment from the start (`texture_within` at its own natural size,
not `texture()` at the full offered region) and was written that way in
the first pass, once this was understood; `Rect`'s "fill whatever I'm
given" is the one case where painting the *whole* offered region really
is the declared behavior, so it needed no change.
3. **The move-offset chain's `parent` link cannot be found by looking up
the parent's `ActiveData` in `draw_inner`, because the parent's
`ActiveData` does not exist yet while its own `Widget::draw` is still
running.** `ActiveData` is inserted only after `draw` returns
(`render_state.rs`, end of `draw_inner`), so a child drawn partway
through its parent's `draw` body — the ordinary case, since every
composite widget draws its children from inside its own `draw` — would
always read "no parent" from `self.active`, silently orphaning it at the
root of the chain. Fixed by threading the parent's `move_slot` down
through `Painter` (it already carries `mask`/`layer` the same way) and
passing it explicitly into `draw_inner` as `parent_move_slot`, rather
than deriving it from `self.active.get(parent_id)`. `move_parent_of`
(the `self.active`-based lookup) is kept, but only for `redraw()`, whose
target's parent genuinely is already active at that call site — the
doc comment on it says which is which. Symptom: `reposition` computed
the right delta and wrote it to the right slot, but the shader never
saw it, because the primitive doing the actual painting chained to
`u32::MAX` one level too early.
4. **`Painter::reposition` cannot reuse `active.region` as "where the
widget currently is," because for a widget offered more room than it
used, `active.region` is the *offered* box, not the *painted* one.**
This only matters for `reposition` (used by `Aligned`); `mov` (used by
`draw_inner`'s own same-size-different-position dispatch, for `Scroll`
and `Offset`) has no such gap, because there the offered region *is*
the visual footprint — content is sized to fill exactly what it is
given. `reposition` instead reconstructs "from" as `active.size`
(already tracked, per §5) anchored at `AxisAlign::Neg` within
`active.region` — i.e. it assumes the child painted itself top-left of
whatever it was offered, per point 2's convention — and **overwrites**
the slot's delta rather than accumulating it the way `mov` does, since
"from" is recomputed fresh from stable inputs every call and repeating
the same `reposition` (an unrelated redraw elsewhere re-running this
widget's parent) must not drift further each time. The one shape this
does not cover: `Aligned` wrapping `Aligned`, where the inner one's own
`reposition` may have moved its content away from top-left already. No
widget or example in this codebase builds that today; if one needs to,
`reposition` would need the child to report *where* it painted, not
just how big, which is a larger change than this pass's scope.
5. **A widget's `move_offsets` slot is allocated once, on its first-ever
draw, and reused in place — never reallocated — for every later redraw
of the same id, with its delta reset to `[0, 0]` on each reuse.** Not
spelled out in §2's original text, which only said slots are assigned
"when the widget is first drawn." Reallocating a fresh slot on every
redraw would leave any *retained* (not-redrawn) descendant's `parent`
link pointing at a now-orphaned old slot — a permanent leak, and worse,
a descendant that silently stops tracking its ancestor's future moves.
Resetting the delta on reuse (rather than carrying it forward) is
required because a full redraw bakes the widget's correct absolute
position into the fresh `region` argument directly; a stale delta left
over from before the redraw would double-offset it.
Two further points worth recording because they were *design decisions*
made while implementing, not bugs — `LAYOUT.md`'s own text left them
unspecified rather than getting them wrong:
- **`Scroll` offers its content a region sized by the *previous* frame's
measured content length, not a fresh one.** A fresh measurement would
require drawing the content once to learn its size and — since that
provisional size essentially never matches the previously active one —
redrawing it a second time at the real size, on every single scroll
tick, which is exactly the cost §2 exists to remove. Using the stale
length means an ordinary scroll (position changes, content does not)
offers the same *size* as last frame, only shifted, which is what makes
`draw_inner` dispatch it as the O(1) move. The cost: a real content-size
change lags one frame before the container's scroll range reflects it,
self-correcting the frame after (the content length itself, read from
what was actually drawn, is never stale — only the offered *region* used
for placement is). No example in this repository builds a `Scroll` yet,
so this could not be checked against a pixel diff; it is covered instead
by `iris/src/layout_tests.rs`'s three `Scroll`-based unit tests, which
build a tree and drive `UiRenderState` directly with no GPU or window
needed.
- **`redraw()`'s parent-relayout check draws the widget first, then
compares the fresh `ActiveData.size` the draw produced against the size
from before removal** — the mirror image of the old code's "query size,
compare, decide whether to draw," which no longer has a size query to
do the comparison with before drawing (§5 deleted `Cache`/`SizeCtx`
along with `desired_width`/`desired_height`). This can occasionally draw
a widget once more than the old code would have (if the parent it
bubbles up to ends up redrawing the same widget again as part of its own
relayout) — `draw_inner`'s own skip/move dispatch absorbs most of that
redundancy for free, and this path was not one of the migration's measured
conditions, so the remaining slack was accepted rather than chased
further.
## Density: `Len::dp`, resolved at `apply_rest` time (2026-09-06)
Iris asked for a third length kind beside `abs` (physical pixels) and
@@ -632,7 +463,7 @@ desktop backend has no per-monitor density wired up yet and stays at
`1.0`. Every layout call site that used to call `.apply_rest()`/
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `LazySpan::place`, and
`UiRenderState::reposition` itself). This also meant the Android
`UiRenderState::place` itself). This also meant the Android
boundary's global logical-space stopgap could come out entirely: window
size, touch coordinates and insets are physical pixels again, matching
`AndroidRenderer`'s own swapchain resolution, with `dp` doing the
@@ -817,8 +648,8 @@ why it must), and `.background(rect(..))` is the ordinary way to style
anything — so a one-frame-stale box is a background drawn at the wrong
size while the text inside it is already right. On screen that is a tool
card that looks closed while its text is there and open while it is not.
A `reposition` is not the fix and cannot be: it writes an offset, never a
size.
A move alone cannot fix a changed size; `Painter::place` redraws in that
case.
The cost is bounded and worth stating, because it is what makes the rule
safe to apply everywhere: the second draw happens only on the frame a
+12 -29
View File
@@ -650,8 +650,7 @@ strategy alone. Three things, in the order they had to be fixed:
dirty. This alone took the glyph array from 95% re-uploaded to 3%.
2. **A redraw freed its primitives and pushed new ones.** Freed slots are
not reusable until the end of the frame (a layer's draw order still
names them), and `Painter::draw_twice` -- how a container learns a
child's size, and containers nest -- meant the arena's high-water was
names them), and nested provisional layout meant the arena's high-water was
the *transient* push count: 17 million pushes across 401 deltas, and
127,443 slots for 11,569 live primitives, growing linearly with the
transcript. A redraw now gets its old handles back as a recycle pool
@@ -674,36 +673,20 @@ changed. `PrimitiveVec::set` and `Primitives::set_instance` compare before
marking, and `arena_churn` prints both numbers so the gap cannot reopen
unnoticed.
**A measurement is a mode, not a discarded draw (added later the same
day).** `Painter::draw_twice(child, first, |used| second)` became
`Painter::measure` + an ordinary draw, at Iris's request: her objection
was the shape it forced on the caller, since the arithmetic that picks
the real region had to happen inside a closure and anything it wanted to
keep came back out through a captured `&mut`. Two statements now say it
in the order it happens.
**Layout has no measurement mode.** A widget is drawn provisionally only
when its size cannot be known yet, and that retained drawing is moved into
place. `Widget::size_hint(axis)` lets context-free wrappers such as `Sized`
report an exact `Len`; a debug assertion compares every hint with the real
draw result. If final allocation changes a child's size, `Painter::place`
redraws it in that box. Otherwise placement is one move-offset write.
`DrawMode::Measure` is that draw with everything it *writes* switched
off -- no arena slot, no mask, no move slot, nothing left in `active`,
nothing marked dirty -- so the real draw that follows is an ordinary one
and cannot be short-circuited by the measurement having "already drawn"
the widget at that region. A `debug_assert` at the end of `draw_inner`
catches a `Painter` method that forgets to check the mode, because the
failure would otherwise be one leaked primitive per measured widget per
frame.
The amplification this removes, measured: a streamed frame makes **1,083
`Widget::draw` calls over 113 distinct widgets**, and the worst widgets
are drawn **11 times** at nesting depth 7-8. It is not two draws, it is
two to the power of how many measuring ancestors a widget has. Only the
*writes* go away, not the traversals -- the walk and the region
arithmetic still happen 11 times, and removing those needs a size that
can be answered without drawing, which is what LAYOUT.md section 5 rules
out. Worth what it cost: the streamed frame went p50 1.39ms -> 1.22ms
and p99 4.75ms -> 3.58ms, and the upload numbers did not move, because
recycling had already made the discarded writes free in arena terms.
Measured over the fixture's 401 streamed events: the busiest frame makes
176 `Widget::draw` calls and the worst widget is called four times.
Streamed-frame CPU p50 is 0.35ms, from 1.18ms before this layout change.
Arena size and upload floors are unchanged.
**What is left, and it is a layout question rather than an upload one.**
Stream instances upload 72.7%, which *is* the floor: the list is pinned to
Stream instances upload 71.9%, against a 71.8% floor: the list is pinned to
the newest end, so a growing reply moves every row, and a row's instances
carry an absolute region. Moving a subtree is supposed to be one
`move_offsets` write (LAYOUT.md section 2); something on this path is
+18 -20
View File
@@ -21,8 +21,8 @@ controller back, and gets `scroll`, `fling`, `drag`, `amt`,
Two widgets have one, and they differ only in how they spend a delta:
- **`ScrollArea`** (`scroll_area.rs`) — a fixed child, measured whole and
then slid about as a lump, which is what makes a scroll tick an O(1)
- **`ScrollArea`** (`scroll_area.rs`) — a fixed child, drawn and then
slid about as a lump, which is what makes a scroll tick an O(1)
move of one subtree. `.scrollable(axis, pin)` wraps anything in one.
- **`LazySpan`** (`lazy_span.rs`) — lays its own rows out from an anchor,
so it cannot be a lump and is not wrapped in anything. Its own
@@ -126,21 +126,18 @@ The same direction for both owners, and a different origin:
A scrollbar needs a real content length before it can use either, and a
lazy span has none. Do not invent one.
## `ScrollArea::draw` — measure, then place
## `ScrollArea::draw` — draw, then place
1. `take_delta`, and move to where it asks.
2. Draw the child in a box as long as **last frame's** length, to measure
it. This is free in the common case: the same region as last frame
means `draw_inner` returns immediately.
3. Apply the pin and clamp against the length just measured.
4. Draw the child again, at that length and position.
2. Offer the child **last frame's** length and read the size it reports.
An unchanged child returns from `draw_inner` without running `draw`.
3. Apply the pin and clamp against that size.
4. Place the retained drawing at its exact length and position. It is
redrawn only if its reported size does not fit that box.
Only the second draw decides anything, and a frame on which the content
did change pays one real extra draw — a frame on which it was being
redrawn anyway. Placing against the hint and letting the next frame fix it
is what hung the composer's text half a line outside its box on Iris's
phone: **layout is a pure function of the state, not of how many frames
have been drawn**, and there may be no next frame.
There is no measurement mode and no discarded drawing. Placing against
the old length and letting the next frame correct it is not valid: layout
must finish from the current state even if no later frame arrives.
The pin only re-pins on a frame with **no delta of its own**: the pin
means "stay flush with the end as the content grows", and a reader who has
@@ -157,11 +154,10 @@ can say how far it may go, so nothing above it is in a position to.
Measured 2026-09-08, and worth not re-deriving:
- A `Span` is skipped entirely in the steady state, but **when it is
redrawn it costs two draws per child** (21 draws for 10 children):
phase 1 offers each child the ambient region to learn its length,
phase 2 offers it its real share. So any mutation of a `Span` redraws
all of it — 24 draws for 11 children after one prepend.
- A `Span` is skipped entirely in the steady state. When redrawn, it uses
exact hints first, draws unknown fixed children forward from the cursor,
and places retained drawings after flexible allocation. A child is
redrawn only when its final box changes size.
- A `ScrollArea`'s efficiency and virtualisation pull opposite ways: a
scroll tick offers a same-size moved region, `draw_inner` takes the
`mov` path, and the child's `draw` never runs. A virtualising child
@@ -282,7 +278,9 @@ cannot pan; there is a `debug_assert` in `drag` naming that.
even enter the widget. This is the number any "store the edges and only
recompute what changed" optimisation would have to beat, and it is why
the walk was left alone.
- `Span`, redrawn: two draws per child (see above).
- A fully hinted `Span` draws each child once. Unknown fixed children draw
provisionally and move; region-dependent children redraw if their final
box has a different size.
- The one design that would collapse those 31 moves into a single delta
write is moving the content as a unit, which needs a content length —
which a lazy layout cannot supply.