diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 7cf8459..d6de49e 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -8,7 +8,9 @@ log. Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18 `split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`; its local -head is **`0283c9d`**, forty-eight commits, pushed to the fork. No PR reviews +head is **`8220a78`**, forty-nine commits, pushed to the fork. Built-in +alignment is **in the working tree at that checkout and not committed** -- see +"Built-in alignment, in flight" below before touching it. No PR reviews were present when checked on 2026-09-15; the verification summary is posted on the PR. @@ -70,15 +72,17 @@ rewrite: `core/src/ui/holds.rs`, the retained tests, and the generated cold-layout oracle pin those rules. Seeds 10 and 86 are now in the ordinary generated set. -## Verification at `0283c9d` +## Verification at `8220a78` - `cargo fmt --all --check` - `cargo clippy --workspace --all-targets --all-features -- -D warnings` - `cargo test --workspace --all-features`: 85 passed, 10 ignored - `cargo test --release --test generated -- --ignored - a_long_run_of_seeds_agrees --exact`: 100 seeds passed in 64.77 s -- The `tabs` reference at 900x1200 remained byte-identical to `f437495`: zero - differing pixels out of 1,080,000 after the region-node rewrite. + a_long_run_of_seeds_agrees --exact`: 100 seeds passed in 67.6 s +- `minimal`, `text` and `view` render byte-identical at 1920x1200 across + `8220a78`. `tabs` differs only in the widget count it prints about itself, + which is two wrapper types smaller -- so it is no longer a byte-identical + reference and the generated oracle is the check that matters. - At preceding head `29c7881`, reference renders against `/home/bob/repos/iris-main-cmp` at `ca2b4b2` covered: `tabs`, `view`, `minimal`, and `text` at 1920x1200; `tabs` cold at @@ -172,6 +176,99 @@ tighter figures. being paid for. The generated oracle compares within 0.05 and `unsettled.rs` runs too few frames to reach it, which is why `tests/drift.rs` exists. +## Built-in alignment, in flight + +Uncommitted in `/home/bob/repos/iris-pr18` on top of `8220a78`: 87 tests, fmt +and clippy clean, the 100-seed generated oracle green, and the shrinker's +`resize` case green at 300 seeds. The shrinker's **`repaint` case is red**, for +the reason in "Placement cannot be applied after the fact" below. Its `reorder` +case is red at `8220a78` too, so that one is **pre-existing and unrelated**. + +What is in it: `align` is a widget property beside `region_node` and the size +rule, `Aligned` is deleted, `.align()`/`.center()` set the property, and the +fuzzer covers size rules, alignment and region nodes and changes all three at +runtime. Region nodes had **no generated coverage at all** before that. + +### Alignment is two fractions, not four directions + +Decided 2026-09-15 (Bryan). A widget's alignment is one `f32` per axis, so a +quarter of the way along an axis is expressible. `AxisAlign`'s three cases +become named constants over that number, which is what every expression +already uses: the layout math only ever reads `AxisAlign::rel()`, so nothing +downstream changes shape. The in-flight tree still has the enum. + +The default is **the middle on both axes**, because the two edges are the ones +that assume a direction -- which edge is the near one depends on the writing +system and on which way a container runs. "Near edge" throughout this document +means the *start of the box in the box's own orientation*, which for a +reversed span is its visually far end, not "top left". + +### Placement cannot be applied after the fact + +**Do not re-attempt "draw the widget, then move its drawing to where its +alignment says".** Three attempts failed, and the reason is structural: the +move is a change of coordinate frame, and no consistent split of the stored +state carries it. + +- Move `ActiveData::region` with the drawing, and a later local redraw asks a + differently rounded question. Measured: `(0,0)..(0,305.936)` re-expressed as + `(0.5,-81.5)..(0.5,224.436)` reads its length back as `305.93604`, which + crosses `Span`'s leftover/no-leftover boundary -- the one that must be exact + -- and draws a child a cold layout leaves undrawn. +- Leave `region` alone, and `placed` accumulates without bound, because + `try_reuse` returns a clean subtree's size **without walking into it**: only + the top widget's `placed` is recomputed while an ancestor's shift carries the + whole subtree. Measured 7,048,813 where a cold layout says 456. + +The replacement, not yet written: alignment applies in the two places that +already exist and are exact. Where the size is known before drawing, from a +rule, `declared_box` hands the child its aligned box directly -- one draw, no +move. Where the size is only known after drawing, the widget is **re-asked in +its placed box**, with its alignment forced to the near edge on the second ask +so it terminates; that ask goes through `try_reuse`, which moves by +recomposing, which `tests/drift.rs` pins as exact. That deletes +`ActiveData::placed` and `shift_subtree`. The cost is a second ask for a +measured widget that is not near-aligned, which is exactly what `Aligned` cost +before this work. + +### Placement compounds, which is what the align override is for + +A container that reports a child's size while handing that child a **bigger +box** gets its content placed twice: once by the child, once by the box around +it. Found three times before the class was fixed rather than the instances -- +`Stack::size(Child(i))`, `Scroll`'s orthogonal axis, and `Pad`. + +`Painter::widget_aligned(child, region, align)` is the override: an +`Option` carried in `DrawInfo` and resolved once in `draw_inner`, +so the root resolves like anything else. `Pad`, `Stack` and `Scroll` pass the +near edge for children whose size they report. `ActiveData` keeps both the +resolved alignment, so a local redraw asks the question its parent asked, and +the widget's own, which is what a change is compared against -- an override +means the answer is the parent's to give again. + +### A widget occupies its box, and must not report more than it draws + +`Scroll` reports `Size::LEFTOVER` on **both** axes: it clips its content to its +box, so it can neither take less of one nor honestly ask for more. The +content's length is what it scrolls through, not what it is. Reporting the +content length instead made the framework place a 400-long drawing in a +200-long box, and placement by recomposition **scales** in that case, because a +part stored at fraction 2 of its box stays at fraction 2 of a box twice as +long. Reporting the content's *cross* length had the box around it place +content already placed. + +Where content shorter than the viewport sits is now `Scroll`'s own alignment. +Its `Holds` widening -- "content of a fixed length that fits sits at the start +of any box it fits in" -- is therefore gated on near alignment: anchored +anywhere else it is a part of the room left over, so it moves with every +length the box takes and the drawing holds for that length alone. + +`Stack` gives every child the box its sizing child defines, through the new +`Painter::box_of`, for the same reason. + +A `debug_assert` that a reported non-leftover size does not exceed the box it +drew in would have caught both immediately, and is still worth adding. + ## Rigs and reproduction Ordinary framework verification: @@ -231,44 +328,33 @@ independently movable-subtree use case; do not restore a separate child-placement API. `docs/LAYOUT.md` ยง2 is stale: it still describes `Painter::place`, which `71c9c39` replaced. -**Built-in alignment and size is next, and goes on #18 rather than after it** -(Bryan, 2026-09-15: #18 is unreviewed and already large enough that most lines -get read anyway). Ownership is agreed: - -- Declared size and alignment become **widget properties on the same mechanism - as `region_node`**, read by `Painter::widget_at`. `SetSize`, `MaxSize` and - `Aligned` are deleted, and `.width()`, `.sized()`, `.max_width()`, `.align()` - and `.center()` stop building widgets. `Painter` then owns measure-then-place, - which `Span`, `Aligned` and `Scroll` each implement separately today. -- A **declared length wins per axis**; the `Size` returned by `draw` answers - only the axes with no declaration. The rule becomes an enum per axis -- - `Exact`, `Min`, `Max`, `Clamp` -- so a clamp is resolved where the box is - decided instead of by a wrapper. `MaxSize::draw` currently calls - `px_size()`, which pins its interval to one exact box on both axes and - redraws its whole subtree on any resize; the parent resolving the clamp - reads px itself and can declare the interval over which the answer holds. -- The clamp boundary is a **hard layout decision, not a tolerance**, the same - shape as `Span`'s leftover split. Its `Holds` range must be exact and split - at the crossover; generated seed 16 is what a tolerant endpoint costs. -- Two things to settle while implementing. `max_width` today does not - constrain anything -- `MaxSize` draws its child in its full box and only - caps the `Size` it reports upward -- so resolving the rule in the box is a - behaviour change. And `declared_len` derives from `size_hint`, so one - channel carries both "my size, for a span to divide around" and "resolve my - box to this length"; those separate. - -Why it comes before anything else that touches layout: a widget's `region` -becomes **its reported size placed by its alignment inside the offer**, rather -than the offer itself. A span's measure-then-place then moves a child between -boxes of the same length, so it is a translation by construction, and the -measuring draw disappears wherever the size is knowable without one. That -changes what `.background()` sits behind, so the reference renders stop being -byte-identical to `ca2b4b2` and the generated cold-layout oracle becomes the -verification. +**Built-in alignment and size goes on #18 rather than after it** (Bryan, +2026-09-15: #18 is unreviewed and already large enough that most lines get read +anyway). The size half landed as `8220a78`; the alignment half is in the +working tree and described below. Do not restore `OnResize::Translate`; retained translation is now expressed by the same `Holds` contract and box chain. +Queued from this work, in order: + +- The shrinker's `repaint` case, via the replacement placement design above. +- Delete `OrthoSize`. It is redundant now that size is built in: a span should + read its own rule on the orthogonal axis and, where that is fixed, skip + reading its children's orthogonal sizes entirely, which is what `Full` does + today. That also removes the span's own instance of the compounding above. +- `Scroll` should take a direction rather than one axis: vertical, horizontal, + or both. Reporting `LEFTOVER` on both axes is already the right shape for it. +- Restore `max_width`/`max_height` as `SizeRule::{Min, Max, Clamp}`. `8220a78` + deleted `MaxSize` **and its builders**, so that is public API owed back. A + clamp resolved where the box is decided also fixes `MaxSize`'s reading of + `px_size`, which pinned its interval to one exact box on both axes and + redrew its whole subtree on any resize. The clamp boundary is a hard layout + decision, not a tolerance: its `Holds` range must be exact and split at the + crossover, the way generated seed 16 taught for `Span`. +- The shrinker's `reorder` case, which is red at `8220a78` and was not + introduced by any of this. + Other queued work, in dependency order: - `UiRenderState` behind `Rc>`.