Redesign span layout around retained placement
This commit is contained in:
1 parent
ae0af8f5e3
commit
0aa03cf621
30 files changed
+501
-1321
No files matched your search
+8
-57
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user