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:
1 parent
0cdda1713f
commit
8c16f9d0fd
1 file changed
+251
-43
+251
-43
@@ -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
|
||||
the widget was given, wherever that box is a remainder rather than the
|
||||
child's whole area.
|
||||
- **Splitting `Widget::draw` into a measure and a draw is acceptable.** His
|
||||
original reason against it: a span whose children have fixed sizes can
|
||||
place them exactly where they were measured and so draw once, and that
|
||||
must survive. The smaller items below come first.
|
||||
- **`draw` stays the only layout method on `Widget`** (Bryan, 2026-09-17,
|
||||
reversing the same day's acceptance of a measure/draw split). Ease of
|
||||
writing a widget is half the reason. The real one is that a second method
|
||||
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`)
|
||||
|
||||
@@ -209,13 +216,75 @@ loop {
|
||||
Bryan's, 2026-09-17, and the right answer where `0e0d4af` was a check:
|
||||
"then that entire category of issue can't even occur".
|
||||
|
||||
**No fuzzer can tell whether `0e0d4af`'s guard still does anything.**
|
||||
Dropping `dirty_size_under` from it passes the suite, the shrinker at 400
|
||||
seeds of depth 5, the oracle at 1000 of depth 6 and 2000 seeds at depth 4.
|
||||
It stays because `update` draws the root for a resize *before*
|
||||
`redraw_updates` runs at all, which the ordering does not reach -- a hole
|
||||
that is reasoned rather than measured, so either find the case or delete
|
||||
the guard, but do not leave it on a hunch forever.
|
||||
**The walk is sound on its own, and the guard is only there for a second
|
||||
entry point** (read on 2026-09-17, Bryan asking for either a breaking case
|
||||
or a proof). By induction on depth: when a widget at depth d draws fresh,
|
||||
every dirty widget deeper has been popped, so each is settled or deferred,
|
||||
and a deferred one has marked its parent. A clean child asked by that draw
|
||||
therefore has a clean subtree, because anything dirty under it would have a
|
||||
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
|
||||
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
|
||||
|
||||
**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
|
||||
*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
|
||||
@@ -373,15 +449,147 @@ back.
|
||||
- `Fixed::div` by zero answers `MIN`/`MAX` while `ratio` answers `ZERO`;
|
||||
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`,
|
||||
`offered`, `answers_offer`, the placing second ask and the reuse dance after
|
||||
it. It costs two methods on every widget and is a rewrite of a core that
|
||||
passes a thousand seeds at depth six. The one-draw property Bryan wants
|
||||
kept holds either way: a child with a fixed size needs no measure, and a
|
||||
measured child costs a measure plus a draw where it now costs a draw plus a
|
||||
reuse or a redraw. Last in "Next".
|
||||
Read on 2026-09-17, against Bryan's question of whether the approach is
|
||||
fundamentally wrong or needs adjusting. The pieces the earlier review
|
||||
approved are sound and the fuzzers agree: fixed point, the pixel chain
|
||||
threaded down, `Holds::through` as the exact preimage, and the bottom-up
|
||||
settle, with every residual they leave one or two steps in a position. One
|
||||
thing is wrong, and it is the protocol rather than any widget: **the
|
||||
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
|
||||
|
||||
@@ -565,9 +773,9 @@ above for what closing the rest would cost.
|
||||
parent (seed 10). `Painter` records size-dependency edges only when a
|
||||
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.
|
||||
- Dirty widgets settle deepest-first. `dirty_size_under` stops a reader
|
||||
taking a retained answer while something below it is dirty; it is an
|
||||
optimization against laying out twice, not a validity mechanism.
|
||||
- Dirty widgets settle deepest-first, and that ordering is what makes an
|
||||
answer trustworthy; `dirty_size_under` is to be deleted once a resize goes
|
||||
through the same walk (see "A frame settles strictly bottom-up").
|
||||
- Declared non-`leftover` lengths are resolved by the parent where the
|
||||
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.
|
||||
@@ -785,22 +993,24 @@ The replay used for the reference check:
|
||||
|
||||
In order, from the review above and Bryan's steer (2026-09-17):
|
||||
|
||||
1. **`wip/stack-fraction-twice`**: find the composition that went from
|
||||
exact to rounded when `box_of` stopped narrowing, and land it. This is
|
||||
the one outright wrong layout known on the branch.
|
||||
2. **`wip/padding-outset-and-inset`**: account for the second halving under
|
||||
`Inset`, rename `Pad` to `Outset` and `.pad()` to `.outset()`, and audit
|
||||
every `.pad()` in the examples for which of the two it meant.
|
||||
3. **A fast test for `0e0d4af`**, the stale-answer guard, which went in
|
||||
with only a fuzz seed behind it. The shape wanted is a widget drawing
|
||||
while a size dependency two levels under it is dirty, where its
|
||||
drawing is reusable at the measuring box and not at the placing one.
|
||||
Check at the same time whether `a92c6ac`'s ordering has made the guard
|
||||
dead outside the resize path.
|
||||
4. Write `ActiveData::answer` in one place.
|
||||
5. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the
|
||||
1. **A resize marks the root and goes through the walk**; delete
|
||||
`dirty_size_under` at both call sites; write `active.parent` where
|
||||
`draw_inner` writes `given`, and descendants' `depth` in
|
||||
`remap_subtree`; pin re-parenting with a test that moves a child between
|
||||
two spans. Small, argued by the induction above, verified by the rig's
|
||||
counters.
|
||||
2. **Frame and extent**, as written above. This is the fundamental change
|
||||
and comes before anything built on the placing ask. It lands the two
|
||||
parked branches' tests (the stack test unchanged, `Inset`/`Outset` with
|
||||
the rename and the `.pad()` audit), flips
|
||||
`a_span_reads_a_child_report_as_a_fraction_of_the_row`, and deletes
|
||||
`box_of`, `reports_of`'s composition in `in_parent_frame`, and the
|
||||
`through(lens)` translation in `draw_inner`. Run the long fuzzers and
|
||||
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`.
|
||||
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
|
||||
(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
|
||||
@@ -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`
|
||||
still inlines; expect a couple of percent of instructions and re-run the
|
||||
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
|
||||
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
|
||||
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.
|
||||
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
|
||||
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
|
||||
only what the parent reports for it.
|
||||
10. `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.
|
||||
9. `Scroll` taking a direction rather than one axis.
|
||||
|
||||
`docs/LAYOUT.md` §4, §5 and the density section are stale: they name
|
||||
`Painter::place`, `SetSize`, `desired_width`, `apply_rest`, `Len::dp`,
|
||||
|
||||
Reference in new issue
Block a user