diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index e953707..a53b0b1 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -136,14 +136,46 @@ pub fn widget<'s, W: ?Sized>(&'s mut self, id: &'s StrongWidget) -> DrawResul } ``` -`Span` is the plan's, without the known-length shortcut (step 7, not done): -it reads `far = painter.extent_len(axis)`, measures each child at -`Place::Within(Part::From(along(cursor, far)))`, and places it at -`Place::Fill(Part::From(along(from, start)))`, with `Place::Within(Part::All)` -across itself. `Stack` gives its sizing child `Fill(All)` and the rest -`Within(All)`; `Scroll` measures at `Fill(All)` and places at -`Fill(From(px content box))`; `Pad` is transparent and insets by -`Within(Of(..))`; `Masked` sets its mask over its own box. +`Span` is the plan's, without the known-length shortcut (step 7, not done), +and reads nothing about where it sits: + +```rust +let far = painter.extent_len(axis); // symbolic; pins the length +let along = |from: Len, to: Len| match self.dir.sign { + Sign::Pos => UiSpan::new(from, to), + Sign::Neg => UiSpan::new(far - to, far - from), +}; +let across = Place::Within(Part::All); +for child in &self.children { // measure + let room = Place::Within(Part::From(along(cursor, far))); + let len = painter + .widget_at(child, UiRegion::FULL, axis.pair(room, across)) + .len(axis); + cursor.px += len.px + self.gap; + cursor.rel += len.rel; + lens.push(len); +} +for (child, len) in self.children.iter().zip(&lens) { // place + let slot = Place::Fill(Part::From(along(from, start))); + let placed = painter.widget_at(child, UiRegion::FULL, axis.pair(slot, across)); +} +``` + +`Pad` is transparent and says its inset in its own box's lengths, which is +what keeps it from reading how long that box is: + +```rust +let inset = |lead: Px, trail: Px| { + Place::Within(Part::Of(UiSpan::new( + Len::from_parts(Rel::ZERO, lead), + Len::from_parts(Rel::ONE, -trail), + ))) +}; +``` + +`Stack` gives its sizing child `Fill(All)` and the rest `Within(All)`; +`Scroll` measures at `Fill(All)` and places at `Fill(From(px content box))`; +`Masked` sets its mask over its own box. A report is still `LayoutLen { px, rel, leftover }` per axis, a fraction of the reporting widget's frame, so it passes a transparent parent unchanged and @@ -305,6 +337,353 @@ box goes with it (what the code does, so an inset inside a row draws its child across the whole row), or the box is a part and the frame does not narrow (`Part::Of`, what `Pad` does), or a third thing Bryan decides. +**The two decisions, in the form they need answering.** + +1. *What is a widget's answer the answer to?* Options as they stand: keep + the deferral, so a mismatched box is always the parent's question (what + the code does, and what #18 does); or give a container one drawing rather + than two, so there is no second geometry to disagree about, which means + deciding a child's box before drawing it; or name the measuring geometry + explicitly in the ask rather than deriving it, which is the retained + `offer_part` taken further. +2. *What is `Pad`?* An outset (what the code does), an inset that takes the + child's box with it (so an inset in a row draws its child across the + row), or "outset pixels, inset `rel` and `leftover`", which needs a way + to say a box in a narrowed frame that the grid cannot express today. + +### The plan as written, for reference + +What follows is the plan as it was handed over on 2026-09-18, kept verbatim +so that what was asked for can be read against what landed. Where the two +differ, the sections above are the code: `Part::Of` is not in it, lazy +`Within` placement is in it and was removed, `extent_len` takes an axis, and +steps 6 to 9 are not done. + +Its own preamble: + +> Decided with Bryan on 2026-09-18 after the trace below. The mechanism was +> checked against the code of `34cafb6` and against the two counterexamples +> on `wip/local-reask`; the pieces that were in doubt are called out. Do the +> steps in order and run each step's check before the next. **If a check fails +> and the fix that suggests itself is a tolerance, a pin, a deferral, a second +> layout method, or a special case in `Span`, stop and report instead**: those +> are exactly the patches that have been made around this design before, and +> each one made the next problem harder to see. + +#### The protocol + +Two boxes per widget, both in the *parent's frame coordinates*: + +- **frame** -- what a declared or reported fraction is a fraction of. + `UiRegion::FULL` for a transparent parent; narrowed by `ask_box` for a + declared length and by an inset. Its *length* is the same on every ask of + the widget, which is the property the whole retained model rests on. +- **extent** -- where the drawing goes. Given by the parent as a `Place` + per axis, **relative to the parent's extent start, in frame units**: + +```rust +/// Where a child goes along one axis, as a part of this widget's extent. +/// Spans are frame lengths from the extent's start, so a span's slot is +/// `from..start` and a moved extent re-places every child by re-adding +/// its start, exactly. `None` is the whole extent. +pub enum Place { + /// The child's answer, aligned inside the part by the child's alignment. + Within(Option), + /// Exactly the part; the answer is not placed inside it again. + Fill(Option), +} +``` + +The widget's `draw` sees only its extent: `px_len`/`px_size` are the +extent's pixels and narrow the extent range, `holds` widens it. Primitives +and masks are written in extent coordinates (`FULL` is the extent); there is +no `DrawRegion` and no frame-coordinate primitive. A container reads +`extent_len() -> UiVec2`, the extent's *symbolic* length in frame units, +which pins its drawing to that length and to nothing about the start. A +span that divides room also reads `frame_px_len(axis)` (today's +`region_px_len`) and widens through `frame_holds` (today's `region_holds`), +which narrow the frame range. `placement()`, `region()`, `box_of`, +`measure_len`, `widget_within`, `DrawRegion` and `ExtentPlacement` go. + +The parent side is one call, with a shorthand: + +```rust +pub fn widget_at<'s, W: ?Sized>( + &'s mut self, + id: &'s StrongWidget, + frame: UiRegion, // in this widget's frame coordinates; FULL forwards it + place: [Place; 2], // relative to this widget's extent start, in frame units +) -> DrawResult<'s, 'a, W>; + +/// The transparent default: the frame as given, the answer aligned in the extent. +pub fn widget<'s, W: ?Sized>(&'s mut self, id: &'s StrongWidget) -> DrawResult<'s, 'a, W> { + self.widget_at(id, UiRegion::FULL, [Place::Within(None); 2]) +} +``` + +`Span`, measuring and placing, with the row being its own extent: + +```rust +fn draw(&mut self, painter: &mut Painter) -> Size { + let axis = self.dir.axis; + let far = painter.extent_len().axis(axis); // symbolic; pins the length + let along = |from: Len, to: Len| match self.dir.sign { + Sign::Pos => UiSpan::new(from, to), + Sign::Neg => UiSpan::new(far - to, far - from), + }; + let across = Place::Within(None); + // Measure. A child whose length is already known -- a rule, a hint -- + // with no share before it is placed at its slot here and never again. + let mut cursor = Len::ZERO; + let mut shares_before = false; + let mut lens = Vec::with_capacity(self.children.len()); + for child in &self.children { + let known = painter.size_hint(child, axis).filter(|_| !shares_before); + let place = match known { + Some(len) if len.leftover == Weight::ZERO => { + Place::Fill(Some(along(cursor, cursor + Len::from_parts(len.rel, len.px)))) + } + _ => Place::Within(Some(along(cursor, far))), + }; + let len = painter.widget_at(child, UiRegion::FULL, axis.pair(place, across)).len(axis); + shares_before |= len.leftover != Weight::ZERO; + cursor += Len::from_parts(len.rel, len.px + self.gap); + lens.push(len); + } + // total, room = far - fixed, the shares decision through frame_px_len and + // frame_holds(through(room)): unchanged from today. + // Place. A child already at its slot is an exact reuse; one whose slot + // moved is recomposed, since its length did not change. + for (child, len) in self.children.iter().zip(&lens) { + // undraw a share with nothing to share, accumulate fixed/taken, then: + painter.widget_at(child, UiRegion::FULL, axis.pair(Place::Fill(Some(along(from, start))), across)); + } + Size::from_axis(axis, total, ortho) +} +``` + +Across itself a span reports the longest child in *frame pixels* among the +`px` and `rel` parts of its children's reports, as that child's own `Len`. +Comparing in pixels is safe here because the frame is decided from above +and nothing feeds back; the read is a `frame_px_len` and narrows the frame +range, and at the crossover both candidates are the same number of pixels, +so the drawing is the same on either side of it. A child's `leftover` +across a span contributes nothing to that: across, children overlap rather +than divide anything, so a share can only mean "as tall as the span", and +`Place::Within(None)` already fills the extent for a leftover answer. Only +when no child has a `px` or `rel` part does the span report `leftover` +itself and take its parent's room (Bryan, 2026-09-18: "returning rest if +any have it is probably fine", refined to this because it costs nothing). +So `row![text, rect]` is as tall as the text with the rect filling it, and +`row![rect, rect]` fills its column's share. **Deliberately not done**: a +fully transparent `leftover` across a span, where the span's height would +be the larger of its tallest fixed child and the share its parent hands +back. That needs the parent's division to come back down after the report, +a second resolution pass; Bryan deferred it until the core is settled. Do +not attempt it as part of this plan. + +The shapes the rest of the containers take (write them, they are short): + +```rust +// Stack: the sizing child takes the whole extent, the rest are aligned in it. +painter.widget_at(child, UiRegion::FULL, [Place::Fill(None); 2]).size() // sizing +painter.widget_at(child, UiRegion::FULL, [Place::Within(None); 2]); // others +// Scroll: viewport is the extent; content is a pixel box offset by the scroll. +painter.widget_at(&self.inner, UiRegion::FULL, + self.axis.pair(Place::Fill(Some(UiSpan::px(anchor - amt, anchor - amt + content_len))), Place::Fill(None))); +// Inset with px/rel margins m: a narrowed frame and a narrowed extent. +let frame = m.narrow(UiRegion::FULL); // Len subtraction, exact +let len = painter.extent_len(); +painter.widget_at(child, frame, [Place::Within(Some(UiSpan::new(m.lead.x, len.x - m.trail.x))), /* y */]).size() + + m.total() // report child plus margins +// Outset: the same placement with the frame forwarded (UiRegion::FULL). +// Leftover margins: measure the child with Within(None), divide the room +// left in the extent by weight as a span does, then place with Fill. +``` + +A report is still `LayoutLen { px, rel, leftover }` per axis. A fraction in +it is of the reporting widget's frame, so through a transparent parent it +passes unchanged and through a narrowing parent (declared, inset) it is +composed by `within_len(frame.len())` as `in_parent_frame` does today. A +stack sized by a child that reports `rel(0.5)` therefore reports `rel(0.5)`, +is placed at half the frame, and hands its child the whole extent while the +child's frame is still the full one: the fraction is applied once. + +#### Retained state and reuse + +Per widget (`ActiveData`), replacing `region`/`placement`/`given_region`/ +`offer_len`/`offer_placement`: + +- `frame: UiRegion` in the parent's frame coordinates, and `place: [Place; 2]` + as last given; `offer_place: [Place; 2]` from the first ask of the + parent's draw at the offer. There is no `offer_frame`: the frame's length + is the same on every ask, and `redraw` asserts it in debug. +- `frame_abs`, `extent_abs`: the two composed into `parent_move` + coordinates, for writing primitives and for `window_region`; rewritten by + recomposition. +- `answer: Option<(Size, LayoutHolds)>` from the offer ask; `holds: + LayoutHolds` for the drawing, where + +```rust +pub struct LayoutHolds { + pub frame: [Holds; 2], // frame pixel lengths + pub extent: [Holds; 2], // extent pixel lengths + pub extent_len: [Option; 2], // symbolic extent length, where read +} +``` + + The `placement: Option` pin is gone. Nothing may depend on where + an extent starts. + +- primitives and the mask retained in extent-local coordinates, as now. + +**Reuse** of a drawing at an ask: same layer, parent move and region-node +choice; frame pixels inside `frame`; the extent resolved from `place` +(`Fill` is the part; `Within` is the answer aligned in the part, or the part +where the answer fills) has pixels inside `extent` and, where pinned, the +same symbolic length. A frame that moved recomposes the subtree from +retained local coordinates (today's `recompose_subtree`). An extent that +moved **re-places every child** through its retained `place` +(`extent_abs.start + place`, then the child's own reuse test), stopping at +region nodes; this is today's `reposition` over `extent_children`, +generalised to all children because every child is now placed relative to +the extent. A child whose reuse fails there is drawn again at its retained +place. Along a span this is what moves `px` and `rel` children whose slots +shifted: the span's redraw re-issues `Fill(from..start)` with the same +lengths and different starts, and the child recomposes. + +**A `Within` ask does not place the answer immediately.** The child draws +in the whole part, and the aligned answer box is applied by the next ask of +that child in the same parent draw, or, for a child not asked again, by a +pass at the end of the parent's draw over `children`. That is today's +`measure_len` rule made the rule for every open axis; it is what keeps a +span child at one draw plus one recomposition rather than two +recompositions. + +**Dependencies composed into the parent** (today's `in_parent`): a child's +`frame` range goes through the child's frame length into the parent's frame +range. A child's `extent` range goes into the parent's *frame* range through +the part's length where the part is a span (a frame length), and into the +parent's *extent* range where the part is `None` (the whole extent). Answer +dependencies come only from children whose answer was read, drawing +dependencies from every child drawn, as today. Pins do not compose: a child +pinned on its symbolic length is checked when it is re-placed. + +**Local redraw** (`redraw`): no deferral on lengths. Draw at `offer_place` +with the drawing left there where the given place differs (measuring), +compare answer and holds with what was retained, keep a still-covering old +guarantee as today, mark the parent only on a change, then place at the +given `place` (a reuse where the holds admit it). Deferral remains only for +a changed declared length or alignment and for an undrawn widget. The +`given_px != offered_px` branch and `offer_len` are deleted, not disabled. + +#### Steps, each with its check + +Work on a branch from `34cafb6` in `/home/bob/repos/iris-layout-experiment` +(its `target` is its own; the baseline's is `target-own`). The checks are +`cargo test --workspace` in debug, `cargo test --release --test generated` +(fast oracle), and after steps 4, 6 and 7 the long ones: + +```sh +SHRINK_CASE=all SHRINK_SEEDS=400 SHRINK_DEPTH=5 cargo test --release --test shrink -- --ignored --nocapture +IRIS_GENERATED_SEEDS=1000 IRIS_GENERATED_DEPTH=6 cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees +``` + +1. **`Place` and `widget_at(child, frame, [Place; 2])`**, with `widget` as + the shorthand, replacing `widget_at`/`widget_within`/`widget`/ + `measure_len`. Internally keep `draw_inner` but make `DrawInfo` carry + `frame` and `place`; delete `DrawRegion`, `ExtentPlacement`, + `reads_placement`, `region()`, `placement()`, `box_of`. Add + `extent_len()`, rename `region_px_len`/`region_holds` to `frame_*`. + Check: it compiles with the call sites moved in step 2; no test yet. +2. **Call sites**: `Span` as above (without the known-length shortcut yet), + `Stack`, `Scroll`, `Pad` (as an inset by pixels, unchanged behaviour), + `Masked`, `Branch` in `random.rs`, `Text` (`glyphs` in extent + coordinates), the examples. Check: suite and fast oracle. Expected to + pass with `Span` reading `extent_len` on both axes and pinning it. +3. **Re-place every child on an extent move**, generalising `reposition`; + delete `extent_children`. Check: suite, fast oracle, + `retained::a_span_ruled_across_itself_moves_its_child_without_redrawing_it` + and a new test: a row whose first child grows by a pixel rule moves the + two after it without drawing them (count draws with the `Counted` widget + in `tests/cases/retained.rs`), once with a `px` second child and once + with a `rel` one. +4. **The symbolic-length pin replaces the placement pin**; `LayoutHolds` + as above. Check: suite, fast oracle, shrinker at 400/5, and the rig's + `many` at seed 13, depth 8 (`IRIS_SEED=13 IRIS_DEPTH=8 IRIS_PHASE=many`): + "reuse outside: the placement it was pinned to" is gone as a counter and + distinct widgets a frame should already be well under `34cafb6`'s 508. +5. **Lazy `Within`** with the end-of-draw pass. Check: suite; + `retained::a_span_does_not_place_its_measurement_before_assigning_the_childs_slot` + still counts three draws. +6. **`redraw` without the deferral**, as above; delete `offer_len` and the + `given_px != offered_px` branch. Check: suite, fast oracle, shrinker, + oracle at 1000/6 -- this is the step `wip/local-reask` failed at seeds + 532 and 398, and both must pass now because the frame no longer changes + under the widget. If either still fails, shrink it and report; do not + restore the deferral. Then the rig: `many` at seeds 1 and 13, depth 8, + against `e44dea3` in `/home/bob/repos/iris-layout-baseline` + (`CARGO_TARGET_DIR=$PWD/target-own`). Expect distinct widgets near + `e44dea3`'s 95 and 159 and no deferral at all. +7. **Span's known-length shortcut and the cross-axis report** as written. + New tests: a `px` child after a `px` child is drawn once on a cold + layout and never on repaint; nested rows two deep with `rel(0.5)` give + half the *root* (or half a declared `.width(rel(0.5))` ancestor) wherever + the child sits; a row with a `rel(0.5)`-tall child reports `rel(0.5)` + across. Check: everything, including the long runs and the five + reference renders (`view`, `minimal`, `random`, `tabs`, `text`) against + `34cafb6` -- expect `random` and `tabs` to move where nested spans or + span heights change meaning, and look at them rather than diffing. +8. **Prove `Inset`/`Outset` are easy**: write them as test widgets in + `tests/cases/layout.rs` with `px`, `rel` and `leftover` margins, a dozen + lines each, and assert the child's box; do not add them to the crate. +9. Measure all six phases against `e44dea3` with the uninstrumented rig, + record the table here, and update the "Retained-layout invariants" list: + delete the bullets about `reports_of`, `decided`, `dirty_size_under`, + the placement pin and the offer-length deferral, and add the frame rule. + +Expected against `e44dea3` at seed 1 and 13, depth 8: `size` and `scroll` +keep their wins; `many` within a few tens of percent, from the container +body running at the offer placement and at the placed one; `resize` at or +below its 13 draws. A `many` result above 2x is a sign something above was +not done as written, not a reason for a new mechanism. + +**Widen one fuzzer axis at a time, and record which.** Seeds **1121** and +**1839** at **depth 4** failed on `ea6dbae` and on every commit before it, +and nothing in the routine verification reached them: the fast oracle takes +ten seeds, the shrinker 400 at depth 5 and the long oracle 1000 at depth 6, +so a defect past seed 400 at depth 4 had nowhere to show. Both are fixed by +`4bd8607`. The scan that found them, worth running again after any change +to layout: + +```rust +// tests/scan.rs, deleted once it had done its job +over_seeds((1..=2000).collect(), |seed| { + let grown = plan(seed, 4, &Edits::default()); + for &case in ALL.iter() { + if let Some(how) = diverges(&grown, case, seed) { + println!("HIT seed {seed} case {} {how}", case.name()); + } + } +}); +``` + +261 s for 2000 seeds at depth 4 over all fifteen cases, and clean at +`4bd8607`. `Rng::new` is `seed | 1`, so an even seed and the odd one above +it are one tree: 1120 and 1121 are the same counterexample, as are 1838 and +1839. + +**Two ideas outrank everything else in this document** (Bryan, 2026-09-17). +First, a changed tree lays out exactly as if it had been drawn that way from +the start; that is what the retained machinery is for and what the oracle +and shrinker check. Second, widgets are predictable: `px` is that many +pixels, `rel(0.5)` is half of the containing widget's area however many +siblings there are and wherever it sits among them, and `leftover` is a +share of the room left once every sibling's `px` and `rel` are resolved. The +invariants listed further down were accumulated by agents chasing single +failures; any of them may be simplified or deleted if those two ideas still +hold. + ## Review of 2026-09-17 A fresh read of `core/src/fixed.rs`, `orientation/`, `ui/holds.rs`,