1036 lines
55 KiB
Markdown
1036 lines
55 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. Pruned on 2026-09-17: the chronicle of closed defects, superseded
|
|
verification lists and cross-fixture tables went, the rules and the lessons
|
|
stayed.
|
|
|
|
## 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`, head
|
|
**`e44dea3`**, pushed. It holds LAYOUT.md §2's position chain, `leftover`,
|
|
the `Holds` retained-layout contract, region nodes, built-in alignment and
|
|
size rules, fixed-point layout, a box in pixels threaded down the draw, and
|
|
a report read as a fraction of the containing widget. No PR review was
|
|
present when checked on 2026-09-15.
|
|
|
|
**Widen one fuzzer axis at a time, and record which.** Seeds **1121** and
|
|
**1839** at **depth 4** failed on `ea6dbae` and on every commit before it,
|
|
and nothing in the routine verification reached them: the fast oracle takes
|
|
ten seeds, the shrinker 400 at depth 5 and the long oracle 1000 at depth 6,
|
|
so a defect past seed 400 at depth 4 had nowhere to show. Both are fixed by
|
|
`4bd8607`. The scan that found them, worth running again after any change
|
|
to layout:
|
|
|
|
```rust
|
|
// tests/scan.rs, deleted once it had done its job
|
|
over_seeds((1..=2000).collect(), |seed| {
|
|
let grown = plan(seed, 4, &Edits::default());
|
|
for &case in ALL.iter() {
|
|
if let Some(how) = diverges(&grown, case, seed) {
|
|
println!("HIT seed {seed} case {} {how}", case.name());
|
|
}
|
|
}
|
|
});
|
|
```
|
|
|
|
261 s for 2000 seeds at depth 4 over all fifteen cases, and clean at
|
|
`4bd8607`. `Rng::new` is `seed | 1`, so an even seed and the odd one above
|
|
it are one tree: 1120 and 1121 are the same counterexample, as are 1838 and
|
|
1839.
|
|
|
|
**Two ideas outrank everything else in this document** (Bryan, 2026-09-17).
|
|
First, a changed tree lays out exactly as if it had been drawn that way from
|
|
the start; that is what the retained machinery is for and what the oracle
|
|
and shrinker check. Second, widgets are predictable: `px` is that many
|
|
pixels, `rel(0.5)` is half of the containing widget's area however many
|
|
siblings there are and wherever it sits among them, and `leftover` is a
|
|
share of the room left once every sibling's `px` and `rel` are resolved. The
|
|
invariants listed further down were accumulated by agents chasing single
|
|
failures; any of them may be simplified or deleted if those two ideas still
|
|
hold.
|
|
|
|
## Review of 2026-09-17
|
|
|
|
A fresh read of `core/src/fixed.rs`, `orientation/`, `ui/holds.rs`,
|
|
`ui/painter.rs`, `ui/render_state.rs` and the position widgets, outside of
|
|
doing work on them, with each finding checked by a scratch test.
|
|
|
|
**Verdict.** The concepts are sound and stay. Fixed point on a `1/1024`
|
|
grid is the right base for a layout that decides "same box or not" by
|
|
equality. Threading the pixel box down the draw, with `Holds::through` the
|
|
exact preimage of that one multiply, is the strongest idea in the code:
|
|
layout has one route to every length and the reuse test is its exact
|
|
inverse. Offer, given and placed is the ordinary measure-then-arrange model.
|
|
What needed work was the bookkeeping around the second ask, one boundary in
|
|
`Span` computed by an expression other than the drawing it guards, and a
|
|
`rel` that meant two things. The two-step residual is structural and no
|
|
grid width fixes it; where it becomes visible is the shader's snap.
|
|
|
|
### Decided by Bryan
|
|
|
|
- **`rel` is a fraction of the containing widget's whole area.** In a span,
|
|
`rel(0.5)` is half the span whatever else is in it and wherever it sits.
|
|
It is never a fraction of what was left after earlier children. This
|
|
supersedes the 2026-09-16 reading that a report is a fraction of the box
|
|
the widget was given, wherever that box is a remainder rather than the
|
|
child's whole area.
|
|
- **`draw` stays the only layout method on `Widget`** (Bryan, 2026-09-17,
|
|
reversing the same day's acceptance of a measure/draw split). Ease of
|
|
writing a widget is half the reason. The real one is that a second method
|
|
holding the same layout drifts from the first, which a span makes
|
|
extremely easy, and the shared logic then gets pulled into helpers both
|
|
call that still have to be applied carefully in each. Where the framework
|
|
needs a widget's layout twice it runs the same body again with the
|
|
painter in a different state, or hands it more through the painter.
|
|
- **A widget's frame does not change between the ask that measures and the
|
|
ask that places.** Fractions are of the frame; the extent reaches the
|
|
widget through the painter. See "Frame and extent" below.
|
|
|
|
### A report is a fraction of the containing widget (landed, `ffd79f3`)
|
|
|
|
**Superseded in part.** Bryan refined the rule twice on 2026-09-17: a
|
|
fraction means whatever the *parent* says it means, and it need not even
|
|
mean the same thing for two siblings -- "Main point is truly that rel is
|
|
decided by parent". The span conclusion below is unchanged and the reason
|
|
for it stands (a span's offer differs per child and the row does not), but
|
|
`reports_of` is the wrong shape for saying it; see the parked padding
|
|
branch.
|
|
|
|
`rel(0.5)` is half the span whatever else is in it and wherever the child
|
|
sits. A report used to come back composed through the box it was offered,
|
|
and a span offers each child the room from its cursor, so a nested span
|
|
taking half of what it was given took a quarter of a row whose first half
|
|
was spoken for -- where the same half written as a rule on the child took
|
|
half the row.
|
|
|
|
The offer is still the remainder, because a text has to wrap at the width
|
|
actually there. What separated from it is the base a report's fractions are
|
|
of, which the ask now carries as `reports_of`:
|
|
|
|
```rust
|
|
pub fn widget_at<'s, W: ?Sized>(
|
|
&'s mut self,
|
|
id: &'s StrongWidget<W>,
|
|
region: UiRegion,
|
|
reports_of: UiVec2,
|
|
decided: [bool; 2],
|
|
) -> DrawResult<'s, 'a, W>
|
|
```
|
|
|
|
It is `region.size()` wherever the box offered is the child's whole area --
|
|
`Pad`'s inset, a `Stack` child, `Scroll`'s content -- and `Span` passes
|
|
`UiVec2::FULL_SIZE` along its row. `widget_decided` is gone; `widget_at`
|
|
says both things about an ask rather than one of them.
|
|
|
|
**A span can now overflow itself without bound**, which is the consequence
|
|
Bryan's rule asks for: two children reporting half each take the whole row
|
|
and a third starts past the end. Under the old reading `total.rel` could
|
|
not exceed one, so `Span`'s `fixed <= 0` branches were only reachable
|
|
through declared fractions; they are ordinary now, and with them boxes of
|
|
negative length passed down to children.
|
|
|
|
### An answer is not an answer while anything under it is dirty (`0e0d4af`, superseded by `a0693ac`)
|
|
|
|
**Superseded: `dirty_size_under` is deleted.** Once a resize goes through
|
|
the settling walk the ordering makes the whole category unreachable rather
|
|
than checked, which is Bryan's steer and the better answer. The defect and
|
|
its reasoning are kept below because they say what the ordering is buying.
|
|
|
|
|
|
`draw_inner` took an answer from `try_reuse`, which checks only whether the
|
|
widget itself is marked, where `retained_answer` beside it also refused one
|
|
while anything the widget read a size from was dirty. A widget whose drawing
|
|
happened to be reusable therefore handed back the answer it gave before that
|
|
descendant changed, and nothing puts it right: the comparison that tells a
|
|
reader its child's answer moved is in `redraw`, and a widget settled inside
|
|
its parent's own draw never goes through it. The placing ask redraws the
|
|
subtree, the descendant's mark is cleared there, and the parent keeps a
|
|
number the tree no longer agrees with.
|
|
|
|
So `dirty_size_under` was not the optimization its comment claimed: it was
|
|
what made an answer an answer, until the walk made the state it guarded
|
|
against impossible to be in.
|
|
|
|
Found at seed 564, depth 6, `shuffle-every-other`, reachable only once a
|
|
span could overflow itself. No hand-built tree ever reproduced it, and the
|
|
seeds at depth 4 above fail for some other reason.
|
|
|
|
### A text is handed back a box its own line fits in (landed, `4bd8607`)
|
|
|
|
A wrapping text reported the width it used through `Px::from_f32`, which
|
|
takes the nearest step and is under the line the shaper measured half the
|
|
time. A parent that sizes itself from that report -- a stack taking a span's
|
|
width, the span taking its widest child's -- then hands the text back a box
|
|
its own longest line does not fit in, and a greedy break there is one line
|
|
longer. Warm kept the break it had; cold made the narrower one.
|
|
|
|
Two tolerances were holding that together and both are gone:
|
|
|
|
- `TextBuffer::shape` answered any width within `BREAK_EPSILON_PX = 0.05` of
|
|
the longest line from the break in hand. Fifty steps of the grid, and a
|
|
structural decision taken on a hair's breadth -- the thing the `Span`
|
|
boundary invariant below already forbids. It is `want >= layout.width()`
|
|
now, exactly.
|
|
- The `Holds` range the text declares started at the nearest step to its
|
|
longest line, so it admitted boxes that line does not fit in. It starts at
|
|
`Px::ceil_from_f32` of it now.
|
|
|
|
Neither was the fix. **The fix is the report**: `Size::from_px(
|
|
PxVec2::ceil_from_f32(tex.size))`, the step at or above what was measured,
|
|
so the box that comes back fits. With it in place either tolerance could
|
|
have stayed and the case passes; both are wrong on their own terms, so both
|
|
went. `Fixed::ceil_from_f32` is new and is the only rounding on the grid
|
|
that is not to the nearest step.
|
|
|
|
The general shape, and the third time this branch has hit it: **a value that
|
|
comes back as a box has to be rounded away from the measurement, not to the
|
|
nearest step.** Rounding to nearest is right for a value being carried;
|
|
it is wrong for a bound.
|
|
|
|
### A frame settles strictly bottom-up (landed, `a92c6ac`)
|
|
|
|
The queue was already deepest-first, but a widget that could not settle
|
|
where it was called `redraw` on its parent from inside itself, which drew a
|
|
shallow widget while dirty widgets deeper in other subtrees were still
|
|
pending. A parent drawing over a subtree that has not settled reads answers
|
|
about to move, and the one that settles does so inside the parent's draw --
|
|
where its mark comes off and nothing compares what it now answers.
|
|
|
|
A widget that cannot settle defers instead: it marks its parent, stays
|
|
marked, and waits in `deferred` until the walk reaches the parent's depth,
|
|
which cannot happen before everything deeper has settled.
|
|
|
|
```rust
|
|
loop {
|
|
let next = rsc.widgets().needs_redraw.iter().copied()
|
|
.filter(|id| !self.deferred.contains(id))
|
|
.max_by_key(|&id| self.depth(id));
|
|
let Some(id) = next else { break };
|
|
if !self.redraw(id, rsc) {
|
|
self.deferred.insert(id);
|
|
}
|
|
}
|
|
```
|
|
|
|
Bryan's, 2026-09-17, and the right answer where `0e0d4af` was a check:
|
|
"then that entire category of issue can't even occur".
|
|
|
|
**The walk is sound on its own, and the second entry point is closed**
|
|
(`a0693ac`). By induction on depth: when a widget at depth d draws fresh,
|
|
every dirty widget deeper has been popped, so each is settled or deferred,
|
|
and a deferred one has marked its parent. A clean child asked by that draw
|
|
therefore has a clean subtree, because anything dirty under it would have a
|
|
dirty parent, and so on up to the child itself.
|
|
|
|
The entry point that was left was `update` drawing the root for a resize
|
|
before the walk ran, top-down over a tree with dirty widgets still in it.
|
|
It is closed by marking the root instead, so layout is one walk a frame and
|
|
`dirty_size_under` is gone at both call sites. **The root is marked only
|
|
where the new output falls outside what its answer holds for**: that range
|
|
is the intersection of everything under it, so admitting the new output
|
|
says the whole tree stands, and nothing above the root moved. Marking it
|
|
unconditionally cost the root its own `Holds` -- a leaf root that scales
|
|
with its box was drawn again on every resize, which two tests caught.
|
|
|
|
The rig says the cost is scheduling only: on `resize`, one queue pop, one
|
|
local redraw and one depth read appear and one failed reuse attempt goes,
|
|
and every other counter on `cold`, `repaint`, `many`, `size`, `scroll` and
|
|
`resize` is identical.
|
|
|
|
### A subtree that changes hands is recorded on both sides (landed, `e44dea3`)
|
|
|
|
A subtree can be reused whole under a different parent -- same box, same
|
|
layer, same region node, clean -- and nothing in the drawing says it moved.
|
|
Two things read who its parent is, and both were wrong after one of these.
|
|
|
|
- The **old parent still listed it**, and a parent's next draw undraws
|
|
whatever is missing from that list. Two spans under one root, with the
|
|
root swapping which of them it holds, drew the subtree under the new span
|
|
and then erased it when the old one drew.
|
|
- Its **depth** was the one it had under the old parent, which is what the
|
|
settling walk orders by, so a change made under it afterwards settled at
|
|
the wrong point in the frame.
|
|
|
|
Both are written where `draw_inner` already records what the ask decided:
|
|
`active.parent` is replaced and the old parent's `children` repaired, and
|
|
`try_reuse` re-walks the subtree's depths -- only where the top of it
|
|
moved, which is what makes that free in the ordinary case. Pinned by
|
|
`retained::a_subtree_that_changed_parents_is_not_undrawn_by_the_one_it_left`
|
|
and `..._settles_at_the_depth_it_moved_to`; each fails without one half.
|
|
|
|
The fuzzer never re-parents (`reshuffle` only trades children between a
|
|
span and its own spares), which is why nothing generated reached either.
|
|
|
|
### A span's leftover boundary is its own inverse (landed, `53b00c6`)
|
|
|
|
The decision used a rounded division where the room the children get is a
|
|
floored multiply, so the boundary and the drawing it guarded were two
|
|
expressions for one length. `room` is that length as a `Len`, `room.to_px`
|
|
is the multiply, and `Holds::through` is its exact preimage:
|
|
|
|
```rust
|
|
let room = Len::rel_max() - Len::from_parts(total.rel, total.px);
|
|
let mut shares = false;
|
|
if total.leftover > Weight::ZERO {
|
|
shares = room.to_px(painter.px_len(axis)) > Px::ZERO;
|
|
let holds = match shares {
|
|
true => Holds::from(Px::STEP..=Px::MAX),
|
|
false => Holds::from(Px::MIN..=Px::ZERO),
|
|
};
|
|
painter.holds(axis, holds.through(room));
|
|
}
|
|
```
|
|
|
|
The three branches were the sign of `1 - rel`, which `through` reads
|
|
already. Forty lines became twelve and one `div` left layout. The general
|
|
rule stands and is now demonstrated: **derive a boundary through the
|
|
inverse of the expression that draws, never by a second expression for the
|
|
same length.**
|
|
|
|
### Two branches parked, both real, neither ready
|
|
|
|
**Superseded on 2026-09-17: the two branches are one defect, and it is in
|
|
the protocol rather than in either widget.** See "Frame and extent" below.
|
|
Do not chase seed 1091's three steps or the Inset's 47.5 px; both are the
|
|
placing ask resolving a fraction in the answer box. What each branch got
|
|
right is kept: the `Inset`/`Outset` pair and its tests, and the stack test
|
|
asserting that half stays half. Their painter changes go.
|
|
|
|
**`wip/stack-fraction-twice`.** A stack sized by a child that reports a
|
|
*fraction* applies that fraction twice: its parent places the stack at the
|
|
reported length, and `box_of(size)` then takes the same fraction of that
|
|
box. Half a row becomes a quarter. Pixels are idempotent under a second
|
|
application, so only a share ever shrank -- and warm and cold shrink alike,
|
|
so **no oracle can see it**. Bryan: "I believe this exact thing has come up
|
|
multiple times for some reason."
|
|
|
|
The fix is that every child gets the whole box, plus `widget_at` not
|
|
resolving a rule into a box already chosen from it. `Painter::box_of` is
|
|
deleted rather than guarded: a first attempt made it answer the whole box on
|
|
a `decided` axis, which fails because `place` can reuse the stack's
|
|
*measuring* drawing by remapping it, so `Stack::draw` never re-runs. **A
|
|
drawing has to be a function of its box alone** -- if `decided` is in it, a
|
|
reused drawing is wrong.
|
|
|
|
What stops it landing: seed 1091 at depth 4, `shuffle-swap-for-three`,
|
|
disagrees by three steps where the oracle tolerates two (warm 1053.9971
|
|
against cold 1054). `box_of` was also making placement *exact*, by handing a
|
|
child a box of exactly the length it asked for, and the whole box puts a
|
|
rounding back at each nesting level. Find that composition; do not widen
|
|
`AGREE_STEPS`.
|
|
|
|
**`wip/padding-outset-and-inset`.** Padding goes outside what it pads and
|
|
never insets the child (Bryan, 2026-09-17): otherwise a child's `rel` and
|
|
`leftover` would mean the inner box while its `px` meant the outer one.
|
|
`Padding::region` moves the child's box in rather than shrinking it, a new
|
|
`Inset` widget with `.inset()` is the old behaviour, and `Pad` is to be
|
|
renamed **`Outset`** with `.outset()` to match. `in_parent_frame`'s
|
|
composition and the `reports_of` argument go with it -- a report comes up
|
|
raw and the parent says what it is a fraction of, which `Inset` does for
|
|
itself.
|
|
|
|
What stops it landing: a child declaring `rel(0.5)` under an `Inset` comes
|
|
out 47.5 px wide of the 190 inside rather than 95, and the second halving is
|
|
unaccounted for. The `Pad` half is green on its own; three tests moved to
|
|
`.inset()` because they were using padding as scaffolding rather than
|
|
testing it.
|
|
|
|
### `Span`'s leftover boundary is a third expression for the room
|
|
|
|
The decision uses a rounded division, `total.px.div(fixed)`, while the room
|
|
the children get is a floored multiply, so the two disagree at the boundary.
|
|
Measured with 300 px, `rel(2/3)` and a `leftover` child:
|
|
|
|
| row width | leftover child | its threaded length |
|
|
| --- | --- | --- |
|
|
| 900.000 | undrawn | |
|
|
| 900.001 | drawn | 0 steps |
|
|
| 900.002 | drawn | 0 steps |
|
|
|
|
Harmless at two steps, but the three-branch block collapses into the inverse
|
|
that already exists. `room` is computed a few lines below the block as
|
|
`Len::rel_max() - Len::from_parts(total.rel, total.px)`, and its `to_px` is
|
|
exactly the threaded length the leftover children share:
|
|
|
|
```rust
|
|
let room = Len::rel_max() - Len::from_parts(total.rel, total.px);
|
|
let mut shares = false;
|
|
if total.leftover > Weight::ZERO {
|
|
shares = room.to_px(painter.px_len(axis)) > Px::ZERO;
|
|
let holds = match shares {
|
|
true => Holds::from(Px::STEP..=Px::MAX),
|
|
false => Holds::from(Px::MIN..=Px::ZERO),
|
|
};
|
|
painter.holds(axis, holds.through(room));
|
|
}
|
|
```
|
|
|
|
`through` already handles a negative fraction and a zero one, so the
|
|
`fixed < 0` and `fixed == 0` branches go with it. The general lesson: `mul`
|
|
floors while `div`, `div_int` and `ratio` round to nearest, so a boundary
|
|
derived with a division guards a drawing made with a multiply. Derive
|
|
boundaries through `through`, or make the grid floor everywhere.
|
|
|
|
### Where the residual comes from, and the snap
|
|
|
|
The `e44dea3` baseline's two-step allowance came from inverse remapping and
|
|
alignment composed by different routes. The frame/extent continuation below
|
|
retains local widget frames as well as local primitive coordinates, recomposes
|
|
in the same order as a cold draw, and removes inverse remapping. Its oracle
|
|
requires exact pixel-region equality. That experiment is not yet the pinned
|
|
framework; the baseline's snap decision below still applies there.
|
|
|
|
Where it does matter is `snap_floor` in `prelude.wgsl`, which adds half a
|
|
layout step before flooring: that absorbs float error and not a layout
|
|
step, and truncation makes "one step under an integer" the common residue.
|
|
A third of 900 px is 299.999 on the grid and lands at 299 on screen, which
|
|
is the pixel `08c9d5a` moved `tabs`'s arcs by. Rounding to the nearest
|
|
pixel absorbs both the truncation and the two-step residual everywhere
|
|
except within two steps of a half pixel, where layout never lands on
|
|
purpose, and keeps integer widths for equal fractional parts:
|
|
|
|
```wgsl
|
|
fn snap_floor(v: vec2<f32>) -> vec2<f32> {
|
|
return floor(v + 0.5);
|
|
}
|
|
```
|
|
|
|
Approved by Bryan on 2026-09-17, together with rounding on the CPU; see
|
|
item 5 under "Next" for why both, and what each does not fix. The check is
|
|
the reference render set plus the oracle; expect `tabs` to move its arcs
|
|
back.
|
|
|
|
### Smaller items
|
|
|
|
- The comment on the `local == UiRegion::FULL` shortcut in `widget_at` says
|
|
composing through `FULL` "is not quite the identity in f32". On the grid
|
|
it is exact; the shortcut is performance only now.
|
|
- An undrawn `leftover` child still contributes its gap, so a vanished
|
|
child leaves a double gap.
|
|
- Nested spans pass `leftover` weight up, so three leftover children in one
|
|
inner span beside one in another get three quarters to one quarter. No
|
|
other layout system does that, and the doc's old example of two and two
|
|
did not distinguish it from per-span division. Confirm it is wanted.
|
|
- `Fixed::div` by zero answers `MIN`/`MAX` while `ratio` answers `ZERO`;
|
|
both are caller bugs under `debug_assert`, but the fallbacks differ.
|
|
|
|
### Frame and extent: retained prototype
|
|
|
|
The original proposal was implemented by Claude as `5fcace1` on
|
|
`wip/region-and-placement` in `/home/bob/repos/iris-pr18`. It kept the fraction
|
|
reference stable but failed five layout cases and three draw-count cases.
|
|
The first correction is `efb416b`, exact recomposition is `2ed5503`, and the
|
|
current performance/alignment continuation is **`7601aa2`**, pushed to origin's
|
|
`wip/region-and-placement`, in the isolated checkout
|
|
`/home/bob/repos/iris-layout-experiment`; **it is not on PR #18**. The original
|
|
checkout is unchanged. Keep the experiment's build directory separate: Cargo
|
|
can accept a different worktree's artifacts as fresh when a target directory
|
|
is shared. The comparison checkout's source timestamps can precede its build.
|
|
|
|
**The distinction stays.** `Painter::region` is the frame, the parent's chosen
|
|
reference for fractions. `placement` is the extent in that frame. A sizing
|
|
report must not replace the reference against which that report was obtained.
|
|
`px_len` reads the extent's pixel length; `region_px_len` reads the frame's.
|
|
Text wraps at the available extent, not the whole fraction reference.
|
|
|
|
**The offer includes availability.** A span can keep the same reference frame
|
|
while offering a text less room after an earlier sibling. `measure_len` therefore
|
|
accepts the placement as well as the frame. The first measurement's
|
|
`offer_placement` survives later placing evaluations, and local redraw asks
|
|
that original question before restoring the assigned slot. Frame-length
|
|
equality alone no longer identifies the measurement question.
|
|
|
|
**An answer and a drawing each retain their dependencies.** `LayoutHolds`
|
|
keeps frame ranges, extent ranges, and an optional raw placement snapshot.
|
|
`px_len` narrows one extent axis without making position or the other axis a
|
|
dependency; `holds` widens that extent range without erasing a frame read.
|
|
`Holds::through` still inverts one fixed mapping exactly. A measured answer
|
|
and the final drawing can only substitute for one another when those
|
|
independent inputs satisfy the retained contract. The old fallback checked
|
|
only frame ranges, so text placed at a fixed pixel width answered a narrower
|
|
window's measurement with its previous line breaks.
|
|
|
|
**Geometry retains its reference.** `DrawRegion::{Frame, Extent}` records local
|
|
primitive and mask coordinates before composition. Ordinary `primitive()`
|
|
follows the extent; explicit `UiRegion` arguments remain frame-relative.
|
|
Text glyphs use extent-relative origins. `Painter::widget()` records a child
|
|
that inherits placement, so a pass-through wrapper and its valid descendants
|
|
can be repositioned without rerunning their draw bodies. `placement()` is
|
|
still a conservative raw read for arbitrary widget computations.
|
|
|
|
**No measurement is different from a measured zero.** The first wider scan
|
|
found seeds 560 (`shuffle-swap-for-three`) and 1690 (`shuffle-add-three`) at
|
|
depth 4. A previously undrawn pure share had only supplied a hint; its
|
|
placeholder zero answer became reusable once it was drawn during placement.
|
|
`ActiveData::answer` is now optional. Both seeds pass after that correction,
|
|
and `adding_text_to_a_reverse_row_keeps_its_shared_height` reproduces the
|
|
zero-height failure with a small tree.
|
|
|
|
**Exact recomposition and reuse.** The continuation replaces `given_len` with
|
|
`given_region`: each widget retains its original frame in its parent's
|
|
coordinates. Moving a retained subtree replays those same local compositions,
|
|
and stops at region nodes. `RegionRemap`, `AxisRemap`, and inverse division
|
|
are gone. Fixed-pixel frames can now change size without forcing otherwise
|
|
valid descendants to draw again. This adds 16 bytes per active widget; local
|
|
primitive coordinates were already retained by `efb416b`.
|
|
|
|
The shared oracle now compares pixel regions with ordinary equality; there
|
|
is no `AGREE_STEPS` allowance. Text publishes the range of its retained line
|
|
breaks rather than narrowing that range to every later
|
|
requested width. A layout with only explicit line breaks or no breaks holds
|
|
at arbitrarily wider widths. New tests compare actual primitive and mask
|
|
geometry and assert reuse for fixed-frame resizing and widening unwrapped
|
|
text (including explicit newlines and empty content).
|
|
|
|
**Measure the offer without placing an intermediate answer.** `Painter::measure_len`
|
|
replaces the hint/cache query and its duplicated drawing fallback. It still runs
|
|
`Widget::draw` when needed, but leaves that drawing in the offer until the parent
|
|
assigns the child's slot. Placing the child's own answer first was wasted work:
|
|
the parent immediately replaced that placement. The regression counts three
|
|
rather than four evaluations for a numeric-size leaf in a span, checks its actual
|
|
primitive bounds, and repeats after resize. There is no second layout body on
|
|
`Widget`. `Holds::ANY.through(...)` now returns `ANY` directly: an unrestricted
|
|
range needs no inverse division, including for a negative fraction.
|
|
|
|
**A chosen slot does not cancel a child's alignment.** Bryan caught this in the
|
|
`tabs` image: a fixed 100 px square under a flexible wrapper sat at the slot's
|
|
near edge. `ask_box` now aligns a declared frame inside the chosen slot, just as
|
|
it does inside an unassigned offer. A regression asserts the actual coordinates
|
|
before and after resize. Warm/cold equality could not find this because both
|
|
were wrong. The corrected 900 px image still has 88 px between blue and red and
|
|
78 px between red and orange: the enclosing `.pad(10)` adds 10 px before the
|
|
red slot. Red is centered in that slot, 5 px right of the visible black gap's
|
|
center. Do not describe those two centers as the same thing or compensate with
|
|
an arbitrary offset. The parked padding API change is still separate.
|
|
|
|
Ordinary verification passes: formatting, workspace clippy with
|
|
`layout-diagnostics`, workspace tests (100 suite tests and 21 core tests), and
|
|
the fast generated cases. The corrected `tabs` resize from 1920x1200 to
|
|
900x1200 matches a cold image with zero differing pixels. The release oracle
|
|
passes 2000 seeds at depth 4 and 1000 at depth 6; the shrinker passes 400 at
|
|
depth 5, and the debug oracle passes 120 at depth 4 with assertions enabled.
|
|
Every scan runs all fifteen scenarios with exact equality. The measurement
|
|
regression fails with four draws instead of three when the optimization is
|
|
disabled.
|
|
|
|
**Still not ready to replace PR #18.** The prototype does substantially more
|
|
nested layout work than `e44dea3`, even after removing intermediate placements
|
|
(seed 1, depth 8):
|
|
|
|
| phase | widget draws at e44dea3 | current | primitive writes at e44dea3 | current |
|
|
| --- | ---: | ---: | ---: | ---: |
|
|
| cold | 369 | 484 | 9635 | 9179 |
|
|
| repaint | 1 | 1 | 1 | 1 |
|
|
| many | 157 | 274 | 4721 | 5873 |
|
|
| size | 16 | 247 | 106 | 5200 |
|
|
| scroll | 2 | 2 | 0 | 0 |
|
|
| resize | 13 | 292 | 0 | 5730 |
|
|
|
|
These are instrumented work counts, not speedups (50 frames per phase except
|
|
cold, which is one; 578 generated widgets, 191 finally active). Against
|
|
`2ed5503`, the changes remove 7 widget draws and 525 primitive writes per size
|
|
frame, and 8 draws and 525 writes per resize frame.
|
|
|
|
**Measure cycles as well as instructions** (Bryan, 2026-09-17). The following
|
|
are whole-process medians from seven alternating before/after runs of direct,
|
|
uninstrumented release test executables under `perf stat -e
|
|
cycles:u,instructions:u`. The baseline is `39f7b08`: `2ed5503` with the same
|
|
centering fix, so both sides draw the corrected layout. CPU-heavy scans were stopped
|
|
during measurement. Counts include fixture setup; size, resize and many run
|
|
5000 frames, repaint and scroll run 1,000,000. These are VM CPU measurements,
|
|
not phone frame times.
|
|
|
|
| phase | cycles before → after (billions) | instructions before → after (billions) |
|
|
| --- | ---: | ---: |
|
|
| size | 12.8008 → 11.0344 (-13.8%) | 34.3660 → 29.9450 (-12.9%) |
|
|
| resize | 14.9368 → 13.0376 (-12.7%) | 39.8487 → 35.0032 (-12.2%) |
|
|
| many | 14.8875 → 12.9579 (-13.0%) | 39.6021 → 34.7499 (-12.3%) |
|
|
| repaint | 2.7416 → 2.6626 (-2.9%) | 6.0266 → 6.0197 (-0.1%) |
|
|
| scroll | 9.7848 → 8.4829 (-13.3%) | 21.7203 → 20.9527 (-3.5%) |
|
|
|
|
The size/resize/many cycle samples have nonoverlapping before/after ranges,
|
|
with each range less than 1.3% of its median. Additional depth-6 fixtures,
|
|
10,000 frames and seven alternating pairs each: seed 3 improves cycles by
|
|
7.0% for size and 8.4% for resize; seed 13 improves resize by 9.2%, while its
|
|
size samples overlap and establish no cycle improvement.
|
|
|
|
**The remaining gap has not been established as an unavoidable correctness
|
|
cost.** A temporary failure trace of one frame on seed 1, depth 8 counted
|
|
39/108 failed reuse attempts in size, 65/151 in resize, and 45/109 in many
|
|
where only the raw placement snapshot differed: both numeric frame and extent
|
|
ranges still held. Containers read that snapshot to construct child positions,
|
|
so discarding the check would be wrong; a way to retain those child positions
|
|
relative to the extent is the next protocol question. There is also necessary
|
|
width-dependent work: the hot text at widget 199 visits 68 px and 89.53613 px
|
|
and reports different heights (316.80078 and 228.80078). It is evaluated
|
|
repeatedly at those widths, so that fact alone does not justify all the repeats.
|
|
|
|
Keeping measurement validity separate from final drawing validity and deferring
|
|
to an already dirty parent were previously tried without improving draw counts.
|
|
Measuring first in local redraw and then restoring its slot saved only one draw
|
|
in `many` and added another request on simple repaints; that trial was removed.
|
|
Retaining local coordinates still costs memory per primitive. Optimize under
|
|
the exact oracle before adopting the prototype; the `Inset`/`Outset` changes
|
|
remain parked.
|
|
|
|
## How layout is decided
|
|
|
|
### Fixed point
|
|
|
|
Decided with Bryan on 2026-09-15. Layout decides on a grid rather than in
|
|
floats.
|
|
|
|
- **`Fixed<SHIFT>` is an `i32` counting `1 / 2^SHIFT`.** Adding and
|
|
subtracting are exact; `mul` drops to the step below (Bryan, 2026-09-16:
|
|
truncation is preferable); `div`, `div_int` and `ratio` round to nearest;
|
|
`to_scale` takes the nearest step. Two routes to one place that land on
|
|
one number are the same place, so everything downstream compares for
|
|
equality.
|
|
- **`Px` is `1/1024` px, `Rel` is `1/2^24` of a box, `Weight` is `1/65536`
|
|
of a share.** `PX_SHIFT` and `REL_SHIFT` are the only statement of the
|
|
first two; the shader's copy is prepended from them by
|
|
`render::module_source`. `Px` was `1/64` first, where one rounding's
|
|
residue was 0.016 px and enough to move a box. Range is +/-2.1M px and
|
|
conversion to `f32` is exact to 16,384 px.
|
|
- A weight is not a fraction: a list divides its room by the total of its
|
|
weights, and `Rel::ratio` turns two weights into a share on the finer
|
|
grid.
|
|
- **Arithmetic wraps** (`4febabf`, Bryan: a coordinate past the range will
|
|
not draw reasonably anyway, so wrap and break clearly). Saturating cost a
|
|
twelfth of layout's instructions. `MIN` and `MAX` stand in for an
|
|
unbounded end and are only ever compared against; `from_f32` is the one
|
|
operation that clamps, and `Holds` keeps a saturating `narrow`.
|
|
- A pointer, a wheel notch, a shaped glyph advance and a window size arrive
|
|
as floats and go on the grid where they arrive. `Vec2` is what the GPU
|
|
and the platform speak; `PxVec2` is what layout decides in.
|
|
- **Do not widen the grid to chase a residue.** Every failure this branch
|
|
saw was one value reached by two expressions, sitting on a boundary
|
|
defined by the same value coming back the other way. No precision shrinks
|
|
a residue that is the whole distance.
|
|
|
|
### A box in pixels is one multiply from its parent's
|
|
|
|
`ActiveData` keeps a widget's box as lengths of its parent's box --
|
|
`given_len`, and `offer_len` for the box it was first asked about --
|
|
`DrawInfo` carries the pixel lengths themselves (`px`, `offered_px`), and a
|
|
draw threads them down one `Len::to_px` at a time: the box its parent gave
|
|
it, then the part of that box its own answer placed its drawing in, which
|
|
`placed_lens` states once for both `placed_box` and the walk.
|
|
`Painter::px_size` and `px_len` read that value, and
|
|
`UiRenderState::asked_px` takes the same steps back up the parent chain
|
|
when a local redraw starts part-way down the tree. Neither chain has a
|
|
coordinate frame in it, so a region node cannot break either, and warm and
|
|
cold reach every length by the same expression.
|
|
|
|
- **`Holds::through` is the exact preimage of `px + floor(rel * box)`**:
|
|
`floor(rel * B) >= lo - px` is `rel * B >= (lo - px) << REL` and
|
|
`floor(rel * B) <= hi - px` is `rel * B < (hi - px + 1) << REL`, two
|
|
`div_toward`s once the sign of `rel` has said which bound is which. The
|
|
answer is an interval even for a single length, because a floor is not
|
|
invertible. The range has to contain the box a drawing was made in (the
|
|
`Holds` assertion in `draw_at`, debug only) and must not contain a box
|
|
the drawing does not hold for (the oracle); being the preimage makes
|
|
those one statement rather than a trade-off.
|
|
- **Symbolic regions are for the GPU, hit testing and remaps alone.**
|
|
`Moves::resolve` is the only walk left and it is the vertex shader's.
|
|
Nothing layout decides is composed back up the move chain.
|
|
- **`px` is not stored on `ActiveData`, deliberately.** A resize every
|
|
widget's `Holds` admits redraws nothing, so a stored pixel length would
|
|
be stale on every widget in the tree with nothing to say so. `asked_px`
|
|
walks up only where a widget is already being redrawn; the mean chain is
|
|
2.8 levels.
|
|
- **The window is not a move entry** (`5b78002`). A chain bottoms out in
|
|
`MoveIdx::NONE`; the window is applied where a fraction becomes pixels,
|
|
`to_px(output_size)` on the CPU and the uniform in the shader. A resize
|
|
rewrites no retained entry and re-uploads nothing but the uniform; its
|
|
cost is whatever `Holds` redraws.
|
|
- Failed hypothesis, kept as the shape of the mistake: an offer composed
|
|
back up the chain fell back to `FULL` under a region node and was resolved
|
|
against that node's *placed* box, so everything under a `Scroll` was
|
|
re-asked at the content's width and confirmed its own answer. Pinned by
|
|
`unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered`.
|
|
The old chain with an allowance in `through` passed that case and the old
|
|
chain with the exact `through` failed it; both halves had to land at once.
|
|
|
|
### What the fuzzers tolerate
|
|
|
|
The frame/extent continuation removes `AGREE_STEPS`: warm and cold pixel
|
|
regions must compare exactly. This is distinct from the equal-share test:
|
|
when a row's grid-step count is not divisible by the number of children,
|
|
individual share widths can differ while every rerun of that layout must
|
|
still agree exactly. The PR #18 baseline still allows two position steps;
|
|
its earlier failure at one step (`resize-size`, seeds 384 and 162 at depth 5)
|
|
is a useful regression target for the experimental recomposition.
|
|
|
|
## Retained-layout invariants
|
|
|
|
- `Holds` is the interval of box lengths for which a widget's drawing and
|
|
reported size stay valid. Reading `Painter::px_len` or `px_size` narrows
|
|
it to the length read; `Painter::holds` widens it. Parent validity is the
|
|
intersection of what its children induce. The contract is trusted: a
|
|
widget declaring a wrong range is a defective widget, and Iris adds no
|
|
defensive work to recover from one.
|
|
- A retained drawing can be reused only when its `Holds` contains the new
|
|
pixel box on both axes, its parent node is unchanged, its region-node
|
|
choice matches the retained structure, it is on the layer it is asked
|
|
for, and the widget is clean. A valid ordinary subtree moves without
|
|
redrawing by recursive remap; a region node moves by one entry.
|
|
- **A retained drawing belongs to the layer it was made on.** A container
|
|
that measures a child by drawing it measures on the layer that child will
|
|
draw on -- `Painter::child_layer_at` -- or it pays two draws a frame.
|
|
- The first box a parent asks about is the offer; a later box chosen from
|
|
the answer is the final box, not another answer. A dirty widget is
|
|
re-asked in the box its parent gave it, and only where that box is as
|
|
long as the offer; anything else is its parent's question, with the mark
|
|
left on. Lengths and not whole boxes: what a drawing depends on is its
|
|
lengths, so the same lengths elsewhere is the same question.
|
|
- An answer is reusable only where both its measurement and the drawing in
|
|
its final placed box remain valid; the drawing's `Holds` is translated
|
|
back through `placed_lens` and intersected with the answer's.
|
|
- `Painter::widget_decided(child, region, [bool; 2])` says the parent chose
|
|
this box from the child's own answer along those axes, so the answer is
|
|
not placed inside it again. A report of "half of what you give me" has no
|
|
fixed point but zero, so the framework asks exactly twice: at the offer,
|
|
and in the box chosen from the answer, final on the decided axes. `Span`
|
|
decides the row axis, `Scroll` both, `Stack` both for its sizing child.
|
|
`Pad` overrides nothing: its inset is exactly the inner where the box is
|
|
its answer, and the slack is the inner's to sit in otherwise.
|
|
- **Placement cannot be applied after the fact.** Three attempts at "draw
|
|
the widget, then move its drawing to where its alignment says" failed,
|
|
because the move is a change of frame and no split of the stored state
|
|
carries it: moving `ActiveData::region` with the drawing made a later
|
|
local redraw ask a differently rounded question, and leaving it made
|
|
`placed` accumulate without bound because `try_reuse` returns a clean
|
|
subtree's size without walking into it. Alignment is applied where the
|
|
size is known -- `declared_box` for a rule, the placing second ask
|
|
otherwise.
|
|
- A widget that clips to its box reports its box: `Scroll` and `Masked`
|
|
report `LEFTOVER` on both axes, and a `debug_assert` holds any widget
|
|
that set a mask this draw to it. Overflowing is otherwise ordinary, which
|
|
is why the assertion is narrowed to mask-setters. Where content shorter
|
|
than a `Scroll`'s viewport sits is the scroll's own alignment, and its
|
|
"fits at the start of any box" widening is gated on near alignment.
|
|
- **A widget's own mask is not the one it inherited.** `ActiveData` keeps
|
|
both; they differ exactly where the widget called `set_mask`, which says
|
|
whose mask a move rewrites, and a local redraw is handed the inherited
|
|
one. Pinned by
|
|
`retained::a_masked_widget_redrawn_on_its_own_sets_its_mask_again`.
|
|
- A span is as long across itself as its longest fixed child, unless a rule
|
|
gives that length outright (`Painter::has_exact_size`), in which case it
|
|
does not read its children there at all. Any relative or `leftover` child
|
|
makes it report `leftover`. **Do not choose between a fixed and a relative
|
|
child in pixels at the span's current width**: that admits multiple
|
|
self-sizing fixed points, and generated seed 13 settled differently warm
|
|
and cold under it. The same circularity is what a cap containing
|
|
`leftover` would put into `SizeRule::Max`.
|
|
- `Span`'s leftover/no-leftover split is a strict layout decision, not a
|
|
rounding tolerance: its `Holds` range must use the same exact boundary as
|
|
drawing. A tolerant endpoint retained zero-height children in seed 16; a
|
|
boundary moved off where boxes land was needed in floats and is not on
|
|
the grid. **A structural decision may not be taken on a hair's breadth**
|
|
that two routes can disagree about. Pinned by
|
|
`unsettled::a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over`.
|
|
- A pixel comparison is equality. A length given in pixels is that many
|
|
pixels wherever it ends up, structurally: `Len::within` adds a part's own
|
|
pixels rather than scaling them. Pinned by
|
|
`a_length_in_pixels_is_that_many_pixels_however_it_is_nested`. A length
|
|
given as a share is not: equal shares come out one or two steps apart
|
|
because positions, not lengths, are what gets rounded, so the row fills
|
|
and no two children leave a seam
|
|
(`equal_shares_differ_by_at_most_two_steps_and_fill_the_row`).
|
|
- **A move that keeps a box's length is a translation, and exact.** A box
|
|
that changed length re-expresses each part as a fraction of the new one,
|
|
which rounds. This inverts the float-era rule; `tests/cases/drift.rs`
|
|
pins that the grid does not drift either way.
|
|
- `Scroll` must return the answer from the first box it asked about,
|
|
whether retained or fresh; returning the final placed answer advanced one
|
|
fixed-point iteration (seed 86). Content that fills the viewport unscrolled
|
|
is handed back as it came, because the same box written as its own
|
|
length in pixels does not round alike.
|
|
- An asked-but-undrawn size dependency names the widget that asked as its
|
|
parent (seed 10). `Painter` records size-dependency edges only when a
|
|
parent reads a child's size or hint; an undrawn measured child stays
|
|
recorded so a later change reaches whoever decided not to draw it.
|
|
- Dirty widgets settle deepest-first, and that ordering is what makes an
|
|
answer trustworthy; `dirty_size_under` is to be deleted once a resize goes
|
|
through the same walk (see "A frame settles strictly bottom-up").
|
|
- Declared non-`leftover` lengths are resolved by the parent where the
|
|
widget is drawn, so a declared-length change redraws the parent. A rule
|
|
wins on the axis it names and the widget under it never learns of it.
|
|
**A cap may not contain `leftover`**: a cap must read the report, so rule
|
|
and report are one equation, and a share puts the row's division into it
|
|
-- the multiple-fixed-point failure again. A cap is pixels and a fraction,
|
|
which is what `Len` is.
|
|
- Text shaping is retained separately from line breaking; a greedy break
|
|
holds from its longest produced line through the width it was made at,
|
|
reported through `Painter::holds`.
|
|
- Region nodes: a node holds a whole `UiRegion` in its parent node's
|
|
coordinates, `FULL` is the identity, widgets opt in with `.region_node()`
|
|
or `Widgets::set_region_node`, and changing it redraws the subtree once.
|
|
`.scrollable()` sets it once; raw `Scroll::new` does not. A removed node's
|
|
move entry stays alive until every descendant has migrated. `Span` and
|
|
`Align` add no nodes.
|
|
- Alignment is one `f32` per axis (Bryan, 2026-09-15), default the middle
|
|
on both because the edges assume a direction. One widget keeps one length
|
|
per axis; a second length needs a second widget, `Wrapper` via
|
|
`.wrapper()` (Bryan, 2026-09-16, `d21a215`).
|
|
|
|
## Verification at the current head
|
|
|
|
At `e44dea3`:
|
|
|
|
- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets --
|
|
-D warnings`, `cargo test --workspace`: green, 92 suite tests, 19 core
|
|
unit tests, 11 generated cases. Only the long runs and the profiling rigs
|
|
are ignored; no known defect is.
|
|
- The release oracle at 100 seeds in 14.2 s, and **120 seeds in debug** in
|
|
59 s -- the debug run exercises the `Holds` assertion in `draw_at`.
|
|
- All fifteen shrinker cases at 400 seeds of depth 5 in 57 s, the oracle at
|
|
1000 seeds of depth 6 in 143 s, and **2000 seeds at depth 4 over all
|
|
fifteen cases** in 260 s. The last is not routine and should be: it is
|
|
the only run that has ever found anything past seed 400.
|
|
- `view`, `minimal`, `random`, `tabs` and `text` byte-identical at
|
|
1920x1200 against `25e456e`, as is `tabs` under the recorded replay.
|
|
`random` live-resized from 1920x1200 to 1280x800 is byte-identical to a
|
|
cold 1280x800 render. Rendered through Venus on the host's RX 7900 XT,
|
|
confirmed against `vulkaninfo --summary` in the same session -- an
|
|
llvmpipe fallback makes the same PNG and nothing in it says so.
|
|
- All rig counters identical on `cold`, `repaint`, `many`, `size` and
|
|
`scroll` across `25e456e`. `resize` gains one queue pop, one local
|
|
redraw and one depth read and loses one failed reuse attempt, which is
|
|
the root going through the walk; drawn widgets, widget draws, draw
|
|
requests and primitive writes do not move.
|
|
|
|
Everything above is verification of what was changed, not a claim that the
|
|
branch is correct.
|
|
|
|
**A claim about a render holds for the commit it was checked at and no
|
|
further.** `tabs` changed twice across `d3b0ebf` with nobody looking; take
|
|
the oracle as the reference and the five renders as a spot check.
|
|
|
|
**Run the long two before believing a rounding change**, and run the
|
|
ordinary suite in debug:
|
|
|
|
```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
|
|
```
|
|
|
|
Depth is what finds things, but so is breadth: every late defect before
|
|
2026-09-17 surfaced at depth 5 or 6, and the two open ones were found by
|
|
running 2000 seeds at depth 4, which nothing routine does. Widen one axis
|
|
at a time and record which.
|
|
|
|
## Performance
|
|
|
|
**Threading a box in pixels down the draw is free on cold layout and 9-13%
|
|
off the retained paths** (2026-09-17). Instructions:u, medians of 21 runs of
|
|
binaries built in one worktree, seed 1 at depth 8, against `5b78002`:
|
|
|
|
| phase | before | after | |
|
|
| --- | --- | --- | --- |
|
|
| `cold`, 200 frames | 313.1M | 312.9M | -0.04% |
|
|
| `resize` | 408.1M | 405.6M | -0.61% |
|
|
| `many` | 1,924M | 1,756M | -8.75% |
|
|
| `scroll` | 357.3M | 323.4M | -9.49% |
|
|
| `repaint` | 363.3M | 315.4M | -13.18% |
|
|
|
|
`cold` and `resize` compare directly: all twenty-five work counters are
|
|
identical. The other three do less work: `repaint` goes from 23 draw
|
|
requests and 13 widget draws to 1 and 1, because `redraw` composes nothing
|
|
and a widget whose box moved without changing length settles itself instead
|
|
of escalating.
|
|
|
|
### How to measure here
|
|
|
|
- **Check the work counters before comparing two commits' times.** The rig
|
|
prints drawn widgets, widget draws and primitive writes; a comparison is
|
|
only worth reading when they match. `random.rs`'s `Branch` picks a
|
|
subtree by a measured pixel length, so the fixture's shape moves with the
|
|
thing measured; `Edits::fixed_branches` pins it for timing and the oracle
|
|
keeps measured branches on purpose. A 3x this section once reported was
|
|
that artifact.
|
|
- **`perf stat` in this VM returns garbage readings** for both
|
|
`instructions:u` and `cycles:u`, roughly a quarter of the time, off by a
|
|
factor of five to fifteen. Take medians of nine or more and report how
|
|
many readings a filter kept. Cycles spread 1-3% between sets of one
|
|
unchanged binary and 6.7% in the worst; instruction counts hold to 0.02%
|
|
within a binary and move 0.5% across a rebuild, so build the baseline
|
|
beside the thing measured and quote a delta. `ex_div_busy` held to 0.1%.
|
|
- **What moves cycles is whether `UiSpan::within` inlines.** It is the
|
|
hottest line in layout; `nm` shows it as a symbol when it does not.
|
|
Shrinking its body until the inliner takes it won; `#[inline]` on the
|
|
body it had lost 1.5% cycles. Shrink it, do not annotate it.
|
|
- `Holds::through` divides twice per call and accounts for essentially all
|
|
of a run's `i64` divisions: 21.3M cycles of a 500-frame `many`, 2.8%.
|
|
The float head divided twice there too.
|
|
|
|
### Tried and rejected, with numbers
|
|
|
|
- A float reciprocal for `AxisRemap::apply_scalar`'s division: +6% cycles.
|
|
`Holds::through`'s division has not been tried.
|
|
- Branchless `shift_round`: +6.7% cycles alone, and worse again with the
|
|
short-circuits removed. Size, not the branch, is what keeps `within` out
|
|
of line.
|
|
- Removing the per-child hash lookup in `remap_subtree`: 0.0%.
|
|
- Short-circuiting `apply_scalar` where the fraction is nought or one: +17%.
|
|
- Short-circuits guarding a saturating multiply stopped paying once the
|
|
multiply wrapped. Re-price a short-circuit before keeping it.
|
|
- Rust does not contract `a + b * c`; the float head never had an FMA to
|
|
compare the grid's multiply against.
|
|
|
|
Wrapping (`4febabf`) was -8.6% instructions and -6.6% cycles. Truncating
|
|
(`08c9d5a`, with `Fixed::scaled`'s zero test and `within`'s `is_full` tests
|
|
removed as one commit, since they are worth 61M instructions apart and 115M
|
|
together) costs a share a thousandth of a pixel of its row, makes a flipped
|
|
span sit a step from its mirror, and moved an antialiased edge in `tabs` by
|
|
one pixel. See "Where the residual comes from" for what that last one is.
|
|
|
|
## 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
|
|
```
|
|
|
|
The ordinary tests are modules of one `tests/suite.rs` target; pick a module
|
|
with `cargo test --test suite layout::`. `profile.test` uses
|
|
`debug = "line-tables-only"`, which halved the test-target rebuild.
|
|
|
|
`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 --
|
|
`iris::random::plan(seed, depth, &edits)` and `build(rsc, &plan)`, so a
|
|
failing seed reduces directly and the oracle prints the command:
|
|
|
|
```sh
|
|
SHRINK_SEED=18 SHRINK_DEPTH=6 SHRINK_CASE=repaint-some \
|
|
cargo test --release --test shrink -- --ignored --nocapture
|
|
```
|
|
|
|
The cases live in `tests/scenario/mod.rs`, included by both targets by
|
|
`#[path]`; a case only one rig knows is how the two drifted apart once. Turn
|
|
what the shrinker finds into a test of its own rather than leaving a seed as
|
|
the record. Both fuzzers take a thread per core but one. A `git bisect`
|
|
once named a commit that could not be the cause; read the tree rather than
|
|
the bisect when that happens.
|
|
|
|
`tests/layout_diagnostics.rs` is the retained CPU rig: `IRIS_PHASE` selects
|
|
`cold`, `many`, `repaint`, `size`, `scroll` or `resize`, the
|
|
`layout-diagnostics` feature gives the explanatory counters, and an
|
|
uninstrumented release binary under `perf` gives totals. Dump the counters
|
|
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` two runs; identical output is what says a change is free.
|
|
|
|
The float head is checked out at `/home/bob/repos/iris-float-cmp`, at
|
|
`5ed9e87` with `Edits::fixed_branches` applied uncommitted. Its counters do
|
|
not match the grid's and will not, so a comparison against it is a bound
|
|
rather than a measurement.
|
|
|
|
The headless reference set runs one process at a time because the rig
|
|
reuses one compositor; comparison worktrees need separate target
|
|
directories.
|
|
|
|
```sh
|
|
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/tabs.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 reference check:
|
|
|
|
```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
|
|
```
|
|
|
|
## Next
|
|
|
|
In order, from the review above and Bryan's steer (2026-09-17):
|
|
|
|
1. ~~A resize marks the root and goes through the walk.~~ Landed as
|
|
`a0693ac` and `e44dea3`; see the two sections above. The re-parenting
|
|
half turned out to be two defects rather than the predicted one, and
|
|
neither was the `depth()` assertion.
|
|
2. **Frame and extent**, as written above. This is the fundamental change
|
|
and comes before anything built on the placing ask. It lands the two
|
|
parked branches' tests (the stack test unchanged, `Inset`/`Outset` with
|
|
the rename and the `.pad()` audit), flips
|
|
`a_span_reads_a_child_report_as_a_fraction_of_the_row`, and deletes
|
|
`box_of`, `reports_of`'s composition in `in_parent_frame`, and the
|
|
`through(lens)` translation in `draw_inner`. Run the long fuzzers and
|
|
the render set once for it.
|
|
3. Write `ActiveData::answer` in one place.
|
|
4. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the
|
|
reconstruction in `redraw`.
|
|
5. **Round on the CPU and snap to the nearest pixel in the shader**, as
|
|
one change with one verification. Bryan approved the snap on 2026-09-17
|
|
(rendering may change wherever it brings the screen closer to what the
|
|
user's code says: three equal sections of 1000 px need one of them
|
|
rounded up), and to CPU rounding the same day. The reason for that one
|
|
is different: a
|
|
`Rel` is off by at most `2^-25` of its box, so with round-to-nearest
|
|
every product whose true value is a whole number of steps is exact for
|
|
boxes under about 8,000 px, where truncation leaves half of them one
|
|
step short and layout then decides "does not fit" on a container the
|
|
user meant to fit exactly. Use the branchless round-half-up form,
|
|
`(a * b + (1 << (BY - 1))) >> BY`, not the sign-branching
|
|
`shift_round`; re-derive `Holds::through` for `round` (its two shifted
|
|
bounds move by half a `Rel` step); check with `nm` that `UiSpan::within`
|
|
still inlines; expect a couple of percent of instructions and re-run the
|
|
long fuzzers and the render set once for both.
|
|
6. The smaller items: the stale `f32` comment, the gap of an undrawn child,
|
|
confirm nested `leftover` weights, one zero-divisor fallback. Add to
|
|
them: a span that overflows itself hands a child a box of negative
|
|
length, which is ordinary now rather than a corner, and nothing states
|
|
what a widget may assume about one.
|
|
7. `LazySpan`, the next LAYOUT.md §2 item. Region nodes cover the movable
|
|
subtree case; do not restore a separate child-placement API.
|
|
8. `SizeRule::{Min, Max, Clamp}`, restoring the `max_width`/`max_height`
|
|
builders `8220a78` deleted. The clamp boundary is a hard layout decision
|
|
with an exact `Holds` split at the crossover, both sides in `Px`. Still
|
|
awaiting Bryan: whether a `Max` narrows the box the child draws in, or
|
|
only what the parent reports for it.
|
|
9. `Scroll` taking a direction rather than one axis.
|
|
|
|
`docs/LAYOUT.md` §4, §5 and the density section are stale: they name
|
|
`Painter::place`, `SetSize`, `desired_width`, `apply_rest`, `Len::dp`,
|
|
`Aligned` and `MaxSize`, none of which exist. Do not restore
|
|
`OnResize::Translate` or `OrthoSize`.
|
|
|
|
Other queued work, in dependency order: `UiRenderState` behind
|
|
`Rc<RefCell<_>>`; density-independent pixels; input restructuring (pointer
|
|
capture, drag slop and axis, cancellation, mask-aware hit testing,
|
|
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.
|