diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index b65593e..6f3faed 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -27,6 +27,317 @@ traced on 2026-09-17 and is not what the earlier sections say; read **Where the `many` gap actually comes from** before anything else in this document about performance, and take the earlier sections' explanations as history. +**The current work is the plan in the next section**, decided with Bryan on +2026-09-18. It is what the next agent does, on top of `34cafb6`, and it +supersedes the "Frame and extent" sections' description of `Span`, `Pad`, +`Stack`, the placement pin and `measure_len` wherever they differ. + +## Plan: transparent frames (2026-09-18) + +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. + +### Decided by Bryan + +- **Containers are transparent.** The frame a widget's fractions are of is + forwarded from its parent through a span, a stack and a scroll unchanged, + and through an inset narrowed by its margins. Any number of nested spans + lay out against one frame. The reason: `px` already passes through, `rel` + should behave the same way, and `leftover` is already the way to say + "fill the containing widget", so `rel(1.0)` meaning that too would be two + ways to say one thing. +- A frame is narrowed only by what is decided from above: a declared length + on the widget (`.width(rel(0.5))` on a span narrows its children's frame + too), an inset's margins, the root. A box that is an *answer* (a row's + height, a stack sized by a child) is never anything's frame. +- `rel` overflows on purpose when it sums past one or has pixels beside it. +- `Inset` and `Outset` take `px`, `rel` and `leftover` margins. Outset adds + its margins to the child's report and moves the child in; Inset draws the + child in a frame with the margins subtracted and reports the child's size + plus what it subtracted. An inset resolves what it subtracts, so `px` and + `rel` margins narrow the frame symbolically and a `leftover` margin is + resolved in pixels from the extent after the child answers, like a span's + shares. A general `Pad` would outset pixels and inset `rel` and + `leftover`. **Do not build these yet**; make them a few lines each to + write. +- Along its own axis a span places a child whose length is known in pixels + at its final slot while measuring, so it is drawn once, and moves a child + that is in the wrong place, `px` or `rel`, by translation rather than + drawing it again. Across itself it may still re-place by the answer. +- Layers may later be tied to another widget (a popup near an anchor). A + position is never defined by two widgets; positions compose up the tree. + +### 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 `leftover` if any child does; otherwise the +longest child in *frame pixels* among `px` and `rel` children, reporting +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. + +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 @@ -1229,14 +1540,11 @@ In order, from the review above and Bryan's steer (2026-09-17): `a0693ac` and `e44dea3`; see the two sections above. The re-parenting half turned out to be two defects rather than the predicted one, and neither was the `depth()` assertion. -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. +2. **Transparent frames**, the plan near the top of this document. This is + the fundamental change and comes before anything built on the placing + ask; it subsumes the parked `Inset`/`Outset` and stack-fraction branches + (their tests land with step 7 and 8) and the `wip/local-reask` branch, + which is superseded and should be deleted once step 6 passes. 3. Write `ActiveData::answer` in one place. 4. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the reconstruction in `redraw`.