diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index cb9034d..94ce4fe 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -424,147 +424,91 @@ back. - `Fixed::div` by zero answers `MIN`/`MAX` while `ratio` answers `ZERO`; both are caller bugs under `debug_assert`, but the fallbacks differ. -### Frame and extent +### Frame and extent: retained prototype -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 original proposal was implemented by Claude as `5fcace1` on +`wip/region-and-placement` in `/home/bob/repos/iris-pr18`. It kept the fraction +reference stable but failed five layout cases and three draw-count cases. +The correction is saved as `efb416b`, pushed to origin's +`wip/region-and-placement`, in the isolated checkout +`/home/bob/repos/iris-layout-experiment`; **it is not on PR #18**. The original +checkout is unchanged. Keep the experiment's build directory separate: Cargo +can accept a different worktree's artifacts as fresh when a target directory +is shared. The comparison checkout's source timestamps can precede its build. -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: +**The distinction stays.** `Painter::region` is the frame, the parent's chosen +reference for fractions. `placement` is the extent in that frame. A sizing +report must not replace the reference against which that report was obtained. +`px_len` reads the extent's pixel length; `region_px_len` reads the frame's. +Text wraps at the available extent, not the whole fraction reference. -```rust -// painter.rs, widget_at -let within = match local == UiRegion::FULL { - true => self.region, - false => local.within(&self.region), -}; -``` +**The offer includes availability.** A span can keep the same reference frame +while offering a text less room after an earlier sibling. `known_len` therefore +accepts the placement as well as the frame. The first measurement's +`offer_placement` survives later placing evaluations, and local redraw asks +that original question before restoring the assigned slot. Frame-length +equality alone no longer identifies the measurement question. -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. +**An answer and a drawing each retain their dependencies.** `LayoutHolds` +keeps frame ranges, extent ranges, and an optional raw placement snapshot. +`px_len` narrows one extent axis without making position or the other axis a +dependency; `holds` widens that extent range without erasing a frame read. +`Holds::through` still inverts one fixed mapping exactly. A measured answer +and the final drawing can only substitute for one another when those +independent inputs satisfy the retained contract. The old fallback checked +only frame ranges, so text placed at a fixed pixel width answered a narrower +window's measurement with its previous line breaks. -**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: +**Geometry retains its reference.** `DrawRegion::{Frame, Extent}` records local +primitive and mask coordinates before composition. Ordinary `primitive()` +follows the extent; explicit `UiRegion` arguments remain frame-relative. +Text glyphs use extent-relative origins. `Painter::widget()` records a child +that inherits placement, so a pass-through wrapper and its valid descendants +can be repositioned without rerunning their draw bodies. `placement()` is +still a conservative raw read for arbitrary widget computations. -```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, -} +**No measurement is different from a measured zero.** The first wider scan +found seeds 560 (`shuffle-swap-for-three`) and 1690 (`shuffle-add-three`) at +depth 4. A previously undrawn pure share had only supplied a hint; its +placeholder zero answer became reusable once it was drawn during placement. +`ActiveData::answer` is now optional. Both seeds pass after that correction, +and `adding_text_to_a_reverse_row_keeps_its_shared_height` reproduces the +zero-height failure with a small tree. -impl Painter<'_> { - pub fn extent(&mut self) -> UiRegion { - self.reads_extent = true; - self.extent - } -} -``` +Ordinary verification currently passes: formatting, workspace clippy with +`layout-diagnostics`, all workspace tests (96 suite tests), and the fast +generated cases. New regressions also inspect primitive coordinates for mixed +frame/extent drawings, including a region node, and a content change after +moving an inherited extent. A `tabs` resize from 1920x1200 to 900x1200 matches +a cold 900x1200 screenshot with zero differing pixels. The release oracle +passes 2000 seeds at depth 4 and 1000 seeds at depth 6 over all fifteen cases. +The shrinker passes 400 seeds at depth 5, and the debug oracle passes 120 +seeds at depth 4 with assertions enabled. -`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: +**Do not land this as a performance improvement.** The old stretching and +wrapper reuse tests pass, and one numeric-size test improves from three draws +to two. Cold work also improves against the broken `5fcace1` prototype: 604 to +535 widget draws and 15,296 to 10,384 primitive writes on seed 1, depth 8. +But against the last working `e44dea3`, the same rig does substantially more +nested layout work: -```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); -``` +| phase | widget draws before | prototype | primitive writes before | prototype | +| --- | ---: | ---: | ---: | ---: | +| cold | 369 | 535 | 9635 | 10384 | +| repaint | 1 | 1 | 1 | 1 | +| many | 157 | 310 | 4721 | 6970 | +| size | 16 | 275 | 106 | 5980 | +| scroll | 2 | 2 | 0 | 0 | +| resize | 13 | 328.5 | 0 | 6721.5 | -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. +These are instrumented work counts, not elapsed-time speedups (50 frames per +phase except cold, which is one; 578 generated widgets, 191 finally active). +Raw placement reads in containers and repeated offer/placement evaluations +remain conservative. Retaining local coordinates also costs memory per +primitive. The experiment demonstrates selective reuse with one `Widget::draw` +body, not a finished performance replacement. Optimize the container protocol +under the same correctness tests before adopting it. The parked `Inset`/`Outset` +API changes are not included in this experiment. ## How layout is decided diff --git a/docs/PLAN.md b/docs/PLAN.md index 1ee49be..e768645 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -864,6 +864,17 @@ such as corner radius and text's position within horizontal overflow use `Len`. Flexible padding participates in the same proportional allocation as a span rather than silently discarding its `rest` component. +**The frame/extent experiment keeps measurement and geometry references separate** +(2026-09-17, prototype only; verification and costs in `HANDOFF.md`). The parent +chooses the reference for fractions; placing a widget's reported size does not +replace that reference. A measurement also records the available extent, so +reusing it checks both inputs. Retained primitive and mask coordinates name +whether they follow the frame or the extent. Numeric extent reads constrain +layout validity; merely drawing in an extent does not require another widget +evaluation when it moves. `Widget::draw` remains the only layout body. The +prototype has significant container-update costs and is not yet adopted into +the pinned framework. + **A `Span` leaves its children in the offered orthogonal region unless explicitly compacted** (2026-09-12). It still reports the widest orthogonal child as its intrinsic size, so a row nested in a column keeps its content