diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 14f8939..c9023b3 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -10,7 +10,7 @@ stayed. Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18 `split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`, head -**`c8beca5`**, pushed. It holds LAYOUT.md §2's position chain, `leftover`, +**`a92c6ac`**, pushed. It holds LAYOUT.md §2's position chain, `leftover`, the `Holds` retained-layout contract, region nodes, built-in alignment and size rules, fixed-point layout, a box in pixels threaded down the draw, and a report read as a fraction of the containing widget. No PR review was @@ -84,6 +84,14 @@ grid width fixes it; where it becomes visible is the shader's snap. ### A report is a fraction of the containing widget (landed, `ffd79f3`) +**Superseded in part.** Bryan refined the rule twice on 2026-09-17: a +fraction means whatever the *parent* says it means, and it need not even +mean the same thing for two siblings -- "Main point is truly that rel is +decided by parent". The span conclusion below is unchanged and the reason +for it stands (a span's offer differs per child and the row does not), but +`reports_of` is the wrong shape for saying it; see the parked padding +branch. + `rel(0.5)` is half the span whatever else is in it and wherever the child sits. A report used to come back composed through the box it was offered, and a span offers each child the room from its cursor, so a nested span @@ -173,6 +181,82 @@ comes back as a box has to be rounded away from the measurement, not to the nearest step.** Rounding to nearest is right for a value being carried; it is wrong for a bound. +### A frame settles strictly bottom-up (landed, `a92c6ac`) + +The queue was already deepest-first, but a widget that could not settle +where it was called `redraw` on its parent from inside itself, which drew a +shallow widget while dirty widgets deeper in other subtrees were still +pending. A parent drawing over a subtree that has not settled reads answers +about to move, and the one that settles does so inside the parent's draw -- +where its mark comes off and nothing compares what it now answers. + +A widget that cannot settle defers instead: it marks its parent, stays +marked, and waits in `deferred` until the walk reaches the parent's depth, +which cannot happen before everything deeper has settled. + +```rust +loop { + let next = rsc.widgets().needs_redraw.iter().copied() + .filter(|id| !self.deferred.contains(id)) + .max_by_key(|&id| self.depth(id)); + let Some(id) = next else { break }; + if !self.redraw(id, rsc) { + self.deferred.insert(id); + } +} +``` + +Bryan's, 2026-09-17, and the right answer where `0e0d4af` was a check: +"then that entire category of issue can't even occur". `dirty_size_under` +stays in `draw_inner` anyway, because `update` draws the root for a resize +*before* `redraw_updates` runs at all and the ordering does not cover that +entry. Whether it is still load-bearing is untested -- the experiment that +said it was not had a failed edit in it and measured nothing. + +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. + +### Two branches parked, both real, neither ready + +**`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 +box. Half a row becomes a quarter. Pixels are idempotent under a second +application, so only a share ever shrank -- and warm and cold shrink alike, +so **no oracle can see it**. Bryan: "I believe this exact thing has come up +multiple times for some reason." + +The fix is that every child gets the whole box, plus `widget_at` not +resolving a rule into a box already chosen from it. `Painter::box_of` is +deleted rather than guarded: a first attempt made it answer the whole box on +a `decided` axis, which fails because `place` can reuse the stack's +*measuring* drawing by remapping it, so `Stack::draw` never re-runs. **A +drawing has to be a function of its box alone** -- if `decided` is in it, a +reused drawing is wrong. + +What stops it landing: seed 1091 at depth 4, `shuffle-swap-for-three`, +disagrees by three steps where the oracle tolerates two (warm 1053.9971 +against cold 1054). `box_of` was also making placement *exact*, by handing a +child a box of exactly the length it asked for, and the whole box puts a +rounding back at each nesting level. Find that composition; do not widen +`AGREE_STEPS`. + +**`wip/padding-outset-and-inset`.** Padding goes outside what it pads and +never insets the child (Bryan, 2026-09-17): otherwise a child's `rel` and +`leftover` would mean the inner box while its `px` meant the outer one. +`Padding::region` moves the child's box in rather than shrinking it, a new +`Inset` widget with `.inset()` is the old behaviour, and `Pad` is to be +renamed **`Outset`** with `.outset()` to match. `in_parent_frame`'s +composition and the `reports_of` argument go with it -- a report comes up +raw and the parent says what it is a fraction of, which `Inset` does for +itself. + +What stops it landing: a child declaring `rel(0.5)` under an `Inset` comes +out 47.5 px wide of the 190 inside rather than 95, and the second halving is +unaccounted for. The `Pad` half is green on its own; three tests moved to +`.inset()` because they were using padding as scaffolding rather than +testing it. + ### `Span`'s leftover boundary is a third expression for the room The decision uses a rounded division, `total.px.div(fixed)`, while the room @@ -477,7 +561,7 @@ above for what closing the rest would cost. ## Verification at the current head -At `c8beca5`: +At `a92c6ac`: - `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`: green, 90 suite tests, 19 core @@ -497,7 +581,9 @@ At `c8beca5`: width of the widest of them. - Twenty-five rig work counters identical on `cold`, `repaint`, `scroll`, `resize` and `size` across `0e0d4af`; `many` differs only in reuse - attempts. + attempts. Across `a92c6ac` drawn widgets, widget draws and primitive + writes are identical on every phase, and only the scheduling counters + move. **None of that reaches seeds 1121 and 1839 at depth 4**, which fail on this head and on `ea6dbae` alike. Everything below is verification of what was @@ -668,15 +754,23 @@ The replay used for the reference check: In order, from the review above and Bryan's steer (2026-09-17): -1. **A fast test for `0e0d4af`**, the stale-answer guard, which went in +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. -2. Write `ActiveData::answer` in one place. -3. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the + 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 reconstruction in `redraw`. -4. `Span`'s leftover boundary through `Holds::through`. -5. **Round on the CPU and snap to the nearest pixel in the shader**, as +6. `Span`'s leftover boundary through `Holds::through`. +7. **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 @@ -692,20 +786,20 @@ 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. -6. The smaller items: the stale `f32` comment, the gap of an undrawn child, +8. 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. -7. `LazySpan`, the next LAYOUT.md §2 item. Region nodes cover the movable +9. `LazySpan`, the next LAYOUT.md §2 item. Region nodes cover the movable subtree case; do not restore a separate child-placement API. -8. `SizeRule::{Min, Max, Clamp}`, restoring the `max_width`/`max_height` +10. `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. -9. `Scroll` taking a direction rather than one axis. -10. The measure/draw split, once the above is in. It deletes the two-ask +11. `Scroll` taking a direction rather than one axis. +12. 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