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`