`OrthoSize` was deleted in `9d8415d` and the handoff still described it as restored; the surviving rule is `Painter::ruled`, and the circularity lesson is restated without the enum's names. `HOLDS_EPSILON_PX` went with `39e4ca2`, the clipping `debug_assert` the alignment section asked for landed as `95fb4f9` and is narrower than it asked, the glyph conversion queued under "Next" landed as `11c55bc`, and `LAYOUT.md`'s stale sections are §4, §5 and density rather than §2. Also records why the grid costs 1.5x what floats did, from the disassembly rather than from theory: `RegionRemap::apply_span` is 352 instructions with two 64-bit `idiv`s in it, because a fixed-point rounding is code where a float's is hardware, integer division has no vector form, and saturation breaks the pairing the `f32` `Vec2` had. And the finding that answers the queued clamp item's open half: a `Max` may not take a `leftover`, because a cap must read the report a rule otherwise makes moot, and a share puts the division into the same equation -- the multiple-fixed-point failure seed 13 already punished once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
585 lines
32 KiB
Markdown
585 lines
32 KiB
Markdown
# 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 **`4f5e27c`**, sixty-three commits. Built-in alignment is complete there;
|
|
see "Built-in alignment" below for the retained-layout details. No PR reviews
|
|
were present when checked on 2026-09-15.
|
|
|
|
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.
|
|
|
|
`9d8415d` deletes `OrthoSize`, which `f437495` had restored as an explicit
|
|
`Span::ortho(OrthoSize::{Children, Full})`. It was the rule beside a widget
|
|
written a second time: a span across itself is as long as its longest child
|
|
*unless a rule already says how long it is*, and `Painter::ruled(axis)` is how
|
|
it asks which case it is in. Under a rule it does not read its children across
|
|
that axis at all -- the answer is not wanted, and reading one is what would
|
|
make its size depend on theirs. Not under one, the largest fixed orthogonal
|
|
length is reported and any relative or `leftover` child makes it report
|
|
`leftover`, which is the old conservative behavior. Either way the children's
|
|
`Holds` ranges 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 make a span choose its orthogonal size by comparing children in pixels
|
|
at its 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
|
|
same circularity is what a `leftover` cap would put into `SizeRule::Max` --
|
|
see the clamp item under "Next".
|
|
|
|
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.
|
|
- `Scroll` must 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. Its `Holds` range must use the same exact divided
|
|
boundary as drawing; a tolerant endpoint retained zero-height children at
|
|
the boundary in generated seed 16. `5ed9e87` moved that boundary rather
|
|
than softening it, and `39e4ca2` removed the move: on the grid the box and
|
|
the sum are the same count. See "Fixed point" below.
|
|
|
|
`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 `4f5e27c`
|
|
|
|
- `cargo fmt --all --check`
|
|
- `cargo build --workspace --all-features`
|
|
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`
|
|
- `cargo test --workspace --all-features`: 105 passed, 10 ignored
|
|
- The release generated cold-layout oracle passed 100 seeds in 9.4 s, and a
|
|
shrinker case at 300 seeds in 3.5 s: both run a thread per core but one.
|
|
- The release shrinker passed **all five** cases -- `resize`, `repaint`,
|
|
`resize-repaint`, `reorder`, `size-change` -- at 300 seeds of depth 5:
|
|
26,001 widgets per case, largest tree 331. It passed 1000 seeds of depth 6
|
|
(159,024 widgets per case, largest tree 587) at `5ed9e87`, before fixed
|
|
point; re-run that before quoting it again.
|
|
- `tabs`, `view`, `minimal`, `text` and `random` render byte-identical at
|
|
1920x1200 across the whole fixed-point sequence.
|
|
- `tests/cases/drift.rs` passed its 20,000-move exactness check in release
|
|
mode.
|
|
- The seeded `random` example, live-resized from 1920x1200 to 1280x800, is
|
|
byte-identical to a cold 1280x800 render. Both PNGs hash to
|
|
`1d397c57b9914a2e596fa907029bc6aab4629e4d74deb0715e81325c703bcdb3`.
|
|
The run used the Venus adapter backed by the host RX 7900 XT.
|
|
- `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
|
|
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 built-in-alignment pre-submit review was run in four passes. It caught
|
|
three distinctions the smaller tests had missed: an answer's validity must
|
|
include the drawing made in its final placed box; a region-node box is resolved
|
|
through its parent move rather than through its own move twice; and boxes with
|
|
equal dimensions but different positions still need the parent that placed
|
|
them. The 100-seed oracle and both 300-seed shrink cases pass after those
|
|
fixes. The earlier 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
|
|
|
|
Measured 2026-09-16 on the release `layout_diagnostics` fixture, seed 1,
|
|
depth 8, 500 frames, as median milliseconds a frame. `5ed9e87` is the commit
|
|
before fixed point; `39e4ca2` is fixed point complete.
|
|
|
|
| phase | `5ed9e87` | `39e4ca2` | `4f5e27c` |
|
|
| --- | ---: | ---: | ---: |
|
|
| many | 0.179 ms | 0.544 | 0.274 |
|
|
| resize | 0.020 | 0.035 | 0.033 |
|
|
| scroll | 0.011 | 0.030 | 0.019 |
|
|
| repaint | 0.012 | 0.031 | 0.021 |
|
|
|
|
**Fixed point cost 3x, and two thirds of that was not the grid.** The
|
|
counters said eight more "placed by redrawing" a frame in `scroll`, all of
|
|
them "reuse: another layer": a retained drawing belongs to the layer it was
|
|
made on, and `Stack` measured the child that sizes it on its own layer before
|
|
drawing it again on the child layer. `97cc8b3` measures on the layer the
|
|
child ends up on, which puts the scroll phase's counters back exactly where
|
|
they were -- 4 widget draws, 12 draw requests.
|
|
|
|
What is left is per-operation cost, not more work: the `many` phase does 125
|
|
widget draws against 121 before, and takes 1.5x as long. Three roundings and
|
|
divisions were taken out after measuring (`11c55bc`, `4f5e27c`, and halving
|
|
the divisions in `Holds::through`); `RegionRemap::apply_span` is still a
|
|
fifth of the phase, and the rest is spread thin. Measure with `perf stat -e
|
|
instructions:u` rather than the clock, which varies 2x here.
|
|
|
|
**Why the grid is slower than floats, from the disassembly at `4f5e27c`.**
|
|
`RegionRemap::apply_span` -- two scalars, every branch -- is 352
|
|
instructions, of which two are 64-bit `idiv`, six `imul`, and about sixty
|
|
are jumps and `cmov`s over twenty-eight compares. The same arithmetic in
|
|
`f32` is a couple of dozen SSE instructions. The difference is in three
|
|
places, and none of them is the grid being a worse idea:
|
|
|
|
- **A float's rounding is free and a fixed-point one is code.** `Fixed::mul`
|
|
widens to `i64`, multiplies, adds a half step, shifts back, and saturates
|
|
into `i32` -- roughly eight instructions and a sign branch where `mulss`
|
|
is one instruction with the rounding in hardware.
|
|
- **Integer division is the slowest instruction on the core**, is not
|
|
pipelined, and has no vector form; `divss` is pipelined and vectorises.
|
|
`div_round` also needs the remainder and its sign. This is why removing
|
|
divisions is where the wins have been, and why the remaining ones are
|
|
worth structural effort rather than micro-optimisation.
|
|
- **Saturation costs what floats get from infinities**, and it breaks the
|
|
pairing. Adding four `i32` lanes with saturation is a dozen SSE ops
|
|
(`pcmpgtd`/`paddd`/`pandn`/`psrad`/`pxor`/`por`) against one `addps`; and
|
|
the widening multiply cannot stay in a vector register at all, since there
|
|
is no packed 64-bit `imul` here, so lanes that an `f32` `Vec2` did at once
|
|
serialise.
|
|
|
|
"Fixed point is faster" is a rule from machines without an FPU. On this one
|
|
the grid buys exactness, and exactness is what the warm-against-cold oracle
|
|
demands -- so the cost is the price of the property, not a defect to chase
|
|
back to 1.0x.
|
|
|
|
One thing tried and reverted, recorded so it is not tried again: short-
|
|
circuiting `apply_scalar` where the fraction is nought or one. Those are not
|
|
the common cases, and the comparisons cost 17% more than the divisions they
|
|
saved.
|
|
|
|
Earlier, against #18's own history: the retained rewrite took `many` from
|
|
25.17M instructions a frame at `691e3eb` to 6.14M at `29c7881`, and `resize`
|
|
from 16.08M to 8.39M. That fixture has since changed; do not compare across
|
|
it.
|
|
|
|
## Retained-layout invariants
|
|
|
|
- **A retained drawing belongs to the layer it was made on.** Asked for again
|
|
on another layer it is redrawn, since nothing about its geometry says it is
|
|
in a list that paints at a different moment. A container that measures a
|
|
child by drawing it therefore measures on the layer that child will draw on
|
|
-- `Painter::child_layer_at` addresses one -- or it pays two draws a frame
|
|
forever and keeps whichever the second ask left.
|
|
- A widget that clips its contents to its box reports its box: `Scroll` and
|
|
`Masked` both report `LEFTOVER`, and a `debug_assert` in `draw_at` holds
|
|
any widget that set a mask to it. Overflowing is otherwise ordinary and a
|
|
text too tall for its box says so.
|
|
- A span is as long across itself as its longest child, unless a rule beside
|
|
it says how long it is -- and then it does not read its children there at
|
|
all, since the answer is not wanted and reading one is what makes its size
|
|
depend on it. `OrthoSize` was that second case written twice and is gone
|
|
(`9d8415d`); `Painter::ruled` is how a container asks which it is in, and
|
|
the only thing a widget may learn about a rule over it.
|
|
|
|
- A region node holds a whole `UiRegion` in its parent node's coordinates.
|
|
`UiRegion::FULL` is the identity. Widgets opt in with `.region_node()` or
|
|
`Widgets::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::region` is its box in its parent node. A region-node
|
|
widget draws in `FULL`; 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_node` redraws 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; raw `Scroll::new` does
|
|
not, and `Scroll` never 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. An
|
|
offer composes through its ancestors' offers, not through their current
|
|
placed boxes.
|
|
- An answer is reusable only where both its measurement and the drawing made
|
|
in its final placed box remain valid. The final drawing's `Holds` interval
|
|
is translated back into lengths of the offered box and intersected with the
|
|
answer's interval.
|
|
- Equal box lengths do not imply equal placement. An ordinary widget whose
|
|
offered and current boxes differ in position must involve its parent again;
|
|
a region node can settle itself only when its own alignment, rather than a
|
|
container override, determines the final box.
|
|
- A retained drawing can be reused only when its `Holds` interval 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.
|
|
- `Painter` records 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_under` prevents 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-`leftover` lengths are resolved by the widget's parent where the
|
|
widget is drawn. A declared-length change therefore redraws the parent.
|
|
- A pixel comparison is equality: lengths are whole counts of `1/1024` px,
|
|
so a change too small to reach the next step is not a change and one that
|
|
reaches it is, however little of a pixel it is worth.
|
|
- 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 `TextView` reports that interval through `Painter::holds`.
|
|
- `Span`'s decision to distribute `leftover` is a pixel question. The room to
|
|
divide is `len * fixed - total.px`; pure `leftover` children are undrawn
|
|
where there is none. The box a parent hands back and the sum of what the
|
|
children asked for are counts of the same step, so the boundary needs no
|
|
margin and the validity interval is split exactly at it.
|
|
- `Scroll` reports 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 that keeps a box's length is a translation, and an offset is
|
|
exact on the grid.** A box that also changed length has to re-express each
|
|
part as a fraction of the new one, and that division and multiplication
|
|
round: `39e4ca2` translates where `from.len() == to.len()` and scales only
|
|
where it must, which is what made the shrinker's `resize` case agree
|
|
exactly. This inverts the float-era rule, and the measurements behind that
|
|
rule are why `tests/cases/drift.rs` exists: in floats, offsetting both ends
|
|
of a span shortened that fixture's row by 0.071 px over 20,000 moves and
|
|
0.712 over 200,000, while re-expressing fractions stayed exact. 20,000
|
|
moves is five minutes of scrolling at 60Hz. On the grid the drift is gone
|
|
either way, and `tests/cases/drift.rs` pins that it stays gone.
|
|
|
|
## Built-in alignment
|
|
|
|
Committed as `d3b0ebf` in `/home/bob/repos/iris-pr18`.
|
|
|
|
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 familiar
|
|
positions are 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 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 applies alignment 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/cases/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.
|
|
|
|
`95fb4f9` adds the `debug_assert` this asked for, and narrows it to what is
|
|
actually true: a widget that **set a mask** this draw must report inside the
|
|
box it drew in. As "a reported size does not exceed its box" it fires on
|
|
ordinary overflow instead -- measured, a hundred fuzzer trees produce
|
|
thousands, every one a text too tall for the box it was offered, which is
|
|
what a text is meant to say. The same commit fixed `Masked`, which was
|
|
passing its inner's size up; `tests/cases/scroll.rs` has a clipping widget
|
|
that reports its content, so the assertion is itself covered.
|
|
|
|
## Fixed point
|
|
|
|
Layout decides on a grid rather than in floats, in four commits: `7548139`
|
|
the number, `4e28f10` positions, `bd6de71` lengths, `39e4ca2` the last of the
|
|
pixels and `Holds`. Decided with Bryan on 2026-09-15.
|
|
|
|
- **`Fixed<SHIFT>` is an `i32` counting `1 / 2^SHIFT`.** Adding and
|
|
subtracting are exact; a multiply or a conversion rounds once, back onto
|
|
the same steps. Two routes to one place that come within half a step land
|
|
on the same number, so everything downstream compares for equality.
|
|
- **`Px` is `1/1024` px, `Rel` is `1/2^24` of a box, `Weight` is `1/65536`
|
|
of a share.** `PX_SHIFT` and `REL_SHIFT` are the only statement of the
|
|
first two, and the shader's copy is prepended from them by
|
|
`render::module_source` rather than written again in WGSL.
|
|
- A weight is not a fraction: a list divides its room by the total of its
|
|
weights, so `Weight` trades precision for the range to hold a whole list,
|
|
and `Rel::ratio` turns two weights into a share on the finer grid.
|
|
- **Arithmetic saturates rather than wrapping**, because a clamped coordinate
|
|
keeps the ordering a wrapped one inverts.
|
|
- `Px` was `1/64` first. The residue of a length reached two ways is one
|
|
rounding, so it scales with the step: at `1/64` that was 0.016 px, enough
|
|
to move a box, and at `1/1024` it is a thousandth of a pixel. Range is
|
|
+/-2.1M px and conversion to `f32` is exact to 16,384 px.
|
|
- **What the fuzzers ask for is a step per level of nesting**, which is two
|
|
for these trees. Traced on 2026-09-16 to the same box reached two ways,
|
|
each rounding where the other does not -- not accumulation, and not one
|
|
place. Two of them are fixed in `bdab558`:
|
|
- `Scroll` wrote a box it had been given back out as its own length in
|
|
pixels. Centring a part in `rel 1` lands a step from centring it in
|
|
`px 900`, because `a(x - y)` and `ax - ay` do not round alike. Content
|
|
that fills the viewport unscrolled is handed back as it came, and the
|
|
`repaint` and `resize-repaint` cases became exact.
|
|
- `Span` placed each child a step from where the last ended, carrying every
|
|
share's rounding along the row. A position is now the fixed parts before
|
|
it, exact, plus one rounded share. Two hundred equal shares of a 1000 px
|
|
row ended at 999.999 and now end at 1000.
|
|
What is left is a box centred in a fraction of its parent against the same
|
|
box centred in its own pixels, one step per level between them. Closing it
|
|
means alignment resolved in pixels everywhere -- which costs the retained
|
|
resize path, since it is the fractional form that re-centres a subtree
|
|
without redrawing it. Not worth it at a thousandth of a pixel.
|
|
- `Holds::through` inverts `px + rel * box`, which rounds, so the answer is
|
|
an interval even for a single length. It maps the half step either side,
|
|
plus one more for the two ways above; inverting the length alone gives a
|
|
point that need not contain the box the part was drawn in.
|
|
- A pointer, a wheel notch, a shaped glyph advance and a window size arrive
|
|
as floats and are put on the grid where they arrive. `Vec2` stays what the
|
|
GPU and the platform speak; `PxVec2` is what layout decides in.
|
|
|
|
## The leftover boundary
|
|
|
|
Fixed in `5ed9e87`, which closed the shrinker's `reorder` case. Neither of its
|
|
two red seeds was about reordering.
|
|
|
|
A span's box in pixels, compared with what its fixed and relative children
|
|
fill, is the same number whenever the parent sized that box from the span's own
|
|
answer -- and the box comes back through the chain a few bits off. So
|
|
`0.00003 px` decided whether a `leftover`-only child existed: warm rounded
|
|
under and left it undrawn, cold rounded over and drew it at zero length. Both
|
|
layouts are stable and the pixels are identical either way, which is why only
|
|
the warm-against-cold oracle could see it.
|
|
|
|
**A structural decision may not be taken where boxes structurally land.** The
|
|
fix was not a tolerant comparison -- that is what generated seed 16 punished
|
|
-- but a boundary moved by `HOLDS_EPSILON_PX` of room, with the validity range
|
|
split exactly at the moved boundary. **The move and the constant are gone
|
|
since `39e4ca2`**: on the grid the box a parent hands back and the sum of what
|
|
the children asked for are whole counts of the same step, and both routes land
|
|
on the same count, so the boundary needs no margin. What the section still
|
|
records is why a decision may not be taken on a hair's breadth, and the
|
|
regression that pins it. `tests/cases/unsettled.rs`'s
|
|
`a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over` is the
|
|
six-widget regression, shrunk from 266; it needs the span above the one that
|
|
divides, because without a box composed through it both trees round the same
|
|
way.
|
|
|
|
`Scroll`'s `content_len <= container_len` sits on the same coincidence but is
|
|
continuous there -- it chooses between two `Holds` ranges that both contain the
|
|
current length, so a rounding difference costs a redraw rather than a
|
|
different layout. Checked while fixing this; nothing else reads a box in
|
|
pixels to decide something structural.
|
|
|
|
## Rigs and reproduction
|
|
|
|
Ordinary framework verification:
|
|
|
|
```sh
|
|
cd /home/bob/repos/iris-pr18
|
|
cargo fmt --all --check
|
|
cargo clippy --workspace --all-targets -- -D warnings
|
|
cargo test --workspace
|
|
```
|
|
|
|
`cb955f1` put the ordinary tests in `tests/cases/`, as modules of one
|
|
`tests/suite.rs` target -- eleven links became one, and with
|
|
`profile.test`'s `debug = "line-tables-only"` a rebuild of `iris`'s test
|
|
targets went from 14.3 s to 7.7 s and `target/` from 45 GB to 13 GB. Pick a
|
|
module out with `cargo test --test suite layout::`. The fuzzers and the
|
|
`*_cost` measurements are still their own targets.
|
|
|
|
The fuzzers take a thread per core but one (`9d8415d`), since a seed grows,
|
|
lays out and drops its tree alone: the oracle's hundred seeds went from 68 s
|
|
to 9.4 s and a shrinker case at 300 seeds from 18 s to 3.5 s. A failing seed
|
|
still shrinks and panics on its own thread.
|
|
|
|
Run the long generated oracle only after ordinary tests pass:
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
./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:
|
|
|
|
```text
|
|
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. `2d86058` brought `docs/LAYOUT.md` §2 and §3 to what
|
|
shipped; **§4, §5 and the density section are still stale** -- they name
|
|
`Painter::place`, `SetSize`, `desired_width`, `apply_rest`, `Len::dp`,
|
|
`Aligned` and `MaxSize`, none of which exist.
|
|
|
|
**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 as `d3b0ebf`.
|
|
|
|
Do not restore `OnResize::Translate`; retained translation is now expressed by
|
|
the same `Holds` contract and box chain.
|
|
|
|
Queued from this work, in order:
|
|
|
|
- `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: its `Holds` range must be exact and split at the crossover, the
|
|
way generated seed 16 taught for `Span`. On the grid the crossover no
|
|
longer needs moving off where boxes land -- see "Fixed point" -- but it
|
|
does need both sides of the comparison to be `Px`.
|
|
|
|
**A cap may not contain `leftover`** (found 2026-09-16, answering whether
|
|
the parent could resolve one). `Exact` works with a share in it because it
|
|
makes the widget's report moot -- `ruled()` is true, nothing reads the
|
|
report, and the parent divides a weight that does not depend on what it
|
|
divides. A cap must read the report, so rule and report are in one
|
|
equation, and a share puts the division into it too: a child capped at one
|
|
share contributes its drawn pixels to the row's total while it fits and a
|
|
weight once it does not, which moves the room, which moves the share. The
|
|
two assignments are each self-consistent, which is the multiple-fixed-point
|
|
failure that generated seed 13 punished for orthogonal sizing -- and
|
|
resolving it the way flexbox does, by freezing violated children and
|
|
dividing again, costs an ask per round where the retained contract allows
|
|
one offer per child. `min(report, cap)` is also not a `Len`: `Len` is a sum
|
|
of the three parts, and the smaller of two of them is not.
|
|
|
|
So a cap takes pixels and a fraction and no share, which is `UiScalar`'s
|
|
shape (`core/src/orientation/pos.rs`) -- either that type or a `Len`
|
|
without `leftover`. Awaiting Bryan: whether to split the type, and whether
|
|
a `Max` narrows the box the child draws in or only what the parent reports
|
|
for it.
|
|
|
|
Other queued work, in dependency order:
|
|
|
|
- `UiRenderState` behind `Rc<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.
|