From ecce4448f0876bda0887b11bcfcba4fefc961915 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Mon, 14 Sep 2026 17:49:42 -0400 Subject: [PATCH] Document per-axis retained layout --- docs/IRIS_EXTRACTION_HANDOFF.md | 35 ++++++++++++++++++++++++++++----- docs/LAYOUT.md | 22 ++++++++++++--------- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/docs/IRIS_EXTRACTION_HANDOFF.md b/docs/IRIS_EXTRACTION_HANDOFF.md index 42a374d..fbc185b 100644 --- a/docs/IRIS_EXTRACTION_HANDOFF.md +++ b/docs/IRIS_EXTRACTION_HANDOFF.md @@ -9,7 +9,7 @@ Canonical `main` is **`ca2b4b2`** (#17, the headless rig). Sixteen slices are in. **#18 `split/18-position-chain`** is open and finished apart from one decision: -worktree `/home/bob/repos/iris-pr18`, head `82fa6c1`, seventeen commits, +worktree `/home/bob/repos/iris-pr18`, head `b1b3eca`, eighteen commits, workspace tests passing, fmt and clippy clean. It is LAYOUT.md ยง2's position chain, generalised to boxes. `/home/bob/repos/ai-app-2` is on `rustify`, worktree clean. @@ -217,9 +217,24 @@ hottest texts draw 32 times. Instrumented layout is about 5.5 ms instead of 60.79B instructions total (60.8M/frame), and 23.85B cycles. This is about 58% fewer instructions than the roughly 138M/frame #18 path before coalescing, but still about 1.8x the old 3.14 ms resize median. The remaining multiplier -is within one constraint traversal, so per-axis retained size validity is now -the useful next question. Re-sending the existing output size also no longer -starts a resize. +is within one constraint traversal. Re-sending the existing output size also +no longer starts a resize. + +`b1b3eca` records pixel reads and retained size inputs per axis. `Text` says it +reads only its offered width, while `Scroll` says it reads only its scrolling +axis. `Span`, `Aligned`, and `Scroll` ask for an exact hint or a retained child +length valid under the box they are about to offer, and draw to measure only +when neither exists. On the same depth-8 load, resize now averages 449 widget +draws, 296 placement calls, 485 drawn-size reads, 12,164 primitive writes, and +107 text shapes. Cached uninstrumented 1,000-frame runs measured 4.95โ€“5.24 ms +medians, 55.4B instructions total (55.4M/frame), and 21.5โ€“21.6B cycles: about +9% fewer instructions than `82fa6c1`, but still about 1.6x the old 3.14 ms +median. + +Pixel comparisons now use a 0.05 physical-pixel tolerance. The comparison is +against the last actual layout, not the preceding resize event or retained +move, so repeated subpixel changes accumulate and eventually redraw. The +generated cold-layout oracle uses the same visual tolerance per coordinate. The generated tree now includes `Aligned` with every meaningful per-axis alignment. That exposed two retained-layout ordering bugs which `84f589e` @@ -227,7 +242,9 @@ fixes. The regular cold-layout equivalence suite and the ignored 100-seed sweep pass. The latter previously stopped at seed 60 on `140.0` versus `139.99996948242188`: exactly two `f32` ULPs from an equivalent composition order, not a visible layout difference. The oracle now keeps draw presence -exact and allows at most four ULPs per pixel coordinate. +exact and allows at most 0.05 physical pixels per coordinate. Seed 98 is also +part of the ordinary suite because it catches stale retained size beneath an +aligned size-changing chain. The branches are invariants rather than widget exceptions: @@ -255,6 +272,14 @@ stale-size failure. Re-measuring the scroll subtree costs 11 draws rather than 1, but remains two orders of magnitude below the old 1,313 and follows the actual dependency invariant. +A frame-local cache of several size answers under different constraint boxes +was also discarded. It reached about 3.5 ms in the instrumented resize rig, +but seed 98 showed that a valid answer is not enough: the child's one active +drawing may still realize another constraint, and a parent can then lay out +from an answer its final child placement does not realize. A correct version +needs an answer-plus-realization or verification protocol; adding dirtiness +conditions to the cache does not fix that invariant. + Do not restore the archive's old `DrawMode::Measure`. It skipped primitive and retained-state writes while still walking widgets and shaping text, and once improved a streamed-frame benchmark from 1.39 to 1.22 ms p50. Retained diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index 148760d..6e42f6d 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -106,8 +106,11 @@ stands rather than new machinery: and the primitive's stored `rel`/`abs` pair every frame, already, on the GPU. A widget laid out purely in `rel`/`abs` terms is therefore already correct after a resize with zero CPU work. Calls to `Painter::px_size` and -`Painter::output_size` mark the active widget as reading concrete pixels; -only those widgets become dirty when the output actually changes. +`Painter::output_size` mark both concrete-pixel axes; `px_len(axis)` and +`output_len(axis)` mark only the axis actually read. Only widgets whose read +axes changed by more than 0.05 physical pixels become dirty. The comparison +is against each widget's last actual draw, so smaller changes accumulate +rather than disappearing event by event. All pixel-dependent leaves are marked before layout begins, along with every chain of parents that read their sizes. Resize then settles the shallowest @@ -145,18 +148,19 @@ An exact `size_hint` stops propagation when both axes still equal the retained size. Otherwise propagation is deliberately conservative: the child may have changed size, and only its dependent ancestors can assign the final boxes. Unchanged descendants still take `draw_inner`'s retained skip-or-move path. -For a stacking container, retained child lengths are cached per axis: a child's -width remains reusable while the parent changes width, and its height remains -reusable while the parent changes height. A change on the orthogonal axis does -invalidate it in both directions. This is a generic constraint rule, not a -text exception; wrapped text is merely the common example of height depending -on width. +An active widget also retains which offered-box and output axes flowed into +the size it reported, directly or through a child size it read. A container +may use that answer for the same prospective box when every observed input is +still within 0.05 physical pixels; content dirtiness anywhere in its size +dependency subtree rejects the answer. This is a generic constraint rule, not +a text exception. Wrapped text is merely the common example: it reads width, +so changing only height leaves its answer valid. ### 4. Wrapped text, and "needs child height before choosing width" **Wrapped text is not a special case any more; it already reads as one draw.** `TextView::render` (`iris/src/widget/text/mod.rs:57-76`) already -does exactly what single-draw asks for: it reads `ctx.px_size().x` as the +does exactly what single-draw asks for: it reads `ctx.px_len(Axis::X)` as the wrap width, shapes once, and memoizes the shaped layout keyed on that width plus a changed-flag on the buffer and attrs (`:63-69`) โ€” a second call with the same width is a hash-map-style cache hit, not a re-shape. Under the new