Three attempts at "draw the widget, then move its drawing to where its alignment says" failed, and the notes are worth more than the attempts: the move is a change of coordinate frame, and no split of the stored state carries it. Moving a widget's region with its drawing has a later local redraw ask a differently rounded question, which crosses the one boundary `Span` needs exact. Leaving the region alone lets the placed box accumulate, because `try_reuse` returns a clean subtree's size without walking into it. Both are measured. Also recorded: that a container reporting a child's size while handing it a bigger box places that content twice, which is what the align override is for; that a widget must not report more than it occupies, which is why `Scroll` now fills both axes; and Bryan's decision that alignment is one fraction per axis rather than four directions, with the middle as the default because the edges assume a direction. The alignment work is in the working tree at `8220a78` and not committed. Its one red case, and the four things queued behind it, are listed.
19 KiB
Handoff
Where the work in flight stands for a session picking it up cold. Keep current invariants, measurements, and failed hypotheses here; this is not a decisions log.
Where things stand
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 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.
The current head completes LAYOUT.md §2's position chain and the requested
leftover behavior. A child whose length is only leftover is not drawn when
nothing is left. A child that also asks for pixels or a relative fraction keeps
that part and overflows as before.
29c7881 replaces the parallel resize rules with one retained-layout contract,
Holds: the interval of box lengths for which a widget's drawing and reported
size stay valid. Reading Painter::px_len or px_size narrows the interval to
the length read; Painter::holds lets the widget widen it. Parent validity is
the intersection of the ranges its children induce. This contract is trusted.
A widget that declares an incorrect range is a defective widget; Iris does not
add defensive work to recover optimizations from a false declaration.
f437495 restores explicit Span::ortho(OrthoSize::{Children, Full}) sizing.
Children remains the default and preserves the old conservative behavior:
the largest fixed orthogonal length is reported, while any relative or
leftover child makes the span report leftover. Full reports exactly
Len::rel(1.0). It does not read the children's orthogonal sizes, but their
Holds ranges still propagate through final-box drawing, so a resize
repositions them without redrawing when their own contracts permit it.
71c9c39 replaces the public Painter::place distinction with an opt-in
widget property. .region_node() gives a widget one independently movable
retained region; Widgets::set_region_node can change that choice at runtime
and causes one structural redraw. Widgets without the property remain at the
default shallow chain depth: Iris recursively remaps their retained primitive
and mask regions when they move. .scrollable() enables a region node on its
content once as its convenient default; raw Scroll::new respects the
caller's choice, and the property can be disabled later without breaking
scrolling. Span and Align do not add nodes to their children.
Do not change Children to select the pixel-longest arbitrary Len at the
span's current width. A fixed child and a relative child can create multiple
self-sizing fixed points; generated seed 13 settled differently warm and cold
under that attempted implementation. A Holds interval says where an already
chosen answer stays valid, but cannot make that circular choice unique.
The implementation also fixes three counterexamples found while finishing the rewrite:
- An asked-but-undrawn size dependency must name the widget that asked as its parent. Using the asker's parent skipped a reader and made generated seed 10 settle differently warm and cold.
Scrollmust return the answer from the first box it asked about, whether that answer came from a retained length or a fresh measurement. Returning the final placed answer only on the retained path advanced one fixed-point iteration and broke seed 86.- A widget retains the layer it was entered on, not the last child layer its painter visited. The old value drifted on local redraw and put a redrawn tab background above its retained text.
Span's leftover/no-leftover split is a strict layout decision, not a rounding tolerance. ItsHoldsrange must use the same exact divided boundary as drawing; a tolerant endpoint retained zero-height children at the boundary in generated seed 16.
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 8220a78
cargo fmt --all --checkcargo clippy --workspace --all-targets --all-features -- -D warningscargo test --workspace --all-features: 85 passed, 10 ignoredcargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees --exact: 100 seeds passed in 67.6 sminimal,textandviewrender byte-identical at 1920x1200 across8220a78.tabsdiffers 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-cmpatca2b4b2covered:tabs,view,minimal, andtextat 1920x1200;tabscold at 900x1200; live resize from 1920x1200 to 900x1200; and the tab interaction before and after replay. Every comparison had zero differing pixels. The live-resize image is also byte-identical to the cold 900x1200 image.
The final pre-submit review was run in four passes. The region-node review
caught an index-reuse hazard when removing a node; its move entry now remains
alive until every descendant has migrated. The generated oracle then exposed
the exact Span threshold described above. The earlier retained-layout
review caught the layer defect above, corrected validity-range inversion for
negative relative extents, and removed an impossible-state unwrap from
Scroll. The orthogonal-sizing review caught both the circular longest-child
choice and the incompatible visual effect of making Full the default.
Performance
The retained rewrite was compared with #18's previous head 691e3eb using the
same release layout_diagnostics fixture, seed 1, depth 8, 500 frames, 130
dirty widgets:
| phase | 691e3eb |
29c7881 |
|---|---|---|
| many | 25.17M instructions/frame | 6.14M |
| resize | 16.08M | 8.39M |
| scroll | 0.720M | 0.691M |
| repaint | 0.720M | 0.689M |
| size | 0.730M | 0.698M |
The final 29c7881 measurements were 3,070,693,965 instructions for 500
many frames and 4,192,804,214 for 500 resize frames. Re-run before quoting
tighter figures.
Retained-layout invariants
- A region node holds a whole
UiRegionin its parent node's coordinates.UiRegion::FULLis the identity. Widgets opt in with.region_node()orWidgets::set_region_node; ordinary widgets share the nearest ancestor node. Region nodes therefore add chain depth only where moving a whole subtree through one entry is useful. - A widget's
ActiveData::regionis its box in its parent node. A region-node widget draws inFULL; its box lives in its node. Moving an ordinary retained subtree instead remaps its primitive, mask, and active regions. Remapping stops at a descendant region node after rewriting that one entry. - Changing
region_noderedraws the subtree once to rebuild the coordinate boundary. The property belongs to widget identity, which is safe because a widget has one parent..scrollable()sets it once; rawScroll::newdoes not, andScrollnever reasserts it while drawing. - The first box a parent asks about is the offer. A later box chosen from the child's answer is the final box, not another independent answer. Dirty widgets are re-asked at the offer and only then drawn in the final box.
- A retained drawing can be reused only when its
Holdsinterval contains the new pixel box on both axes, its parent node is unchanged, its region-node choice matches the retained structure, and the widget is clean. A valid ordinary subtree may move without redrawing because its regions are recursively remapped. Painterrecords size-dependency edges only when a parent reads a child's size or hint. An undrawn measured child remains recorded so a later change reaches the parent that decided whether to draw it.- Dirty widgets settle deepest-first.
dirty_size_underprevents a reader from taking a retained answer while something below that answer is still dirty; the walk is an optimization against laying out twice, not a second validity mechanism. - Declared non-
leftoverlengths are resolved by the widget's parent where the widget is drawn. A declared-length change therefore redraws the parent. - Pixel comparison uses a 0.05 physical-pixel tolerance, against the last actual layout. Repeated subpixel changes accumulate and eventually redraw.
- Text shaping is retained separately from line breaking. A greedy line break
remains valid from its longest produced line through the width at which it
was made, and
TextViewreports that interval throughPainter::holds. Span's decision to distributeleftoveris a pixel question. Its validity interval is split exactly at the length where fixed parts fill the box; pureleftoverchildren are undrawn on the no-space side. Exact/open ranges are for hard widget decisions; the ordinary tolerant ranges remain for accumulated coordinate rounding.Scrollreports its content's first measured size. Its drawing can survive container-length changes only over the interval in which clamping and its current offset do not change.- A move must re-express each part as the same fraction of its new box, not
add an offset to the last answer. A subtree's stored regions are the only
record of where it is, so an offset integrates its own rounding and nothing
recomputes it. Measured 2026-09-15 on
tests/drift.rs: offsetting both ends of a span shortens that fixture's row by 0.071 over 20,000 moves and 0.712 over 200,000, growing with the count; placing the far end from the near one leaves 0.069, because the length is re-derived from the endpoints either way. 20,000 moves is five minutes of scrolling at 60Hz, which is when it passes the 0.05 physical pixels layout treats as the same place. The fraction path is exact at 200,000. SoRegionRemapis load-bearing for accuracy rather than for generality, and its multiplies are not what is being paid for. The generated oracle compares within 0.05 andunsettled.rsruns too few frames to reach it, which is whytests/drift.rsexists.
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::regionwith 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 as305.93604, which crossesSpan's leftover/no-leftover boundary -- the one that must be exact -- and draws a child a cold layout leaves undrawn. - Leave
regionalone, andplacedaccumulates without bound, becausetry_reusereturns a clean subtree's size without walking into it: only the top widget'splacedis 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<RegionAlign> 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:
cd /home/bob/repos/iris-pr18
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
Run the long generated oracle only after ordinary tests pass:
cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees
tests/generated.rs compares a warm incremental tree with a cold tree of the
same state. IRIS_GENERATED_SEED, IRIS_GENERATED_SEEDS, and
IRIS_GENERATED_DEPTH select failures. tests/shrink.rs reduces a failing
tree; use it to turn a seed into a readable regression rather than leaving the
seed as the only record.
The headless reference set must be run one process at a time because the rig reuses one compositor. Comparison worktrees need separate target directories. Useful commands:
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/tabs.png
./scripts/run-headless.sh tabs --mode 900x1200@60Hz --shot /tmp/cold.png
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \
--resize 900x1200@60Hz --shot /tmp/resized.png
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \
--replay /tmp/tabs.touch --shot /tmp/replay.png
The replay used for the final check was:
0 down 1728 24
80 up 1728 24
400 down 1836 1116
480 up 1836 1116
800 down 1836 1116
880 up 1836 1116
tests/layout_diagnostics.rs is the retained CPU rig. Select cold, many,
repaint, size, scroll, or resize with IRIS_PHASE; use the feature for
explanatory counters and an uninstrumented release binary under perf for
instruction totals.
Next
The next small LAYOUT.md §2 item is LazySpan. Region nodes now cover the
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 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
repaintcase, 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 whatFulldoes today. That also removes the span's own instance of the compounding above. Scrollshould take a direction rather than one axis: vertical, horizontal, or both. ReportingLEFTOVERon both axes is already the right shape for it.- Restore
max_width/max_heightasSizeRule::{Min, Max, Clamp}.8220a78deletedMaxSizeand its builders, so that is public API owed back. A clamp resolved where the box is decided also fixesMaxSize's reading ofpx_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: itsHoldsrange must be exact and split at the crossover, the way generated seed 16 taught forSpan. - The shrinker's
reordercase, which is red at8220a78and was not introduced by any of this.
Other queued work, in dependency order:
UiRenderStatebehindRc<RefCell<_>>.- Split
Len/layout length and add density-independent pixels. - Input restructuring: pointer capture, drag slop and axis, cancellation, mask-aware hit testing, and timestamps.
- Retained paints, selection, overlays, and shared runtime state.
- Generic desktop/Android hosts and reusable example/APK tooling.
- Application-owned fonts and replaceable glyph-atlas buckets.
- Positioned text overflow and cluster-safe ellipsis.
The archive is a reference, not a patch: it predates returned Size, the
current box chain, and the current length types. Recreate changes on current
types and keep app/session concepts out of Iris.