Iris 5b78002 on #18: a child's answer comes back in the asker's frame, the near-edge override is per-axis decided flags, and the window is no longer a move entry. Also records the threading plan that closes the region-node offer defect, and why widening the grid would not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1177 lines
68 KiB
Markdown
1177 lines
68 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 **`5f16617`**, eighty-two commits, pushed. 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.
|
|
|
|
**`5b78002` on top of it** (2026-09-16) does two things Bryan asked for after
|
|
the frame and root questions below. A child's answer now comes back to the
|
|
widget that asked in *that widget's* frame, and the window is no longer a
|
|
move entry -- see "A report is a fraction of the box it was given" and the
|
|
root item under "Fixed point". `Holds::through` is untouched by either.
|
|
|
|
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.
|
|
|
|
`a8898aa` gives a length with no share in it its own type again, which is the
|
|
separation canonical `main` had and this branch had collapsed: `Len` is pixels
|
|
plus a fraction of a box -- a position being the length from the box's start,
|
|
which is why a span is two of them -- and `LayoutLen` is a `Len` plus a claim
|
|
only a container dividing its room can answer. What was `UiScalar` is `Len`,
|
|
what was `Len` is `LayoutLen`, and `From<Len> for LayoutLen` is the one-way
|
|
step. `490918b` renames `Painter::ruled` to `has_exact_size`, over
|
|
`SizeRule::exact`: "is there a rule here" and "is my report moot" are the same
|
|
question only while `Exact` is the only rule, and `Min`/`Max`/`Clamp` are
|
|
queued.
|
|
|
|
`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.
|
|
|
|
## Warm layout agreed with cold, and the rigs that got it there
|
|
|
|
**Fixed in `aea878d`, and the bisect was a red herring.** `git bisect` named
|
|
`95fb4f9`, the commit that made `Masked` report `Size::LEFTOVER`; the failing
|
|
tree has no `Masked` in it and that commit changes nothing else but a
|
|
`debug_assert`, so the attribution was wrong and the write-up built on it
|
|
("`Scroll` clips through a `Masked`") was too. Read the tree rather than the
|
|
bisect next time a commit that cannot be the cause is named.
|
|
|
|
The tree, five widgets, now running in `tests/cases/unsettled.rs` as
|
|
`redrawing_one_widget_does_not_move_what_scrolls_around_it`:
|
|
|
|
Scroll(Y) { Span(DOWN) { Scroll(X){ text } as a region node, rect 87x24 } }
|
|
|
|
`UiRenderState::redraw` asks a dirty widget in the box its parent asked it in
|
|
and then again in the box its parent chose from that answer, and it skipped
|
|
that second ask whenever the two were the same **length**. They are not the
|
|
same box: the outer scroll offers its whole viewport and places the span 24px
|
|
above it, snapping to the end of a content 24px longer than its box, so the
|
|
offer was as long as the final box and 24px below it. A region node writes
|
|
the box it drew in into its own move entry, so the inner scroll stayed where
|
|
the offer put it. `d3b0ebf` had already compared whole boxes for
|
|
`parent_must_place` and left this one a length comparison -- one decision with
|
|
its two halves disagreeing.
|
|
|
|
**The rigs.** `98d4e98` splits `iris::random::grow` into `plan(seed, depth,
|
|
&edits)` and `build(rsc, &plan)`. A seed cannot be made smaller, which is why
|
|
a failure the oracle found could never be handed to the shrinker; a `Plan`
|
|
can, and `tests/scenario/` holds the fifteen cases both rigs now run over the
|
|
same trees. So any failing seed reduces directly:
|
|
|
|
SHRINK_SEED=18 SHRINK_DEPTH=6 SHRINK_CASE=repaint-some \
|
|
cargo test --release --test shrink -- --ignored --nocapture
|
|
|
|
That took seed 18's 277 widgets to 5. The oracle prints the command to reduce
|
|
whatever it failed on. Seeds still mean the trees they meant: the 1000-seed
|
|
depth-6 run gives the same three failures with the same boxes before and after
|
|
the split, which is the check to repeat if the generator is touched again.
|
|
|
|
**Nothing is left, as of `d8ae9c3`.** All fifteen shrinker cases pass at 400
|
|
seeds of depth 5 and the oracle passes 1000 seeds of depth 6, both for the
|
|
first time. What closed the last two was one line: `redraw`'s second ask, in
|
|
the box its parent chose from the widget's answer, handed that box over as if
|
|
it were an offer, so `draw_inner` ran `placed_box` on an already-placed box
|
|
and applied the widget's own alignment to it twice. That only showed where
|
|
the alignment was the widget's own to apply, which is why it stayed hidden
|
|
while `Stack`, `Pad` and `Scroll` overrode every child.
|
|
|
|
Closed by it: shrinker seed 288 on `region-node` (11 widgets from 42, a
|
|
`Text` inset 8.8px at each end) and oracle seed 326 at depth 6 (43 from 205,
|
|
88px out, around two `Branch`es). Neither reduced small enough to hand-write,
|
|
so **generated seed 20 at depth 4 is the record** -- it is in `SEEDS`, so
|
|
`cargo test` fails without the fix rather than only the ignored long run.
|
|
Closed earlier by `aea878d`: shrinker seeds 174, 175 and 2, oracle seeds 18
|
|
and 190.
|
|
|
|
**Depth is what found these.** Nothing failed at 100 seeds of depth 4, which
|
|
is all the oracle had ever routinely run. Before fixed point, `5ed9e87` fails
|
|
the 1000-seed depth-6 run on seed 40 after a resize; that one is older than
|
|
all of the rest and is also gone.
|
|
|
|
**Run the long two before believing a rounding change.** They are 14 s, 57 s
|
|
and 145 s, which is nothing against how long the two above took to find:
|
|
|
|
```sh
|
|
cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees
|
|
SHRINK_CASE=all SHRINK_SEEDS=400 SHRINK_DEPTH=5 \
|
|
cargo test --release --test shrink -- --ignored --nocapture
|
|
IRIS_GENERATED_SEEDS=1000 IRIS_GENERATED_DEPTH=6 \
|
|
cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees
|
|
```
|
|
|
|
And run the ordinary suite in **debug**: the `Holds` assertion in `draw_at`
|
|
is what says a validity range stopped containing the box a drawing was made
|
|
in, and it is compiled out of the release runs above.
|
|
|
|
## Verification
|
|
|
|
**At `5f16617`**, the current head:
|
|
|
|
- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets --
|
|
-D warnings`, `cargo test --workspace`: sixteen targets green, 83 suite
|
|
tests, 17 core unit tests, 11 generated cases. What stays ignored is the
|
|
long runs and the profiling rigs; no known defect is ignored any more.
|
|
- The release oracle at 100 seeds in 14.3 s, **all fifteen shrinker cases at
|
|
400 seeds of depth 5** in 57 s, and **1000 seeds of depth 6** in 143 s. The
|
|
last two pass for the first time, and have kept passing across every
|
|
commit since.
|
|
- `view`, `minimal` and `random` render byte-identical at 1920x1200 against
|
|
`98d4e98`. `tabs` and `text` changed twice on purpose: `08c9d5a` moved an
|
|
antialiased edge by less than a pixel (see "Performance") and `2bc6bdf`
|
|
gave stack and pad children their own alignment back, which is what `tabs`
|
|
and `text` were asking for and what canonical `main` draws. **Take the
|
|
oracle rather than a render as the reference from here**; `view`,
|
|
`minimal` and `random` are the three still worth comparing.
|
|
- Twenty-five rig work counters identical between `60367d8` and `08c9d5a` on
|
|
the `many` fixture, which is what makes their timings comparable, and
|
|
unmoved again by `38eba54`.
|
|
|
|
**At `394d514`**, kept because several of these have not been re-run since:
|
|
|
|
`4febabf` on top of it -- `Fixed` wrapping rather than saturating -- passed
|
|
the workspace tests with all features (105), the release 100-seed oracle in
|
|
9.5 s, and rendered `random`, `tabs` and `text` byte-identical at 1920x1200
|
|
against `394d514`. The measurements are under "Performance".
|
|
|
|
- `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 it had then -- `resize`,
|
|
`repaint`, `resize-repaint`, `reorder`, `size-change` -- at 300 seeds of
|
|
depth 5. **That claim does not carry to `98d4e98`**, which runs fifteen
|
|
cases over the oracle's trees rather than five over its own, and fails at
|
|
400 seeds of depth 5. Neither does the 1000-seed depth-6 line that used to
|
|
sit here: re-run, `5ed9e87` fails it too, on seed 40 after a resize.
|
|
- `tabs`, `view`, `minimal`, `text` and `random` render byte-identical at
|
|
1920x1200 across the whole fixed-point sequence, and across this session's
|
|
renames and arithmetic changes on top of it.
|
|
- `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` differed only in the widget count it printed 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.
|
|
**That last claim was then left to cover `d3b0ebf` as well, and it does
|
|
not**: `tabs` changed twice more there, and nobody looked (2026-09-16).
|
|
See "Alignment as a property changed two examples" below. A claim about a
|
|
render holds for the commit it was checked at and no further.
|
|
- 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
|
|
|
|
**The 3x this section used to report was a fixture artifact, withdrawn
|
|
2026-09-16.** `random.rs`'s `Branch` picks which of two subtrees to draw by
|
|
comparing a measured pixel length with a threshold, so the fixture's shape
|
|
moves with the thing being measured. Seed 1 at depth 8 draws 88 widgets and
|
|
writes 2,298 primitives a frame at `5ed9e87`, and 115 and 8,209 at `bd6de71`
|
|
-- three and a half times the work behind a number read as three and a half
|
|
times the cost. `394d514` gives the rig `Edits::fixed_branches`; the oracle
|
|
keeps measured branches, which is the whole point of them.
|
|
|
|
**Check the work counters before comparing two commits' times.** The rig
|
|
prints drawn widgets, widget draws and primitive writes. An undrawn
|
|
`leftover` child still moves them and no flag removes that, so the trees are
|
|
near rather than equal -- and `9d8415d` changed the generator itself, since
|
|
an X span's `OrthoSize::Full` became a size rule.
|
|
|
|
Measured on the fixed-shape fixture, seed 1, depth 8, 500 frames of `many`:
|
|
|
|
| | drawn widgets | primitive writes | instructions | cycles | IPC |
|
|
| --- | ---: | ---: | ---: | ---: | ---: |
|
|
| `5ed9e87`, before fixed point | 100 | 4,272 | 1,761M | 688M | 2.561 |
|
|
| `4cbb242` | 97 | 3,951 | 2,093M | ~819M | |
|
|
| `4febabf`, wrapping | 97 | 3,951 | 1,912M | 771M | 2.48 |
|
|
| `60367d8`, rounding | 97 | 3,951 | 1,915M | 777M | 2.465 |
|
|
| `08c9d5a`, truncating | 97 | 3,951 | 1,800M | 715M | 2.516 |
|
|
|
|
The three ends were measured as medians of 25 runs of binaries built for the
|
|
reading; `4cbb242`'s row is still the single reading it was taken from, and
|
|
`4febabf`'s is medians of nine from 2026-09-16. `5ed9e87`'s numbers
|
|
reproduced to within 0.1% and 0.7% across those two sessions, which is what
|
|
says the rig is sound.
|
|
|
|
**`60367d8` and `08c9d5a` are the one pair here that can be compared
|
|
directly**: all twenty-five of the rig's work counters are identical between
|
|
them, so -6.0% instructions and -8.0% cycles is the same work at a different
|
|
speed. Against the float head the head is +2.2% and +3.9%, down from +8.7%
|
|
and +12.9% -- but **take neither for the grid's cost.** Those two do
|
|
different work: the float head draws 100 widgets to the head's 97, writes
|
|
4,272 primitives to 3,951, remaps 38 subtrees a frame and redraws 43 where
|
|
the head remaps 53 and redraws 27, and renders 49 texts to 42. Dividing by
|
|
primitive writes does not correct for a different mix of remapping and
|
|
redrawing.
|
|
|
|
**The fused multiply-add this section used to name does not exist,
|
|
withdrawn 2026-09-16.** `5ed9e87`'s binary holds no FMA instruction at all,
|
|
and none appears when it is rebuilt with `-C target-feature=+fma,+avx2`
|
|
either. Rust does not contract `a + b * c`, so `LerpUtil::lerp`'s
|
|
`from + (to - from) * self` is `subss`, `mulss`, `addss` on every target
|
|
there is. The float side never had the single instruction the grid's
|
|
multiply was being compared against, here or anywhere.
|
|
|
|
**Nor is the multiply the cost.** Each row below is `4febabf` with one
|
|
thing taken out, in one worktree so the rows compare to each other, 500
|
|
frames of `many` at seed 1 depth 8, medians of nine to eleven runs. **Every
|
|
row leaves the work counters where they were**, so unlike the cross-commit
|
|
numbers above these compare one tree against itself: all twenty-seven
|
|
counters diffed identical for the all-three row that the conclusion rests
|
|
on, and six of them checked for the others.
|
|
|
|
| | instructions | cycles |
|
|
| --- | ---: | ---: |
|
|
| `4febabf` | 1,921M | 772M |
|
|
| `shift_round` out of `Fixed::mul`, truncating instead | 1,878M | 774M |
|
|
| `is_full` short-circuits out of `UiSpan::within` | 1,910M | 765M |
|
|
| zero short-circuit out of `Fixed::scaled` | 1,914M | 768M |
|
|
| all three | **1,803M** | **715M** |
|
|
| branchless `shift_round`, both short-circuits out | 1,964M | 773M |
|
|
|
|
The "all three" row is the head as of `08c9d5a`; it re-measures there at
|
|
1,800M and 715M, which is the 0.2% a rebuild moves.
|
|
|
|
Taking the rounding out alone removes 43M instructions and moves cycles by
|
|
+2M, which is inside the noise: the grid's arithmetic is instructions the
|
|
machine has spare issue width for, not time.
|
|
|
|
**IPC has reversed since this section recorded it**, and that is the same
|
|
finding from the other side. It was 2.58 before fixed point and 2.59 after,
|
|
which is what "the cost is instruction count at unchanged throughput" was
|
|
read from. On the re-measured medians it is **2.54 before and 2.48 after**:
|
|
the head both executes more instructions and retires them more slowly, so
|
|
there is something to find beyond counting them. The all-three build
|
|
recovers half of it against its own worktree's baseline, 2.49 to 2.52.
|
|
|
|
**What moves cycles is whether `UiSpan::within` inlines, and the three cuts
|
|
are superadditive because of it.** Separately they are worth 61M
|
|
instructions and 8M cycles between them; together, 118M and 57M -- four
|
|
fifths of the instruction gap and two thirds to three quarters of the cycle
|
|
gap, which is the looser number. The mechanism is visible in `nm`:
|
|
`<UiSpan>::within` is a symbol in `4febabf` and in neither `5ed9e87` nor the
|
|
all-three build. Shortening its body past the inliner's threshold is what
|
|
pays, and only the whole set of cuts reaches it. That is where to push: a
|
|
cheaper `Holds` or a smaller `Len::within` is worth trying for this reason
|
|
rather than for its own instruction count.
|
|
|
|
**Cycle noise is larger than this section used to claim.** Across ten sets
|
|
of nine to eleven runs, each of one unchanged binary, cycles spread 1-3%
|
|
usually and 6.7% in the worst set -- not the 0.23% recorded from three
|
|
runs, which was a lucky set read as the machine's precision. Worse,
|
|
`instructions:u` in this VM intermittently returns a garbage value
|
|
-- 11.0e9, 25.8e9 and 28.4e9 against a true 1.9e9, in roughly a quarter of
|
|
readings. **`cycles:u` does it too**, seen 2026-09-16: 7.1e9 and 11.3e9
|
|
against a true 715M, and in one set of three runs all three cycle readings
|
|
were garbage while all three instruction readings were good -- so a filter
|
|
that drops anything off by a factor has to report how many readings it kept,
|
|
or an empty sample reads as a zero. Take medians of nine or more and do not
|
|
trust a single `perf stat`. `ex_div_busy` was the one counter that
|
|
held to 0.1% across runs.
|
|
|
|
**Instruction counts hold to 0.02% within a binary and move 0.5% across a
|
|
rebuild of the same source**, which is why the two tables above disagree
|
|
about `4febabf` by 9M: their baselines were built in different worktrees.
|
|
The *difference* against `5ed9e87` was 149M in both builds. So compare
|
|
against a baseline built beside the thing being measured, quote a delta
|
|
rather than an absolute, and re-measure the baseline whenever anything is
|
|
rebuilt.
|
|
|
|
**"The division is 0.00% of cycles" was read off the wrong function.** It
|
|
is true of `AxisRemap::apply_scalar`, where most moves are translations and
|
|
the multiply is 1.5%. It is not true of the run: `Holds::through` divides
|
|
twice on every call and accounts for essentially all of the 2.23M `i64`
|
|
divisions a 500-frame `many` executes, which hold the integer divider busy
|
|
**21.30M cycles** -- the steadiest counter on this machine, 0.1% across
|
|
runs, and 2.8% of the run. A controlled extra `div_toward` in `through`,
|
|
against a de-const control so the inlining does not move, costs 6.3 cycles
|
|
each, so the two real ones are about 14M. `5ed9e87` divides twice there too,
|
|
in floats, so this is a real cost but a smaller *difference* than the
|
|
source-line profile makes it look, where `div_toward`'s `num / den` is the
|
|
largest single grid line in the head at 106M of 5,361M over 4,000 frames,
|
|
against `shift_round`'s 71M.
|
|
|
|
Recovered this session, each measured before and after on one commit with
|
|
byte-identical renders proving the layout unchanged -- on the old fixture,
|
|
689.6M cycles and 1,886M instructions down to 638.9M and 1,657M:
|
|
|
|
- `cb1bba4` works a move out once per subtree rather than per scalar: -3.8%
|
|
instructions, -0.8% cycles. The gap between those two is the finding.
|
|
- `1940e85` translates a whole region at once, since that is what a
|
|
translation is: -4% instructions, -3.4% cycles.
|
|
- `d75a1e2` and `4cbb242` skip multiplies that cannot change anything --
|
|
composing through a full box, and `lerp` where the ends are equal:
|
|
together -2.7% instructions, -1.8% cycles when they landed. **That no
|
|
longer reproduces at `4febabf`**: removing either one now *saves* a
|
|
little, and removing both is part of the 118M/57M above. Wrapping changed
|
|
what a multiply costs, and a guard priced against a saturating multiply is
|
|
priced against something that is gone. Re-price a short-circuit before
|
|
keeping it.
|
|
|
|
Tried and rejected, with numbers, so they are not tried again:
|
|
|
|
- A float reciprocal for the division: **+6% cycles**. The conversions cost
|
|
more than the `idiv` did there. That was `apply_scalar`'s division; the
|
|
one in `Holds::through` is a different question and has not been tried.
|
|
- Branchless `shift_round`: **+6.7% cycles** when tried alone, and
|
|
re-tested 2026-09-16 with both short-circuits also removed, where the
|
|
branchy form reaches 1,803M/715M: 1,964M instructions and 773M cycles,
|
|
worse than the head on both. It is the *size* of the rounding that keeps
|
|
`within` out of line, not its branch, so making it branchless makes it
|
|
bigger and loses twice.
|
|
- Removing the per-child hash lookup in `remap_subtree` with `mem::take`:
|
|
**0.0%**. `FxHashMap` on a hot line is not the cost.
|
|
- Short-circuiting `apply_scalar` where the fraction is nought or one: +17%.
|
|
(From the previous session.)
|
|
|
|
- `#[inline]` on `UiSpan::within`, whose prologue and epilogue were a sixth
|
|
of its own cycles: -0.2% instructions, **+1.5% cycles**. That is not in
|
|
tension with the inlining finding above: forcing the body it has now into
|
|
every caller loses, and shrinking the body until the inliner takes it of
|
|
its own accord wins. Shrink it; do not annotate it.
|
|
|
|
**`4febabf` makes `Fixed` wrap rather than saturate** (Bryan, 2026-09-16: a
|
|
coordinate past the range is not going to draw reasonably anyway, so wrap
|
|
and break clearly): -8.6% instructions, -6.6% cycles, more on cycles than
|
|
the 2.7% the instruction share predicted. `from_f32` still clamps and
|
|
`Holds` keeps its saturating `narrow`, since a range of box lengths past
|
|
`i32` really is unbounded.
|
|
|
|
**`08c9d5a` makes `Fixed::mul` drop to the step below rather than round**
|
|
(Bryan, 2026-09-16: truncation is preferable at this point). The three cuts
|
|
this section priced together landed as one commit, since separately they are
|
|
worth 61M instructions and 8M cycles and together 115M and 62M -- the whole
|
|
of the superadditivity above. `Fixed::scaled` was its own zero short-circuit
|
|
and is gone; `UiSpan::within` lost both `is_full` tests and is inlined again,
|
|
which `nm` confirms.
|
|
|
|
What it costs, all of it measured rather than asserted:
|
|
|
|
- A share lands a thousandth of a pixel short of its row rather than on it.
|
|
`an_uneven_nesting_still_gives_every_share_the_same_length` now says what
|
|
is still exact -- each share starts where the last ended, the row ends at
|
|
its own edge, each edge is on the even division or one step below.
|
|
- A value and its negation are no longer the same distance from where they
|
|
came, since the drop is toward negative infinity on both sides of zero, so
|
|
a flipped span can sit a step from its mirror image. The alternative that
|
|
keeps that symmetry is the sign branch, which is part of what was bought.
|
|
- `tabs` is no longer a byte-identical render: 4,664 of 2,304,000 pixels
|
|
differ, in single-pixel-wide runs along 80 columns of one band of rounded
|
|
rects, which is an antialiased edge moved less than a pixel. `view`,
|
|
`minimal`, `text`, `random` and the tab replay are unchanged.
|
|
- The warm-against-cold oracle is **not** worse: 100 seeds pass, the fifteen
|
|
shrinker cases at 400 seeds of depth 5 fail only on seed 288 as before, and
|
|
depth-6 seeds 18 and 190 pass with 326 failing as before.
|
|
- `Holds::through` had to be re-derived, and the derivation for one
|
|
truncation either side is measurably too narrow. See the item under
|
|
"Retained-layout invariants".
|
|
|
|
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 twice; 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` holds any widget that
|
|
**set a mask this draw** to it. Overflowing is otherwise ordinary and a text
|
|
too tall for its box says so -- which is why the assertion is narrowed to
|
|
mask-setters rather than to every widget.
|
|
`Masked` is the only one that sets a mask; `Scroll` reports its box because
|
|
a drawing placed in a longer box is *scaled* by recomposition, not because
|
|
it masks. So a `LazySpan` is unaffected: it clips by not drawing, and may
|
|
report its whole content length, which is what the `Scroll` above it
|
|
measures. One that set its own mask would have to report its box and hand
|
|
the content length up another way.
|
|
- 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.
|
|
- **A container's near-edge override is owed only where the box really is the
|
|
child's own answer.** `Stack` gives every child the box `box_of` derives
|
|
from its sizing child, so that one child has no room in it and would be
|
|
placed twice; every other child is handed a box that owes nothing to it and
|
|
keeps its own alignment. `Pad` reports its inner plus the padding, so where
|
|
its box is that answer the inset box is exactly the inner, and where the box
|
|
is bigger the slack is the inner's to sit in -- it overrides nothing
|
|
(`2bc6bdf`). `Scroll` still overrides, on both axes and deliberately: the
|
|
box it hands its content along the scrolling axis is the content's own
|
|
length, and where shorter content sits across the viewport is `Scroll`'s own
|
|
alignment by design.
|
|
- **A box a parent has already placed is asked about at the near edge.** The
|
|
second ask in `redraw` is in the final box, not at an offer, so applying the
|
|
widget's alignment to it again places its content twice (`d8ae9c3`).
|
|
- 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. **Both halves of that
|
|
decision compare whole boxes** -- whether the parent must place it, and
|
|
whether the offer already was the final box. Comparing lengths for the
|
|
second left a region node drawn at its offer (`aea878d`).
|
|
- 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.
|
|
- **A length given in pixels is that many pixels wherever it ends up**, and
|
|
structurally rather than by luck: `Len::within` adds a part's own pixels
|
|
rather than scaling them, and both ends of a gap carry the same fraction,
|
|
so the multiply that rounds is the same on each and cancels. Pinned by
|
|
`a_length_in_pixels_is_that_many_pixels_however_it_is_nested`, over a row
|
|
buried under three containers that are each a fraction of their parent;
|
|
swept over 2,100 box widths and exact at every one.
|
|
- **A report is a fraction of the box the widget was given, and comes back
|
|
to the asker in the asker's frame** (Bryan, 2026-09-16: `rel` has nothing
|
|
to do with the remainder; it is the proportion of the space the widget
|
|
was given). `placed_box` always read it that way. `Span` did not: it added
|
|
a drawn child's `rel` straight into a cursor that counts fractions of the
|
|
row, which is right only while the offer has the row's whole extent --
|
|
and a `Span` offers what is left after the cursor, so it broke once a
|
|
relative child preceded another. `Pad` summed padding onto a fraction of
|
|
its inset the same way and reported its box plus the padding for a child
|
|
that filled the inset. `DrawResult::size`/`len` and `known_len` now
|
|
compose the answer through the offer's length before handing it back
|
|
(`in_parent_frame`), so a container reads lengths of its own box and the
|
|
box `Span` hands back afterwards is the one `placed_box` chose. A
|
|
declared axis is excepted: the parent resolved the rule in its own box
|
|
and the rule is what the report says there. A hint is used as a
|
|
declaration and so is in the parent's frame too; `hints_agree` compares
|
|
it with the raw report, which holds because every hint is pixels or
|
|
`leftover`. A hint with a fraction in it would need one frame chosen.
|
|
- **A length given as a share is not**, and cannot be: children asking for
|
|
the same fraction come out one or two steps apart, 0.001 to 0.002 px. A
|
|
position is the quantity that gets rounded, so the row fills exactly and no
|
|
two children leave a seam, and the spread between lengths is what that
|
|
costs. Exact composition would shrink it, not remove it -- five equal
|
|
lengths cannot fill a row whose step count is not a multiple of five. Both
|
|
halves of this are in
|
|
`equal_shares_differ_by_at_most_two_steps_and_fill_the_row`.
|
|
- 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 `Size::LEFTOVER`; what it takes from the first box it asked
|
|
about is its *content's* length, which it clamps and holds by rather than
|
|
reports. 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_decided(child, region, [bool; 2])` is the override as of
|
|
`5b78002`, and it is per axis and not an alignment: it says the parent chose
|
|
this box from the child's own answer along those axes, so the answer is not
|
|
placed inside it again. It replaced `widget_aligned(child, region, NEAR)`,
|
|
which forced the near edge on *both* axes and, being an alignment, still let
|
|
`placed_box` scale a fractional answer against the box -- fine while every
|
|
overriding container handed a box its child had reported in pixels, and
|
|
wrong the moment `Span` had to use it. `Span` now decides the row axis alone
|
|
when it places a child at the length it reported, since across the row the
|
|
child sits where its alignment says; `Scroll` decides both, and `Stack` both
|
|
for its sizing child. `DrawInfo::decided` and `ActiveData::decided` carry it,
|
|
so a local redraw asks the question its parent asked; alignment is always the
|
|
widget's own property now, and `ActiveData::align` is gone.
|
|
|
|
**A fractional answer taken of the parent's second box shrinks twice.**
|
|
Found by the frame fix's own test: a nested span reporting `rel(0.5)` was
|
|
placed by its parent at a quarter of the row, correctly, and then
|
|
`placed_box` took half of *that* box because the parent's second ask was not
|
|
an override. A report of "half of what you give me" has no fixed point but
|
|
zero, so the framework asks exactly twice -- once at the offer, once in the
|
|
box chosen from the answer -- and the second box is final on the axes the
|
|
parent decided. This is also why a root that reports a fraction of the
|
|
window is placed inside it by its own alignment and then scaled to fit,
|
|
which the two new tests in `tests/cases/layout.rs` avoid by ruling the root
|
|
to the window; that is old behaviour and was not changed.
|
|
|
|
### Alignment as a property changed two examples, unnoticed
|
|
|
|
Found 2026-09-16 by looking at `tabs` beside canonical `main` rather than at
|
|
its pixel count. `d3b0ebf` deleted `Aligned`, and `8220a78` deleted
|
|
`SetSize`, so `.align()`, `.center()`, `.sized()`, `.width()` and `.height()`
|
|
all set properties on **one** widget where each used to wrap it in another.
|
|
Chaining them therefore means something different now, and two of it showed:
|
|
|
|
- **A stack or pad child could not be aligned at all**, because those
|
|
containers overrode every child with the near edge. `tabs`'s counters
|
|
asked for `Align::RIGHT` and sat at the left; `text`'s narrow panel asked
|
|
to sit at the top of its row and filled it. Fixed in `2bc6bdf`; both match
|
|
`main` again.
|
|
- **`.width()` overwrites what `.sized()` set on the same axis**, so
|
|
`tabs`'s `rrect.sized((100, 100)).center().width(leftover(2))` was a
|
|
100-tall bar across two shares where `main` draws a 100x100 square centred
|
|
in them. **Answered by `d21a215`** (Bryan, 2026-09-16): one widget keeps
|
|
one length per axis, and the second length needs a second widget that does
|
|
as little as possible. `WidgetPtr` already was that widget and is now
|
|
`Wrapper`, with `.wrapper()` to make one -- `Wrapper` and not `Wrap`, and
|
|
`.wrapper()` and not `.wrapped()`, so neither reads as the text setting.
|
|
|
|
`text` still differs from `main` in its `aligned` panel, and that one is a
|
|
decision rather than a defect: `9d8415d` deleted `OrthoSize`, so a Y span
|
|
across itself is as long as its longest child instead of full width. The
|
|
example needs a width, not the framework a change.
|
|
|
|
### 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 drops to the step below and a conversion
|
|
between grids takes the nearest step. Two routes to one place that land on
|
|
one number are the same place, so everything downstream compares for
|
|
equality. **The multiply truncates as of `08c9d5a`** -- see the item in
|
|
"Performance" for what that bought and what it costs. `shift_round` is
|
|
still there for `to_scale`, which has no caller outside its own test.
|
|
- **`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 wraps, as of `4febabf`.** It saturated first, so that a
|
|
clamped coordinate kept the ordering a wrapped one inverts, and that cost
|
|
a twelfth of layout's instructions to keep order two million pixels out
|
|
where nothing draws. `MIN` and `MAX` still stand in for an unbounded end,
|
|
which is safe only because every use compares against them and none adds
|
|
to them; `from_f32` is the one operation that clamps.
|
|
- `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. Truncating made each of those steps a whole one rather
|
|
than half of one; it did not add a level. 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 drops a step, so the answer
|
|
is an interval even for a single length: inverting the length alone gives a
|
|
point that need not contain the box the part was drawn in. What it allows
|
|
for has two parts and they are not the same shape.
|
|
- **The two routes to a length** -- composed down the chain against
|
|
measured against the window -- get two half steps either side, since
|
|
`45a7176` made the walk round once instead of once a level. It was three
|
|
while composing rounded per level, and four before that, which was
|
|
overshoot. Tightening it has never moved one of the rig's twenty-five
|
|
work counters, so slack here buys no reuse.
|
|
|
|
**One half step further is arithmetically available and deliberately not
|
|
taken.** The `Holds` assertion is quiet at one, and the whole-of-a-box
|
|
case becomes an exact identity. What stops it is shrinker seed 220 on
|
|
`reorder` -- diagnosed on 2026-09-16 and written up under "The offer
|
|
chain breaks at a region node" below. It is not a rounding question.
|
|
- **The multiply on the way in** gets one more step at the top of the range
|
|
and nothing at the bottom, since truncation only ever drops. The whole of
|
|
a box has no multiply in it, however many pixels are added to it, and
|
|
allowing for one there compounded a step per level down a chain of
|
|
widgets each taking the whole of its parent (`60367d8`).
|
|
|
|
**It cannot go to zero while a length reached two ways is two numbers.**
|
|
The range has to contain the box a drawing was made in, which the `Holds`
|
|
assertion in `draw_at` checks, and it must not contain a box the drawing
|
|
does not hold for, which the warm-against-cold oracle checks. Those two
|
|
only coincide where composition is exact -- the `i64` item under "Next" --
|
|
and shrinking this allowance is the test of whether that worked. Too wide
|
|
is the unsound side of it. **Run the generated cases in debug**, since the
|
|
assertion is compiled out of every release run.
|
|
- **The window is not a move entry** (`5b78002`). It was one, holding the
|
|
output in pixels, so composing through it zeroed every `rel` below and the
|
|
shader's `rel * window.dim` and the CPU's final multiply by the output
|
|
were both dead. Now a chain bottoms out in `MoveIdx::NONE`, the window is
|
|
applied where a fraction becomes pixels -- `to_px(output_size)` on the
|
|
CPU, the uniform in the shader -- and a resize rewrites no retained entry
|
|
and re-uploads nothing but the uniform. Instances were never re-uploaded
|
|
on a resize either way; they are `(rel, px)` in their node's frame and the
|
|
shader walks the chain per vertex. The cost of a resize is whatever
|
|
`Holds` redraws.
|
|
- 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 offer chain breaks at a region node
|
|
|
|
**The open defect at `5f16617`**, and the reason `Holds::through` keeps a
|
|
half step it does not need. It is the fourth thing this session to come down
|
|
to the offer against the placed box, after `aea878d`, `d8ae9c3` and the
|
|
`Stack`/`Pad` overrides in `2bc6bdf`.
|
|
|
|
`offered_region` composes a widget's offer through its ancestors' offers,
|
|
which is the invariant. Where the parent is a **region node** it does not:
|
|
it falls back to `UiRegion::FULL`, and `redraw` then resolves that against
|
|
the node's slot entry -- which holds the node's **placed** box. So a widget
|
|
under a region node is re-asked in the box its parent *placed* that node in,
|
|
not the box its parent *offered* it.
|
|
|
|
That is only wrong where the two differ, and `Scroll` is where they do: it
|
|
offers its content the viewport and places it in a box as long as the
|
|
content. So the offer becomes a function of the answer being re-derived, and
|
|
the old answer confirms itself.
|
|
|
|
Reproduced from the shrinker's ten-widget reduction:
|
|
|
|
Scroll(X) { Pad as a region node { Span(X) {
|
|
Branch(threshold 213), Span(X){ wrapped text, one-line text } } } }
|
|
|
|
```sh
|
|
SHRINK_SEED=220 SHRINK_DEPTH=5 SHRINK_CASE=reorder \
|
|
cargo test --release --test shrink -- --ignored --nocapture
|
|
```
|
|
|
|
Traced, at a 900x1200 window, with `reorder` rotating both spans:
|
|
|
|
- Each order **does** settle, and to a different answer: `[0,1]` gives the
|
|
pad a 2080.18px box and `[1,0]` gives it 1789.90. So this is not an
|
|
unsettled tree; both are fixed points.
|
|
- Cold, rotated: the texts are asked at 900 and 594.06, shape, and report
|
|
305.94 and 583.97, so their span reports 889.90.
|
|
- Warm, rotated: the outer span is re-asked in **2080.18**, the pre-rotation
|
|
content box. Its texts are asked at 305.94 and 977.42, both reuse retained
|
|
answers without reshaping, and the span reports 1283.36 -- the same as
|
|
before the rotation, so no answer changed and nothing propagated up to the
|
|
`Scroll`. Widget 0 lands 290.27px out and the `Branch` picks its other
|
|
subtree.
|
|
- Marking every widget dirty converges warm onto cold, which is what says
|
|
the retained path and not the arithmetic is at fault.
|
|
|
|
**The fix is to stop recomputing the offer at all** (agreed with Bryan,
|
|
2026-09-16). `ActiveData::offer` is in the parent *widget's* coordinates and
|
|
`ActiveData::region` is in the parent *node's*, so "was this node placed
|
|
where it was offered" is not a comparison either field can answer alone --
|
|
and neither answer is needed. By the retained contract a drawing and its
|
|
answer depend on the box's pixel *lengths* only; checked against every
|
|
widget, the pixel reads are `px_len` and `px_size` and nothing reads a
|
|
position. The offer's symbolic position was only ever an input to its pixel
|
|
size, and that the parent already knew when it asked. So:
|
|
|
|
- Thread the placed box's pixel size down beside `offered_px` -- one
|
|
expression per level, `local.size().to_px(parent_px)` -- and keep both on
|
|
`ActiveData`. Cold layout computes them the same way, so warm and cold
|
|
agree by construction rather than by allowance.
|
|
- `at_offer` becomes `px == offered_px`. Equal lengths at a different
|
|
position no longer escalate: the widget draws at `region`, which is the
|
|
`aea878d` fix without the second draw. A region node whose lengths differ
|
|
still draws twice, and the first box only needs the right length.
|
|
- `redraw` reads the stored `offered_px`. `offered_region`, `px_of`,
|
|
`px_region`, `Moves::compose`/`size_of`, `slot_wide` and `wide.rs` go; a
|
|
narrow `resolve` stays for `window_region`, which is hit testing and not a
|
|
layout decision.
|
|
- `remap_subtree`'s `Translate` keeps lengths, so stored sizes stay valid
|
|
at no cost on the hot path; `Scale` is the resize path and recomputes
|
|
them down the subtree from the same expression.
|
|
- `Holds::through` becomes the exact preimage of the truncating map
|
|
`px + floor(rel * B)`: two integer divisions, no `ROUTES`, no `way_in`,
|
|
and the whole-of-a-box case is the identity. The allowance existed only
|
|
because the box arrived by a second route.
|
|
|
|
Symbolic regions then exist for the GPU and for remaps alone, and their
|
|
rounding is cosmetic below a step. That is the separation the fixed-point
|
|
work kept reaching for. **Do not widen the grid instead**: every failure in
|
|
this document is one value reached by two expressions, sitting exactly on a
|
|
boundary because the boundary was defined by the same value coming back the
|
|
other way, and no precision shrinks a residue that is the whole distance.
|
|
|
|
## 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 what it covers. `tests/shrink.rs` reduces a
|
|
failing tree, over the same fifteen cases and the same trees since `98d4e98`,
|
|
so a seed the oracle fails on goes straight to it -- the failure prints the
|
|
command. `SHRINK_SEED`, `SHRINK_SEEDS`, `SHRINK_DEPTH`, and `SHRINK_CASE`
|
|
(a case name, or `all`) select what it tries. Turn what it finds into a test
|
|
of its own rather than leaving a seed as the record.
|
|
|
|
The fifteen cases live in `tests/scenario/mod.rs`, which both targets include
|
|
by `#[path]`. Adding one there gives it to both rigs at once; a case that only
|
|
one of them knows is how the two drifted apart in the first place.
|
|
|
|
**The float head is checked out at `/home/bob/repos/iris-float-cmp`**, at
|
|
`5ed9e87` with `394d514`'s `Edits::fixed_branches` cherry-applied to
|
|
`src/random.rs` and `tests/layout_diagnostics.rs` but not committed, since
|
|
the flag is the only way to measure the two on one tree. Build it and
|
|
`iris-pr18` with `--test layout_diagnostics` and run the binaries directly
|
|
rather than through cargo. A comparison is only worth reading when the
|
|
`--features layout-diagnostics` counters match on both sides; check
|
|
`primitive writes`, `widget draws`, `reuse remapped` and `placed by
|
|
redrawing` before any timing. **Against the float head they do not match and
|
|
will not**, so that comparison is a bound rather than a measurement -- the
|
|
pair worth measuring is two fixed-point commits, where all twenty-five can
|
|
be diffed. Dump them with
|
|
|
|
```sh
|
|
IRIS_SEED=1 IRIS_DEPTH=8 IRIS_FRAMES=500 IRIS_PHASE=many \
|
|
<instrumented binary> --ignored --nocapture \
|
|
| grep -E '^ +[a-z].*[0-9.]+$' | grep -v ' ms$' | sort
|
|
```
|
|
|
|
and `diff` the two, which is the check that says a change is free.
|
|
|
|
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 what `Len` is
|
|
since `a8898aa`. Still awaiting Bryan: whether a `Max` narrows the box the
|
|
child draws in, or only what the parent reports for it.
|
|
|
|
- ~~**Compose in `i64` and narrow only when storing**~~ -- **done in
|
|
`45a7176`**, and what it bought is below. The rest of this item is kept
|
|
because the measurement in it is what stopped the work being aimed at the
|
|
wrong thing.
|
|
|
|
The range inside a single operation is already protected -- `Fixed::mul`
|
|
widens to `i64` for the product, `Holds::through` does all of its
|
|
arithmetic in `i64` and narrows once at the end, and `div_toward` takes
|
|
`i64`. What is not protected is *precision across a chain*:
|
|
`UiSpan::within` composes a box through its parent with four multiplies and
|
|
every one comes back to the `i32` grid before the next starts, which is the
|
|
"step per level of nesting" in "Fixed point" and what forces
|
|
`Holds::through`'s two-route allowance. A `Wide<SHIFT>` held through a
|
|
composition and narrowed when it is written to `ActiveData` or a primitive
|
|
would round once instead of once a level, at no cost in stored size.
|
|
|
|
**But it cannot make equal shares the same number of screen pixels,
|
|
because the CPU's grid is not what puts them on the screen.**
|
|
`prelude.wgsl` decodes the raw `rel`/`px` counts into `f32`, walks the move
|
|
chain in floats -- with the comment saying so, "what has to hold is that
|
|
this agrees with itself frame to frame, not that it matches the CPU to the
|
|
last bit" -- and then lands each edge with
|
|
`snap_floor(rel * dim + px)`, which is a whole pixel. (It floored the two
|
|
parts apart until `5b78002`, which was harmless only while the root entry
|
|
zeroed every `rel`.)
|
|
**Measured: every box edge in the `tabs` render is a hard edge**, no
|
|
partially covered column anywhere along the band, and the two `pad(10)`
|
|
gaps are exactly ten pixels on both sides of the truncation change. So the
|
|
screen invariant is already as good as integers allow: a length in pixels
|
|
is that many pixels, and equal shares differ by at most one whole pixel
|
|
because three equal integers cannot sum to 1000. Layout's own one-or-two
|
|
step spread is below what the shader can express, and shows only where it
|
|
pushes a value across the `floor` -- which is what moved `tabs`'s corner
|
|
arcs by a pixel at `08c9d5a`.
|
|
|
|
What it bought is **layout's own decisions**: `Holds::through`'s two-route
|
|
allowance went from three half steps to two, and the whole of a box now
|
|
maps back to one step rather than one step per level of nesting.
|
|
|
|
**And it made layout faster.** 1,840M instructions and 735M cycles against
|
|
1,908M and 760M -- 3.6% and 3.3% -- medians of 25 with all twenty-five
|
|
work counters identical. `5f16617` is where the win is: a draw already
|
|
descends past every move entry on its way in, so `DrawInfo` carries what
|
|
the slot composes to and a widget's own box is a select rather than a walk
|
|
back up a mean of 2.8 levels, eight hundred times a frame. Composing on
|
|
the fine grid alone cost 1,880M and 755M, so the widening was paid for
|
|
twice over by not doing the walk.
|
|
|
|
**One narrow step is left in that path.** `Painter` still composes a
|
|
child's region into its own on the grid before asking for it in pixels.
|
|
Taking it out needs the child's region as its parent stated it, which
|
|
`draw_inner` is not handed -- it receives that box already composed into
|
|
the slot's frame, because that is the form `ActiveData` and the GPU want.
|
|
Worth doing after the region-node offer defect below, which is what pins
|
|
`Holds::through` at the moment.
|
|
Two things paid for the widening and are the reason to keep them: a
|
|
*length* composes on its own in two multiplies a level rather than the
|
|
four both ends cost, since where the parent sits falls out of the
|
|
difference; and the pixel term takes its fraction on the ordinary grid, so
|
|
only the fraction itself -- the part that compounds multiplicatively --
|
|
needs the room. A third, skipping a parent that spans its own box, cost
|
|
18M instructions and is not there, the same verdict `UiSpan::within`'s
|
|
short-circuits got.
|
|
|
|
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.
|