Record the frame-and-extent finding and the resize walk, and drop the measure/draw split

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Fable 5.1 committed 2026-09-17 12:34:41 -04:00
1 parent 0cdda1713f
commit 8c16f9d0fd
1 file changed
+251 -43
+251 -43
View File
@@ -77,10 +77,17 @@ grid width fixes it; where it becomes visible is the shader's snap.
supersedes the 2026-09-16 reading that a report is a fraction of the box supersedes the 2026-09-16 reading that a report is a fraction of the box
the widget was given, wherever that box is a remainder rather than the the widget was given, wherever that box is a remainder rather than the
child's whole area. child's whole area.
- **Splitting `Widget::draw` into a measure and a draw is acceptable.** His - **`draw` stays the only layout method on `Widget`** (Bryan, 2026-09-17,
original reason against it: a span whose children have fixed sizes can reversing the same day's acceptance of a measure/draw split). Ease of
place them exactly where they were measured and so draw once, and that writing a widget is half the reason. The real one is that a second method
must survive. The smaller items below come first. holding the same layout drifts from the first, which a span makes
extremely easy, and the shared logic then gets pulled into helpers both
call that still have to be applied carefully in each. Where the framework
needs a widget's layout twice it runs the same body again with the
painter in a different state, or hands it more through the painter.
- **A widget's frame does not change between the ask that measures and the
ask that places.** Fractions are of the frame; the extent reaches the
widget through the painter. See "Frame and extent" below.
### A report is a fraction of the containing widget (landed, `ffd79f3`) ### A report is a fraction of the containing widget (landed, `ffd79f3`)
@@ -209,13 +216,75 @@ loop {
Bryan's, 2026-09-17, and the right answer where `0e0d4af` was a check: Bryan's, 2026-09-17, and the right answer where `0e0d4af` was a check:
"then that entire category of issue can't even occur". "then that entire category of issue can't even occur".
**No fuzzer can tell whether `0e0d4af`'s guard still does anything.** **The walk is sound on its own, and the guard is only there for a second
Dropping `dirty_size_under` from it passes the suite, the shrinker at 400 entry point** (read on 2026-09-17, Bryan asking for either a breaking case
seeds of depth 5, the oracle at 1000 of depth 6 and 2000 seeds at depth 4. or a proof). By induction on depth: when a widget at depth d draws fresh,
It stays because `update` draws the root for a resize *before* every dirty widget deeper has been popped, so each is settled or deferred,
`redraw_updates` runs at all, which the ordering does not reach -- a hole and a deferred one has marked its parent. A clean child asked by that draw
that is reasoned rather than measured, so either find the case or delete therefore has a clean subtree, because anything dirty under it would have a
the guard, but do not leave it on a hunch forever. dirty parent, and so on up to the child itself. `dirty_size_under` cannot
fire inside `redraw_updates`, which is what dropping it from the suite,
the shrinker at 400 seeds of depth 5, the oracle at 1000 of depth 6 and
2000 seeds at depth 4 measured.
The entry point it guards is `update` drawing the root for a resize before
the walk runs, top-down over a tree with dirty widgets still in it. Most
routes through that are safe anyway: `retained_answer` succeeding at the
offer implies `try_reuse` succeeds at the placed box, since the settled
holds are cut by the drawing's holds taken through the placing length, so
a stale answer is reused
whole and the dirty descendant later reaches its readers through
`redraw`'s comparison. The route that is not safe is the one asymmetry
between the two: `try_reuse` refuses a drawing on another layer and
`retained_answer` does not. A resize frame in which a structural edit
shifted a clean child's layer, with a dirty descendant under that child
whose answer changes, hands back the old answer, redraws the subtree fresh
in `place`, clears the descendant's mark there, and tells nobody.
**Close the entry point rather than test the contrived case.** A resize
marks the root and nothing else, so layout has one walk and the induction
covers everything:
```rust
pub fn resize(&mut self, size: impl Into<Vec2>, widgets: &mut Widgets) {
let size = PxVec2::from_f32(size.into());
if size == self.output_size {
return;
}
self.output_size = size;
if let Some(root) = self.old_root {
widgets.needs_redraw.insert(root);
}
}
```
`redraw` already asks a parentless widget again in `root_region` against
the output. Then `dirty_size_under` goes at both call sites, `draw_inner`
and `retained_size`. Bryan's question of whether a resize could instead
mark "the important widgets" up front answers itself: `needs_redraw` is the
mark, and `Holds` is the exact, per-widget, lazy computation of which
widgets a new output invalidates; nothing has to be worked out ahead of the
walk. What it costs: the root always runs its own `draw` on a resize where
today the whole tree can remap in one `try_reuse`, and a widget dirty in
the same frame as a resize may draw twice if the root's layout then gives
it a different box. Drawn widgets should otherwise be identical on the rig;
check the counters.
**Two facts the induction relies on are not pinned, and both are the shape
of `0e0d4af`: state that is right only because something else set it up.**
- `try_reuse` never writes `active.parent`, and neither does the tail of
`draw_inner`. A subtree reused under a different parent inside the same
region node keeps the old parent, so a later deferral marks the wrong
widget. The fuzzer never re-parents: `reshuffle` only trades children
between a span and its own spares. A hand-built test moving a child
between two spans should miss a redraw or trip the `depth()` assertion.
- `remap_subtree` never updates descendants' `depth`. The debug assertion
in `depth()` catches it only for a widget that later goes dirty.
Both fixes are one line where `draw_inner` already writes `given` and
`answer`, plus a walk in `remap_subtree` or a depth kept relative to the
nearest region node.
Drawn widgets, widget draws and primitive writes are unchanged on every rig Drawn widgets, widget draws and primitive writes are unchanged on every rig
phase; `many` pays 51 queue pops for 27 and 1059 depth reads for 410. phase; `many` pays 51 queue pops for 27 and 1059 depth reads for 410.
@@ -248,6 +317,13 @@ same length.**
### Two branches parked, both real, neither ready ### Two branches parked, both real, neither ready
**Superseded on 2026-09-17: the two branches are one defect, and it is in
the protocol rather than in either widget.** See "Frame and extent" below.
Do not chase seed 1091's three steps or the Inset's 47.5 px; both are the
placing ask resolving a fraction in the answer box. What each branch got
right is kept: the `Inset`/`Outset` pair and its tests, and the stack test
asserting that half stays half. Their painter changes go.
**`wip/stack-fraction-twice`.** A stack sized by a child that reports a **`wip/stack-fraction-twice`.** A stack sized by a child that reports a
*fraction* applies that fraction twice: its parent places the stack at the *fraction* applies that fraction twice: its parent places the stack at the
reported length, and `box_of(size)` then takes the same fraction of that reported length, and `box_of(size)` then takes the same fraction of that
@@ -373,15 +449,147 @@ back.
- `Fixed::div` by zero answers `MIN`/`MAX` while `ratio` answers `ZERO`; - `Fixed::div` by zero answers `MIN`/`MAX` while `ratio` answers `ZERO`;
both are caller bugs under `debug_assert`, but the fallbacks differ. both are caller bugs under `debug_assert`, but the fallbacks differ.
### Measure and draw, later ### Frame and extent
Splitting `Widget::draw` into a measure and a draw would delete `at_offer`, Read on 2026-09-17, against Bryan's question of whether the approach is
`offered`, `answers_offer`, the placing second ask and the reuse dance after fundamentally wrong or needs adjusting. The pieces the earlier review
it. It costs two methods on every widget and is a rewrite of a core that approved are sound and the fuzzers agree: fixed point, the pixel chain
passes a thousand seeds at depth six. The one-draw property Bryan wants threaded down, `Holds::through` as the exact preimage, and the bottom-up
kept holds either way: a child with a fixed size needs no measure, and a settle, with every residual they leave one or two steps in a position. One
measured child costs a measure plus a draw where it now costs a draw plus a thing is wrong, and it is the protocol rather than any widget: **the
reuse or a redraw. Last in "Next". placing ask replaces the box a widget's children resolve fractions against
with the widget's own answer.** Every "fraction twice" item is that defect,
and it has lasted because it was fixed per widget four times (`decided`,
`reports_of`, `box_of`, the stack branch's `resolve`, `Inset` against
`Pad`), each of which stops the re-resolution at one level while it happens
again inside the child's second draw, which no flag on the parent's ask
can reach.
The repository asserts both answers. `tests/cases/layout.rs` pins a nested
span's `rel(0.5)` child at a quarter of the row and calls it correct ("half
of that final box is what its own child takes"); the parked stack branch's
test pins the same shape as a half. Both are consequences of the one line
where a child's region composes within the widget's current box, which the
placing ask has made the answer:
```rust
// painter.rs, widget_at
let within = match local == UiRegion::FULL {
true => self.region,
false => local.within(&self.region),
};
```
The reuse path gives the same result, because `AxisRemap::Scale`
re-expresses the child as a fraction of the new box. `Len::within` itself
is correct geometry and was never the problem.
**The protocol is one draw body evaluated twice, and that is right.** The
first evaluation measures and the second places, and the retained
machinery skips the second wherever it can. Only what the second is told
its box is has to change. The widget stays in its **frame**, the box it was
offered, and is handed its **extent** as a region in the frame's
coordinates:
```rust
pub struct Painter<'a> {
/// This widget's frame: the box it was offered, in `move_idx`
/// coordinates. Every region this draw writes is in its coordinates
/// and every fraction in one is of it. It does not change between the
/// ask that measures and the ask that places.
pub(super) frame: UiRegion,
/// What of the frame this widget's answer took, in the frame's own
/// coordinates: `FULL` while the answer is not yet known, and the
/// placed answer once its parent has chosen where it sits.
extent: UiRegion,
/// The frame in pixels; the extent is one `Len` of it.
pub(super) px: PxVec2,
/// Whether this draw read its extent, which makes the drawing one that
/// holds only for that extent, the way `px_len` does for a length.
reads_extent: bool,
}
impl Painter<'_> {
pub fn extent(&mut self) -> UiRegion {
self.reads_extent = true;
self.extent
}
}
```
`px_len` stays the frame's length, which is what a text wraps at and what
`Span`'s leftover decision reads. `widget_at` composes the child's region
within `self.frame` and resolves the child's fractions, declared or
reported, against the child's own offer, which `ActiveData::offer_len`
already records. `draw_inner` places the answer as a length of the offer
rather than of the region passed this time:
```rust
let lens = placed_lens(answer.0, declared, info.decided);
let extent = lens.within_len(info.offer_len);
let placed = placed_box(region, extent, align);
```
Where the parent hands back exactly the answer the slack is zero and the
multiply exact, so `decided` stops being load-bearing. `try_reuse` compares
frames, not placed boxes: a nested span's frame is the row on both asks, so
nothing remaps and its rect stays at half the row. Remapping is for a frame
that moved, a translation in the common case and a scale on a resize, the
two cases `AxisRemap` has.
**For a widget author the rule is one sentence: regions are written in the
frame, and `painter.extent()` is "my box".**
```rust
// span.rs: children sit across the row inside the span's extent.
let across = painter.extent().axis(!axis);
let region = UiRegion::from_axis(axis, span, across);
// pad.rs: the inner sits in the pad's extent less the padding.
painter.widget_within(&self.inner, self.padding.region_of(painter.extent()))
// stack.rs: every child gets the stack's extent; `box_of` is deleted.
let region = painter.extent();
```
`Span`'s cursor arithmetic does not change: it is already a sum of the
children's answers, which are fractions of the row.
Checked by hand against the failing shapes:
- Nested span: the inner's frame is the row on both asks; its rect is 200
px. `a_span_reads_a_child_report_as_a_fraction_of_the_row` flips to
assert 200..400 for the inner.
- Stack sized by a `rel(0.5)` child: the child's extent and the stack's are
the same half of the frame, zero slack, exact. Seed 1091's three steps
were `placed_box` scaling a nonzero slack per level.
- Pad and Inset: the inner's fraction is of its offer and its extent sits in
`extent().inset(padding)`. The 47.5 px was the offer changing between
asks.
- Pad around a 40 px rect, bottom aligned: on the second ask the pad's
extent is 60 px, the inner region 40 px, slack zero, rect at 20..60. This
is the case that needs the second evaluation at all, and why a frame
alone is not enough.
- A rule: a stack declared `width(rel(0.5))` holding a `rel(0.5)` rect gives
a quarter of the row, correctly. A rule sets the frame; a report does not.
Reading the extent narrows reuse the way reading a pixel length does. A
widget that never calls `extent()` has a first drawing that holds for any
extent and keeps it: `Stack` without a sizing child, `Scroll`, every leaf,
`Span` along its axis. One that reads it is redrawn on the second ask only
where the extent differs from the frame, and its children reuse through
their own `Holds` since their frames did not move. Suppressing primitives
on the first evaluation would be an optimization over this, not a
requirement. The one place it costs more than today is a stack whose
sizing child reports less than the frame: `box_of` narrowed the other
children's first draw so they landed right at once, and with `extent()`
they draw at `FULL` and again at the extent. They are usually a background
rect; let the rig's counters say whether it matters.
Fixed point, the pixel chain and `Holds::through` are untouched. The chain
already threads `offer_len` beside `given_len` and `offered_px` beside
`px`; the change is that the offer chain becomes the coordinate base and
the placed chain is derived from it, rather than the other way round.
## How layout is decided ## How layout is decided
@@ -565,9 +773,9 @@ above for what closing the rest would cost.
parent (seed 10). `Painter` records size-dependency edges only when a parent (seed 10). `Painter` records size-dependency edges only when a
parent reads a child's size or hint; an undrawn measured child stays parent reads a child's size or hint; an undrawn measured child stays
recorded so a later change reaches whoever decided not to draw it. recorded so a later change reaches whoever decided not to draw it.
- Dirty widgets settle deepest-first. `dirty_size_under` stops a reader - Dirty widgets settle deepest-first, and that ordering is what makes an
taking a retained answer while something below it is dirty; it is an answer trustworthy; `dirty_size_under` is to be deleted once a resize goes
optimization against laying out twice, not a validity mechanism. through the same walk (see "A frame settles strictly bottom-up").
- Declared non-`leftover` lengths are resolved by the parent where the - Declared non-`leftover` lengths are resolved by the parent where the
widget is drawn, so a declared-length change redraws the parent. A rule widget is drawn, so a declared-length change redraws the parent. A rule
wins on the axis it names and the widget under it never learns of it. wins on the axis it names and the widget under it never learns of it.
@@ -785,22 +993,24 @@ The replay used for the reference check:
In order, from the review above and Bryan's steer (2026-09-17): In order, from the review above and Bryan's steer (2026-09-17):
1. **`wip/stack-fraction-twice`**: find the composition that went from 1. **A resize marks the root and goes through the walk**; delete
exact to rounded when `box_of` stopped narrowing, and land it. This is `dirty_size_under` at both call sites; write `active.parent` where
the one outright wrong layout known on the branch. `draw_inner` writes `given`, and descendants' `depth` in
2. **`wip/padding-outset-and-inset`**: account for the second halving under `remap_subtree`; pin re-parenting with a test that moves a child between
`Inset`, rename `Pad` to `Outset` and `.pad()` to `.outset()`, and audit two spans. Small, argued by the induction above, verified by the rig's
every `.pad()` in the examples for which of the two it meant. counters.
3. **A fast test for `0e0d4af`**, the stale-answer guard, which went in 2. **Frame and extent**, as written above. This is the fundamental change
with only a fuzz seed behind it. The shape wanted is a widget drawing and comes before anything built on the placing ask. It lands the two
while a size dependency two levels under it is dirty, where its parked branches' tests (the stack test unchanged, `Inset`/`Outset` with
drawing is reusable at the measuring box and not at the placing one. the rename and the `.pad()` audit), flips
Check at the same time whether `a92c6ac`'s ordering has made the guard `a_span_reads_a_child_report_as_a_fraction_of_the_row`, and deletes
dead outside the resize path. `box_of`, `reports_of`'s composition in `in_parent_frame`, and the
4. Write `ActiveData::answer` in one place. `through(lens)` translation in `draw_inner`. Run the long fuzzers and
5. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the the render set once for it.
3. Write `ActiveData::answer` in one place.
4. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the
reconstruction in `redraw`. reconstruction in `redraw`.
6. **Round on the CPU and snap to the nearest pixel in the shader**, as 5. **Round on the CPU and snap to the nearest pixel in the shader**, as
one change with one verification. Bryan approved the snap on 2026-09-17 one change with one verification. Bryan approved the snap on 2026-09-17
(rendering may change wherever it brings the screen closer to what the (rendering may change wherever it brings the screen closer to what the
user's code says: three equal sections of 1000 px need one of them user's code says: three equal sections of 1000 px need one of them
@@ -816,21 +1026,19 @@ In order, from the review above and Bryan's steer (2026-09-17):
bounds move by half a `Rel` step); check with `nm` that `UiSpan::within` bounds move by half a `Rel` step); check with `nm` that `UiSpan::within`
still inlines; expect a couple of percent of instructions and re-run the still inlines; expect a couple of percent of instructions and re-run the
long fuzzers and the render set once for both. long fuzzers and the render set once for both.
7. The smaller items: the stale `f32` comment, the gap of an undrawn child, 6. The smaller items: the stale `f32` comment, the gap of an undrawn child,
confirm nested `leftover` weights, one zero-divisor fallback. Add to confirm nested `leftover` weights, one zero-divisor fallback. Add to
them: a span that overflows itself hands a child a box of negative them: a span that overflows itself hands a child a box of negative
length, which is ordinary now rather than a corner, and nothing states length, which is ordinary now rather than a corner, and nothing states
what a widget may assume about one. what a widget may assume about one.
8. `LazySpan`, the next LAYOUT.md §2 item. Region nodes cover the movable 7. `LazySpan`, the next LAYOUT.md §2 item. Region nodes cover the movable
subtree case; do not restore a separate child-placement API. subtree case; do not restore a separate child-placement API.
9. `SizeRule::{Min, Max, Clamp}`, restoring the `max_width`/`max_height` 8. `SizeRule::{Min, Max, Clamp}`, restoring the `max_width`/`max_height`
builders `8220a78` deleted. The clamp boundary is a hard layout decision builders `8220a78` deleted. The clamp boundary is a hard layout decision
with an exact `Holds` split at the crossover, both sides in `Px`. Still with an exact `Holds` split at the crossover, both sides in `Px`. Still
awaiting Bryan: whether a `Max` narrows the box the child draws in, or awaiting Bryan: whether a `Max` narrows the box the child draws in, or
only what the parent reports for it. only what the parent reports for it.
10. `Scroll` taking a direction rather than one axis. 9. `Scroll` taking a direction rather than one axis.
11. The measure/draw split, once the above is in. It deletes the two-ask
protocol, which is what `0e0d4af` had to put a guard around.
`docs/LAYOUT.md` §4, §5 and the density section are stale: they name `docs/LAYOUT.md` §4, §5 and the density section are stale: they name
`Painter::place`, `SetSize`, `desired_width`, `apply_rest`, `Len::dp`, `Painter::place`, `SetSize`, `desired_width`, `apply_rest`, `Len::dp`,