diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index c729746..97f1949 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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, 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`,