Iris #18 is at `e166e00`, and warm layout now agrees with cold on every rig the repository has: all fifteen shrinker cases at 400 seeds of depth 5, and the oracle at 1000 seeds of depth 6. Both pass for the first time. The handoff's two standing leads, shrinker seed 288 and oracle seed 326, were one defect and are gone. What found them was asking why the `tabs` render differed, rather than counting the pixels that differed: - `d8ae9c3`: `redraw`'s second ask handed the final box over as if it were an offer, so a locally redrawn widget with its own alignment was placed twice. - `2bc6bdf`: `Stack` and `Pad` overrode every child's alignment with the near edge, which is owed only where the box really is the child's own answer. `tabs`'s counters and `text`'s narrow panel match canonical `main` again. - `38eba54`: `Holds::through`'s allowance was four half steps where three is the floor, and tightening it moved none of the rig's work counters. Why it cannot go to zero is recorded with it. - `e166e00`: pins what the gap question was actually about -- a length in pixels is that many pixels at any nesting depth, and a share is one or two steps off, which is a trade for the row filling exactly. Two corrections to this document. The bisect it reported for the partial-repaint defect named a commit that cannot have caused it, and the reasoning built on that was wrong. And its claim that `tabs` "differs only in the widget count it prints about itself" was checked at `8220a78` and left to cover `d3b0ebf`, which changed the render twice more. One API question is open: `.width()` now overwrites what `.sized()` set, since both are properties of one widget, so `tabs`'s centred square is a bar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
992 lines
57 KiB
Markdown
992 lines
57 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 **`e166e00`**, seventy-nine 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.
|
|
|
|
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 `e166e00`**, 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 145 s. The
|
|
last two pass for the first time.
|
|
- `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` 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.
|
|
**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 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_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.
|
|
|
|
### 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))` is a
|
|
100-tall bar across two shares where `main` draws a 100x100 square centred
|
|
in them. **Open, and an API question rather than a bug**: one widget
|
|
carries one length per axis, so "a small thing centred in a big box" now
|
|
needs a container to hold the big box -- `(thing.sized(..).center(),)
|
|
.stack().width(leftover(2))` is the idiom, and it is what the old builders
|
|
were inserting silently. The alternatives are to update the example, or to
|
|
give a widget an outer length as well as its own. Awaiting Bryan.
|
|
|
|
`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 three half steps either side. That is
|
|
the floor: two fires the `Holds` assertion on eleven generated cases.
|
|
Four was tried and was overshoot, and tightening it moved none of the
|
|
rig's twenty-five work counters, so slack here buys no reuse (`38eba54`).
|
|
- **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.
|
|
- 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 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** (Bryan, 2026-09-16). 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 of them comes back to the `i32` grid before the next starts,
|
|
which is exactly the "step per level of nesting" in "Fixed point" and what
|
|
forces `Holds::through`'s allowance for two routes. 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, since storage stays `i32`.
|
|
|
|
Two things to decide before starting: how many fractional bits the
|
|
intermediate keeps, since chained multiplies accumulate them and an `i64`
|
|
runs out too; and whether it helps `UiSpan::within` stay inlined, which is
|
|
the thing that actually moves cycles there -- a `Wide` that makes the body
|
|
bigger loses on the axis the truncation change won on. The success test is
|
|
narrowing `Holds::through`'s two-route allowance back to a step, which is a
|
|
check the generated oracle can answer in debug.
|
|
|
|
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.
|