1605 lines
87 KiB
Markdown
1605 lines
87 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.
|
|
|
|
The separate frame/extent experiment now addresses the dominant size/resize
|
|
redraw cascade through dependency tracking and invalidation, not glyph emission.
|
|
Its current head is `34cafb6` on `wip/region-and-placement` in
|
|
`/home/bob/repos/iris-layout-experiment`. See **Frame and extent: retained
|
|
prototype** for the mechanism. The app's framework pin is unchanged. **It is
|
|
not ready to replace #18 yet**: against `e44dea3` it wins on `size` and
|
|
`scroll` and loses on `many` and `resize`. The cause of the `many` loss was
|
|
traced on 2026-09-17 and is not what the earlier sections say; read **Where
|
|
the `many` gap actually comes from** before anything else in this document
|
|
about performance, and take the earlier sections' explanations as history.
|
|
|
|
**The current work is the plan in the next section**, decided with Bryan on
|
|
2026-09-18. It is what the next agent does, on top of `34cafb6`, and it
|
|
supersedes the "Frame and extent" sections' description of `Span`, `Pad`,
|
|
`Stack`, the placement pin and `measure_len` wherever they differ.
|
|
|
|
## Plan: transparent frames (2026-09-18)
|
|
|
|
Decided with Bryan on 2026-09-18 after the trace below. The mechanism was
|
|
checked against the code of `34cafb6` and against the two counterexamples
|
|
on `wip/local-reask`; the pieces that were in doubt are called out. Do the
|
|
steps in order and run each step's check before the next. **If a check fails
|
|
and the fix that suggests itself is a tolerance, a pin, a deferral, a second
|
|
layout method, or a special case in `Span`, stop and report instead**: those
|
|
are exactly the patches that have been made around this design before, and
|
|
each one made the next problem harder to see.
|
|
|
|
### Decided by Bryan
|
|
|
|
- **Containers are transparent.** The frame a widget's fractions are of is
|
|
forwarded from its parent through a span, a stack and a scroll unchanged,
|
|
and through an inset narrowed by its margins. Any number of nested spans
|
|
lay out against one frame. The reason: `px` already passes through, `rel`
|
|
should behave the same way, and `leftover` is already the way to say
|
|
"fill the containing widget", so `rel(1.0)` meaning that too would be two
|
|
ways to say one thing.
|
|
- A frame is narrowed only by what is decided from above: a declared length
|
|
on the widget (`.width(rel(0.5))` on a span narrows its children's frame
|
|
too), an inset's margins, the root. A box that is an *answer* (a row's
|
|
height, a stack sized by a child) is never anything's frame.
|
|
- `rel` overflows on purpose when it sums past one or has pixels beside it.
|
|
- `Inset` and `Outset` take `px`, `rel` and `leftover` margins. Outset adds
|
|
its margins to the child's report and moves the child in; Inset draws the
|
|
child in a frame with the margins subtracted and reports the child's size
|
|
plus what it subtracted. An inset resolves what it subtracts, so `px` and
|
|
`rel` margins narrow the frame symbolically and a `leftover` margin is
|
|
resolved in pixels from the extent after the child answers, like a span's
|
|
shares. A general `Pad` would outset pixels and inset `rel` and
|
|
`leftover`. **Do not build these yet**; make them a few lines each to
|
|
write.
|
|
- Along its own axis a span places a child whose length is known in pixels
|
|
at its final slot while measuring, so it is drawn once, and moves a child
|
|
that is in the wrong place, `px` or `rel`, by translation rather than
|
|
drawing it again. Across itself it may still re-place by the answer.
|
|
- Layers may later be tied to another widget (a popup near an anchor). A
|
|
position is never defined by two widgets; positions compose up the tree.
|
|
|
|
### The protocol
|
|
|
|
Two boxes per widget, both in the *parent's frame coordinates*:
|
|
|
|
- **frame** -- what a declared or reported fraction is a fraction of.
|
|
`UiRegion::FULL` for a transparent parent; narrowed by `ask_box` for a
|
|
declared length and by an inset. Its *length* is the same on every ask of
|
|
the widget, which is the property the whole retained model rests on.
|
|
- **extent** -- where the drawing goes. Given by the parent as a `Place`
|
|
per axis, **relative to the parent's extent start, in frame units**:
|
|
|
|
```rust
|
|
/// Where a child goes along one axis, as a part of this widget's extent.
|
|
/// Spans are frame lengths from the extent's start, so a span's slot is
|
|
/// `from..start` and a moved extent re-places every child by re-adding
|
|
/// its start, exactly. `None` is the whole extent.
|
|
pub enum Place {
|
|
/// The child's answer, aligned inside the part by the child's alignment.
|
|
Within(Option<UiSpan>),
|
|
/// Exactly the part; the answer is not placed inside it again.
|
|
Fill(Option<UiSpan>),
|
|
}
|
|
```
|
|
|
|
The widget's `draw` sees only its extent: `px_len`/`px_size` are the
|
|
extent's pixels and narrow the extent range, `holds` widens it. Primitives
|
|
and masks are written in extent coordinates (`FULL` is the extent); there is
|
|
no `DrawRegion` and no frame-coordinate primitive. A container reads
|
|
`extent_len() -> UiVec2`, the extent's *symbolic* length in frame units,
|
|
which pins its drawing to that length and to nothing about the start. A
|
|
span that divides room also reads `frame_px_len(axis)` (today's
|
|
`region_px_len`) and widens through `frame_holds` (today's `region_holds`),
|
|
which narrow the frame range. `placement()`, `region()`, `box_of`,
|
|
`measure_len`, `widget_within`, `DrawRegion` and `ExtentPlacement` go.
|
|
|
|
The parent side is one call, with a shorthand:
|
|
|
|
```rust
|
|
pub fn widget_at<'s, W: ?Sized>(
|
|
&'s mut self,
|
|
id: &'s StrongWidget<W>,
|
|
frame: UiRegion, // in this widget's frame coordinates; FULL forwards it
|
|
place: [Place; 2], // relative to this widget's extent start, in frame units
|
|
) -> DrawResult<'s, 'a, W>;
|
|
|
|
/// The transparent default: the frame as given, the answer aligned in the extent.
|
|
pub fn widget<'s, W: ?Sized>(&'s mut self, id: &'s StrongWidget<W>) -> DrawResult<'s, 'a, W> {
|
|
self.widget_at(id, UiRegion::FULL, [Place::Within(None); 2])
|
|
}
|
|
```
|
|
|
|
`Span`, measuring and placing, with the row being its own extent:
|
|
|
|
```rust
|
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
|
let axis = self.dir.axis;
|
|
let far = painter.extent_len().axis(axis); // symbolic; pins the length
|
|
let along = |from: Len, to: Len| match self.dir.sign {
|
|
Sign::Pos => UiSpan::new(from, to),
|
|
Sign::Neg => UiSpan::new(far - to, far - from),
|
|
};
|
|
let across = Place::Within(None);
|
|
// Measure. A child whose length is already known -- a rule, a hint --
|
|
// with no share before it is placed at its slot here and never again.
|
|
let mut cursor = Len::ZERO;
|
|
let mut shares_before = false;
|
|
let mut lens = Vec::with_capacity(self.children.len());
|
|
for child in &self.children {
|
|
let known = painter.size_hint(child, axis).filter(|_| !shares_before);
|
|
let place = match known {
|
|
Some(len) if len.leftover == Weight::ZERO => {
|
|
Place::Fill(Some(along(cursor, cursor + Len::from_parts(len.rel, len.px))))
|
|
}
|
|
_ => Place::Within(Some(along(cursor, far))),
|
|
};
|
|
let len = painter.widget_at(child, UiRegion::FULL, axis.pair(place, across)).len(axis);
|
|
shares_before |= len.leftover != Weight::ZERO;
|
|
cursor += Len::from_parts(len.rel, len.px + self.gap);
|
|
lens.push(len);
|
|
}
|
|
// total, room = far - fixed, the shares decision through frame_px_len and
|
|
// frame_holds(through(room)): unchanged from today.
|
|
// Place. A child already at its slot is an exact reuse; one whose slot
|
|
// moved is recomposed, since its length did not change.
|
|
for (child, len) in self.children.iter().zip(&lens) {
|
|
// undraw a share with nothing to share, accumulate fixed/taken, then:
|
|
painter.widget_at(child, UiRegion::FULL, axis.pair(Place::Fill(Some(along(from, start))), across));
|
|
}
|
|
Size::from_axis(axis, total, ortho)
|
|
}
|
|
```
|
|
|
|
Across itself a span reports the longest child in *frame pixels* among the
|
|
`px` and `rel` parts of its children's reports, as that child's own `Len`.
|
|
Comparing in pixels is safe here because the frame is decided from above
|
|
and nothing feeds back; the read is a `frame_px_len` and narrows the frame
|
|
range, and at the crossover both candidates are the same number of pixels,
|
|
so the drawing is the same on either side of it. A child's `leftover`
|
|
across a span contributes nothing to that: across, children overlap rather
|
|
than divide anything, so a share can only mean "as tall as the span", and
|
|
`Place::Within(None)` already fills the extent for a leftover answer. Only
|
|
when no child has a `px` or `rel` part does the span report `leftover`
|
|
itself and take its parent's room (Bryan, 2026-09-18: "returning rest if
|
|
any have it is probably fine", refined to this because it costs nothing).
|
|
So `row![text, rect]` is as tall as the text with the rect filling it, and
|
|
`row![rect, rect]` fills its column's share.
|
|
|
|
The shapes the rest of the containers take (write them, they are short):
|
|
|
|
```rust
|
|
// Stack: the sizing child takes the whole extent, the rest are aligned in it.
|
|
painter.widget_at(child, UiRegion::FULL, [Place::Fill(None); 2]).size() // sizing
|
|
painter.widget_at(child, UiRegion::FULL, [Place::Within(None); 2]); // others
|
|
// Scroll: viewport is the extent; content is a pixel box offset by the scroll.
|
|
painter.widget_at(&self.inner, UiRegion::FULL,
|
|
self.axis.pair(Place::Fill(Some(UiSpan::px(anchor - amt, anchor - amt + content_len))), Place::Fill(None)));
|
|
// Inset with px/rel margins m: a narrowed frame and a narrowed extent.
|
|
let frame = m.narrow(UiRegion::FULL); // Len subtraction, exact
|
|
let len = painter.extent_len();
|
|
painter.widget_at(child, frame, [Place::Within(Some(UiSpan::new(m.lead.x, len.x - m.trail.x))), /* y */]).size()
|
|
+ m.total() // report child plus margins
|
|
// Outset: the same placement with the frame forwarded (UiRegion::FULL).
|
|
// Leftover margins: measure the child with Within(None), divide the room
|
|
// left in the extent by weight as a span does, then place with Fill.
|
|
```
|
|
|
|
A report is still `LayoutLen { px, rel, leftover }` per axis. A fraction in
|
|
it is of the reporting widget's frame, so through a transparent parent it
|
|
passes unchanged and through a narrowing parent (declared, inset) it is
|
|
composed by `within_len(frame.len())` as `in_parent_frame` does today. A
|
|
stack sized by a child that reports `rel(0.5)` therefore reports `rel(0.5)`,
|
|
is placed at half the frame, and hands its child the whole extent while the
|
|
child's frame is still the full one: the fraction is applied once.
|
|
|
|
### Retained state and reuse
|
|
|
|
Per widget (`ActiveData`), replacing `region`/`placement`/`given_region`/
|
|
`offer_len`/`offer_placement`:
|
|
|
|
- `frame: UiRegion` in the parent's frame coordinates, and `place: [Place; 2]`
|
|
as last given; `offer_place: [Place; 2]` from the first ask of the
|
|
parent's draw at the offer. There is no `offer_frame`: the frame's length
|
|
is the same on every ask, and `redraw` asserts it in debug.
|
|
- `frame_abs`, `extent_abs`: the two composed into `parent_move`
|
|
coordinates, for writing primitives and for `window_region`; rewritten by
|
|
recomposition.
|
|
- `answer: Option<(Size, LayoutHolds)>` from the offer ask; `holds:
|
|
LayoutHolds` for the drawing, where
|
|
|
|
```rust
|
|
pub struct LayoutHolds {
|
|
pub frame: [Holds; 2], // frame pixel lengths
|
|
pub extent: [Holds; 2], // extent pixel lengths
|
|
pub extent_len: [Option<Len>; 2], // symbolic extent length, where read
|
|
}
|
|
```
|
|
|
|
The `placement: Option<UiRegion>` pin is gone. Nothing may depend on where
|
|
an extent starts.
|
|
|
|
- primitives and the mask retained in extent-local coordinates, as now.
|
|
|
|
**Reuse** of a drawing at an ask: same layer, parent move and region-node
|
|
choice; frame pixels inside `frame`; the extent resolved from `place`
|
|
(`Fill` is the part; `Within` is the answer aligned in the part, or the part
|
|
where the answer fills) has pixels inside `extent` and, where pinned, the
|
|
same symbolic length. A frame that moved recomposes the subtree from
|
|
retained local coordinates (today's `recompose_subtree`). An extent that
|
|
moved **re-places every child** through its retained `place`
|
|
(`extent_abs.start + place`, then the child's own reuse test), stopping at
|
|
region nodes; this is today's `reposition` over `extent_children`,
|
|
generalised to all children because every child is now placed relative to
|
|
the extent. A child whose reuse fails there is drawn again at its retained
|
|
place. Along a span this is what moves `px` and `rel` children whose slots
|
|
shifted: the span's redraw re-issues `Fill(from..start)` with the same
|
|
lengths and different starts, and the child recomposes.
|
|
|
|
**A `Within` ask does not place the answer immediately.** The child draws
|
|
in the whole part, and the aligned answer box is applied by the next ask of
|
|
that child in the same parent draw, or, for a child not asked again, by a
|
|
pass at the end of the parent's draw over `children`. That is today's
|
|
`measure_len` rule made the rule for every open axis; it is what keeps a
|
|
span child at one draw plus one recomposition rather than two
|
|
recompositions.
|
|
|
|
**Dependencies composed into the parent** (today's `in_parent`): a child's
|
|
`frame` range goes through the child's frame length into the parent's frame
|
|
range. A child's `extent` range goes into the parent's *frame* range through
|
|
the part's length where the part is a span (a frame length), and into the
|
|
parent's *extent* range where the part is `None` (the whole extent). Answer
|
|
dependencies come only from children whose answer was read, drawing
|
|
dependencies from every child drawn, as today. Pins do not compose: a child
|
|
pinned on its symbolic length is checked when it is re-placed.
|
|
|
|
**Local redraw** (`redraw`): no deferral on lengths. Draw at `offer_place`
|
|
with the drawing left there where the given place differs (measuring),
|
|
compare answer and holds with what was retained, keep a still-covering old
|
|
guarantee as today, mark the parent only on a change, then place at the
|
|
given `place` (a reuse where the holds admit it). Deferral remains only for
|
|
a changed declared length or alignment and for an undrawn widget. The
|
|
`given_px != offered_px` branch and `offer_len` are deleted, not disabled.
|
|
|
|
### Steps, each with its check
|
|
|
|
Work on a branch from `34cafb6` in `/home/bob/repos/iris-layout-experiment`
|
|
(its `target` is its own; the baseline's is `target-own`). The checks are
|
|
`cargo test --workspace` in debug, `cargo test --release --test generated`
|
|
(fast oracle), and after steps 4, 6 and 7 the long ones:
|
|
|
|
```sh
|
|
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
|
|
```
|
|
|
|
1. **`Place` and `widget_at(child, frame, [Place; 2])`**, with `widget` as
|
|
the shorthand, replacing `widget_at`/`widget_within`/`widget`/
|
|
`measure_len`. Internally keep `draw_inner` but make `DrawInfo` carry
|
|
`frame` and `place`; delete `DrawRegion`, `ExtentPlacement`,
|
|
`reads_placement`, `region()`, `placement()`, `box_of`. Add
|
|
`extent_len()`, rename `region_px_len`/`region_holds` to `frame_*`.
|
|
Check: it compiles with the call sites moved in step 2; no test yet.
|
|
2. **Call sites**: `Span` as above (without the known-length shortcut yet),
|
|
`Stack`, `Scroll`, `Pad` (as an inset by pixels, unchanged behaviour),
|
|
`Masked`, `Branch` in `random.rs`, `Text` (`glyphs` in extent
|
|
coordinates), the examples. Check: suite and fast oracle. Expected to
|
|
pass with `Span` reading `extent_len` on both axes and pinning it.
|
|
3. **Re-place every child on an extent move**, generalising `reposition`;
|
|
delete `extent_children`. Check: suite, fast oracle,
|
|
`retained::a_span_ruled_across_itself_moves_its_child_without_redrawing_it`
|
|
and a new test: a row whose first child grows by a pixel rule moves the
|
|
two after it without drawing them (count draws with the `Counted` widget
|
|
in `tests/cases/retained.rs`), once with a `px` second child and once
|
|
with a `rel` one.
|
|
4. **The symbolic-length pin replaces the placement pin**; `LayoutHolds`
|
|
as above. Check: suite, fast oracle, shrinker at 400/5, and the rig's
|
|
`many` at seed 13, depth 8 (`IRIS_SEED=13 IRIS_DEPTH=8 IRIS_PHASE=many`):
|
|
"reuse outside: the placement it was pinned to" is gone as a counter and
|
|
distinct widgets a frame should already be well under `34cafb6`'s 508.
|
|
5. **Lazy `Within`** with the end-of-draw pass. Check: suite;
|
|
`retained::a_span_does_not_place_its_measurement_before_assigning_the_childs_slot`
|
|
still counts three draws.
|
|
6. **`redraw` without the deferral**, as above; delete `offer_len` and the
|
|
`given_px != offered_px` branch. Check: suite, fast oracle, shrinker,
|
|
oracle at 1000/6 -- this is the step `wip/local-reask` failed at seeds
|
|
532 and 398, and both must pass now because the frame no longer changes
|
|
under the widget. If either still fails, shrink it and report; do not
|
|
restore the deferral. Then the rig: `many` at seeds 1 and 13, depth 8,
|
|
against `e44dea3` in `/home/bob/repos/iris-layout-baseline`
|
|
(`CARGO_TARGET_DIR=$PWD/target-own`). Expect distinct widgets near
|
|
`e44dea3`'s 95 and 159 and no deferral at all.
|
|
7. **Span's known-length shortcut and the cross-axis report** as written.
|
|
New tests: a `px` child after a `px` child is drawn once on a cold
|
|
layout and never on repaint; nested rows two deep with `rel(0.5)` give
|
|
half the *root* (or half a declared `.width(rel(0.5))` ancestor) wherever
|
|
the child sits; a row with a `rel(0.5)`-tall child reports `rel(0.5)`
|
|
across. Check: everything, including the long runs and the five
|
|
reference renders (`view`, `minimal`, `random`, `tabs`, `text`) against
|
|
`34cafb6` -- expect `random` and `tabs` to move where nested spans or
|
|
span heights change meaning, and look at them rather than diffing.
|
|
8. **Prove `Inset`/`Outset` are easy**: write them as test widgets in
|
|
`tests/cases/layout.rs` with `px`, `rel` and `leftover` margins, a dozen
|
|
lines each, and assert the child's box; do not add them to the crate.
|
|
9. Measure all six phases against `e44dea3` with the uninstrumented rig,
|
|
record the table here, and update the "Retained-layout invariants" list:
|
|
delete the bullets about `reports_of`, `decided`, `dirty_size_under`,
|
|
the placement pin and the offer-length deferral, and add the frame rule.
|
|
|
|
Expected against `e44dea3` at seed 1 and 13, depth 8: `size` and `scroll`
|
|
keep their wins; `many` within a few tens of percent, from the container
|
|
body running at the offer placement and at the placed one; `resize` at or
|
|
below its 13 draws. A `many` result above 2x is a sign something above was
|
|
not done as written, not a reason for a new mechanism.
|
|
|
|
**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 continuation is **`0e107f0`**, 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.
|
|
|
|
**The major update gap was over-invalidation, not a required correctness cost.**
|
|
Two different dependency graphs had been accumulated into one range. A child's
|
|
size answer constrains its parent only when the parent reads that answer;
|
|
the child's drawing constrains the parent's retained drawing whether its size
|
|
was read or not. A stack sized by one child must not remeasure because an
|
|
unmeasured overlay wrapped at another width during provisional layout.
|
|
|
|
`Painter` now collects these contracts separately. `DrawResult::size()` and
|
|
`measure_len` contribute answer dependencies; every child draw contributes
|
|
drawing dependencies. Direct numeric and raw-placement reads conservatively
|
|
constrain both. The original offer retains the answer contract, and final
|
|
placement retains the drawing contract. Previous attempts separated storage
|
|
without separating which child dependencies entered each contract, so they
|
|
continued to invalidate measurements for unrelated drawing work.
|
|
|
|
**A valid answer does not certify a valid drawing.** Root resize checks both.
|
|
Local updates propagate an invalidated drawing contract as well as an invalidated
|
|
answer; otherwise an ancestor can retain a broad resize range after a descendant
|
|
begins reading its pixel width. Focused regressions cover both paths and fail when
|
|
those checks are removed. No geometry check or equality requirement was relaxed.
|
|
|
|
**A wider validity range does not invalidate an existing guarantee.** When a
|
|
local redraw returns the same size under a contract covering the old one, the
|
|
old answer contract remains sufficient. Keep it, and likewise keep a still-valid
|
|
old drawing contract. Adopting the wider range without telling the parent would
|
|
lose which guarantee the parent relies on; keeping it prevents both widening
|
|
and narrowing back from causing layout. Scroll alternates between an exact
|
|
width and a wider interval as it enters/leaves end anchoring. Comparing whole
|
|
contracts by equality caused four draws and doubled scroll cycles after the
|
|
initial dependency split (`f860f71`). Retaining the valid guarantee reduces
|
|
scroll to one draw, below the previous baseline's two. It introduces no retained
|
|
state or separate propagation subsystem. A focused test widens/narrows several
|
|
times without redrawing the parent, then resizes and requires the leaf to redraw.
|
|
|
|
**Declared-size changes use ordinary bottom-up propagation.** The changed child
|
|
and its direct parent remain marked until the parent resolves the new rule.
|
|
Further propagation depends on the parent's new answer and drawing contract.
|
|
The blanket ancestor walk, `answer_invalid` set and frame-global
|
|
`replace_answers` flag are gone. They predated strict bottom-up settlement and
|
|
original-offer replay; a parent's independent answer no longer invalidates the
|
|
whole tree merely because a descendant declared a new width. A regression
|
|
checks the changed geometry and that propagation stops at an independent parent.
|
|
|
|
**The `c44bd19` extent-child extension was removed.** It changed
|
|
`widget_within` to accept `DrawRegion::Extent` and enlarged retained child records,
|
|
but saved only 1.8% resize cycles and no primitive writes. The dependency fixes
|
|
address the major cost with the original `widget_within(UiRegion)` API and
|
|
widget-id-only inherited-child list. Pad and Stack again read raw placement;
|
|
those conservative reads are not the cause of the pathological update counts.
|
|
|
|
Instrumented work, seed 1/depth 8, 50 frames per phase (cold once):
|
|
|
|
| phase | draws at c44bd19 | current draws | primitive writes at c44bd19 | current writes |
|
|
| --- | ---: | ---: | ---: | ---: |
|
|
| cold | 463 | 484 | 9179 | 9179 |
|
|
| repaint | 1 | 1 | 1 | 1 |
|
|
| many | 263.1 | 274 | 5873 | 5873 |
|
|
| size | 240 | 3 | 5200 | 105 |
|
|
| scroll | 2 | 1 | 0 | 0 |
|
|
| resize | 278 | 27 | 5730 | 0 |
|
|
|
|
Resize performs no text renders. Size updates render one text. Removing the
|
|
extent-child extension increases cold/many container evaluations again. Do not
|
|
hide those costs behind the large size/resize gains. The original `e44dea3`
|
|
measured 16 draws/106 writes for size and 13/0 for resize, but does not implement
|
|
the corrected frame/extent semantics, so it is context rather than a correctness
|
|
baseline.
|
|
|
|
Nine alternating pairs of direct, uninstrumented release executables compare
|
|
`c44bd19` with `0e107f0` under `perf stat -e cycles:u,instructions:u`. All samples
|
|
were retained, with no scans or builds running during measurement. Counts include
|
|
fixture setup; these are VM CPU measurements, not phone frame times.
|
|
|
|
| phase (frames) | median cycles before → after (billions) | median instructions before → after (billions) |
|
|
| --- | ---: | ---: |
|
|
| size (2,000) | 4.0774 → 0.2021 (-95.0%) | 10.4017 → 0.4993 (-95.2%) |
|
|
| resize (2,000) | 4.7070 → 0.4930 (-89.5%) | 11.9383 → 1.3009 (-89.1%) |
|
|
| many (2,000) | 4.7718 → 5.0276 (+5.4%) | 12.0567 → 12.4463 (+3.2%) |
|
|
| repaint (1,000,000) | 2.6803 → 2.9086 (+8.5%) | 6.0224 → 6.4382 (+6.9%) |
|
|
| scroll (300,000) | 2.6252 → 1.3443 (-48.8%) | 6.5802 → 3.0649 (-53.4%) |
|
|
|
|
All five before/after cycle ranges are disjoint. The main gains coexist with
|
|
5.4% more cycles for `many` and 8.5% more for repaint. No claim is made that the
|
|
prototype is now uniformly faster.
|
|
|
|
Additional depth-6 fixtures, nine alternating pairs and 4000 frames: seed 3
|
|
size updates use 20.2% fewer cycles and 23.0% fewer instructions, while its resize
|
|
case costs 5.4% more cycles and 3.5% more instructions. Seed 13 resize uses 6.6%
|
|
fewer cycles and 7.7% fewer instructions. Those cycle ranges are disjoint too.
|
|
Seed 13's size case performs no widget draws and its cycle ranges overlap.
|
|
|
|
Verification: workspace formatting, clippy with `layout-diagnostics`, and tests
|
|
(106 suite tests, 21 core tests, fast generated cases) pass. Exact release scans
|
|
pass 2000 seeds/depth 4 and 1000/depth 6; the shrinker passes 400 trees/depth 5,
|
|
and the debug scan passes 120 seeds/depth 4 with assertions enabled. Every scan
|
|
runs all fifteen scenarios without a position tolerance. Each of the five new
|
|
regressions fails when its corresponding behavior is deliberately broken.
|
|
All five reference renders (`view`, `minimal`, `random`, `tabs`, `text`) match
|
|
`a7307d9` pixel for pixel. Resizing `tabs` from 1920x1200 to 900x1200 matches a
|
|
cold render there, also with zero differing pixels. The GPU probe reports Venus
|
|
on the RX 7900 XT.
|
|
|
|
**Remaining work is narrower, but not proved unavoidable.** Cold layout and
|
|
widespread content updates still run descendants while evaluating provisional
|
|
sizes, including children whose answers will never be read. A disposable trial
|
|
made child requests lazy during measurement and skipped primitive emission, but
|
|
reused the same active records for measurement and painting. It failed nine
|
|
oracle scenarios and made dirty updates much worse. Measurement cannot erase
|
|
or replace the final drawing's dependency/lifetime state. The trial was removed;
|
|
there is no second widget layout body or measurement phase in this change.
|
|
|
|
The hot wrapping text really does return different heights at 68 and 89.53613 px,
|
|
so some width-dependent evaluation is necessary. That does not justify repeating
|
|
it after an unchanged size answer. The resize trace and reduced stack/overlay
|
|
case distinguished those two situations and led to this fix. Future work on
|
|
cold/many should preserve that distinction rather than weakening raw-placement
|
|
checks or memoizing arbitrary width results without a validity model.
|
|
|
|
The earlier glyph-origin composition optimization (`a7307d9`) remains; it changes
|
|
no layout decisions or retained state. Two removed arithmetic trials remain
|
|
unpromising: combining frame recomposition with extent movement saved under 0.8%
|
|
instructions and no cycles; special-casing equal endpoint fractions in
|
|
`UiSpan::within` cost 11% cycles and 7% instructions. Retained local coordinates
|
|
still cost memory per primitive. The prototype remains separate from PR #18;
|
|
the `Inset`/`Outset` changes remain parked.
|
|
|
|
### Measured against #18's head, and the placement pin behind the gap
|
|
|
|
Checked on 2026-09-17 in `/home/bob/repos/iris-layout-baseline` (`e44dea3`,
|
|
its own `target-own` because its `target` is a symlink into
|
|
`/home/bob/repos/iris-pr18`) against `/home/bob/repos/iris-layout-experiment`
|
|
(`0e107f0`). **The five-phase cycles table above compares `c44bd19` with
|
|
`0e107f0` -- two commits inside the experiment -- so it says what the last two
|
|
fixes bought, not what the experiment costs against the branch it would
|
|
replace.** Against `e44dea3` the picture is different, and it decides whether
|
|
this can replace #18.
|
|
|
|
Nine alternating pairs under `perf stat`, seed 1 at depth 8, uninstrumented
|
|
release executables:
|
|
|
|
| phase | cycles `e44dea3` -> `0e107f0` | instructions |
|
|
| --- | ---: | ---: |
|
|
| size (2,000) | 0.2618 -> 0.2026 B (-22.6%) | -21.3% |
|
|
| scroll (300,000) | 2.5169 -> 1.2796 B (-49.2%) | -49.5% |
|
|
| repaint (300,000) | 0.8546 -> 0.9533 B (+11.5%) | +15.0% |
|
|
| resize (2,000) | 0.2697 -> 0.4813 B (+78.5%) | +99.0% |
|
|
| many (2,000) | 2.3885 -> 4.9504 B (+107.3%) | +98.4% |
|
|
|
|
Each phase's before and after cycle ranges are disjoint. The work counters agree with the times: `size`
|
|
falls from 16 widget draws to 3 and `scroll` from 2 to 1, while `many` rises
|
|
from 157 to 274 and `resize` from 13 to 27. `many` is also the phase that
|
|
costs anything at all -- median frames at seed 1, depth 8 are 1 us for
|
|
repaint and scroll, 9 us for size, 48 us for resize and 667 us for `many`,
|
|
so a percentage on `many` is worth two orders of magnitude more than the same
|
|
percentage on repaint.
|
|
|
|
**The `many` regression varies enormously with the tree**, so one fixture
|
|
cannot settle it. Median frame, 200 frames, `IRIS_DIRTY` at its default:
|
|
|
|
| fixture | `e44dea3` | `0e107f0` | draws before -> after |
|
|
| --- | ---: | ---: | --- |
|
|
| seed 1, depth 8 | 0.318 ms | 0.667 ms | 157 -> 274 |
|
|
| seed 2, depth 8 | 0.218 ms | 0.700 ms | 111 -> 349 |
|
|
| seed 5, depth 8 | 0.759 ms | 0.702 ms | 337 -> 230 |
|
|
| seed 13, depth 8 | 1.088 ms | 6.351 ms | 524 -> 2380 |
|
|
| seed 3, depth 6 | 0.173 ms | 0.156 ms | 83 -> 51 |
|
|
| seed 13, depth 6 | 0.583 ms | 0.570 ms | 325 -> 268 |
|
|
|
|
**The cause is the raw placement read, and it is nearly all of it.**
|
|
`LayoutHolds::placement` is `Some(..)` for any widget that called
|
|
`Painter::placement`, and that pins the exact box the drawing sits in: the
|
|
widget is redrawn whenever it moves at all, however wide its frame and extent
|
|
ranges are. `Pad` and `Stack` both read it -- `self.padding.region_of(
|
|
painter.placement())` and `let placement = painter.placement()` -- so every
|
|
`Pad` and `Stack` in a row redraws its whole subtree when an earlier sibling
|
|
changes length. A counter for reuse failures where the frame and extent
|
|
ranges still hold and only the placement moved says it is 45 of the 109
|
|
failures a `many` frame at seed 1 has, 14 of 26 on `resize`, and 73 of 136
|
|
on `cold`.
|
|
|
|
Bounded by deleting the `reads_placement = true` line -- unsound, since
|
|
nothing then redraws a widget whose placement really did move, but it prices
|
|
the dependency:
|
|
|
|
| fixture, `many` | `e44dea3` | `0e107f0` | no placement pin |
|
|
| --- | ---: | ---: | ---: |
|
|
| seed 1, depth 8 | 0.318 ms / 157 draws | 0.667 / 274 | 0.127 / 78 |
|
|
| seed 2, depth 8 | 0.218 / 111 | 0.700 / 349 | 0.079 / 46 |
|
|
| seed 13, depth 8 | 1.088 / 524 | 6.351 / 2380 | 0.147 / 84 |
|
|
|
|
`cold` at seed 1 falls from 484 draws to 280 and 12.5 ms to 11.1 ms with it
|
|
gone, and `resize` from 48 us to 12 us. Read it as an upper bound on what
|
|
`Pad` and `Stack` having no way to say "inside my extent" was costing, not as
|
|
what removing the read properly would buy -- see below for the difference.
|
|
|
|
**That is what `c44bd19` was, and the 1.8% that got it reverted was measured
|
|
before the dependency split.** It is now landed as `e6ba570`, below, and the
|
|
bound above turned out to overstate it: deleting the read also deletes
|
|
invalidation that the extent semantics genuinely require, so it prices "no
|
|
dependency at all" rather than "the dependency expressed properly".
|
|
|
|
### Landed on 2026-09-17: extent children, and an ordered walk
|
|
|
|
Three commits on `wip/region-and-placement`, pushed, on top of `0e107f0`.
|
|
|
|
**`e6ba570`, a child gets a part of the container's extent.**
|
|
`widget_within` takes a `DrawRegion`, and `DrawRegion::Extent(part)` gives
|
|
the child a part of the extent without reading it. What is retained is the
|
|
part rather than the box it resolved to, so moving the extent re-places the
|
|
child through the same rule: `inherited_children` became `extent_children`,
|
|
carrying `Inherit` for the wrapper case `Painter::widget` already had and
|
|
`Within(part)` for the new one. `Pad` and `Stack` use it and no longer read
|
|
`placement()`. The dependency that goes up is a range on the container's
|
|
*extent*, since only the part's length reaches the child. A declared length
|
|
is unchanged -- it is a length of the frame wherever its box came from.
|
|
What still pins the placement is a report with a fraction in it, and that
|
|
pin is on the answer rather than the drawing; the test from the first
|
|
attempt fails without it.
|
|
|
|
**`3bf2293` and `34cafb6`, the walk takes the deepest mark from a
|
|
`BTreeSet` keyed by depth** rather than `max_by_key` over the whole set.
|
|
Every mark made while the walk runs goes through `mark`, which queues
|
|
itself; the set is still what says the walk is done, so a mark that arrived
|
|
another way cannot be left for the next frame. Depth reads per `many` frame
|
|
at seed 1 depth 8: 131/1,314/14,611 at 9/34/145 marks become 57/160/436.
|
|
What is drawn does not change at any load measured. Ties between equal
|
|
depths now break by widget id, which makes the walk deterministic.
|
|
|
|
Cycles, medians of seven alternating runs, `perf stat -e cycles:u`, against
|
|
both the branch #18 would merge and the experiment as it stood:
|
|
|
|
| phase | `e44dea3` | `0e107f0` | head | vs `0e107f0` |
|
|
| --- | ---: | ---: | ---: | ---: |
|
|
| `many`, seed 1 | 1.273 B | 2.543 B | 2.502 B | -1.6% |
|
|
| `many`, seed 13 | 1.323 B | 7.308 B | 5.931 B | -18.8% |
|
|
| `resize` | 0.269 B | 0.485 B | 0.389 B | -19.9% |
|
|
| `size` | 0.261 B | 0.205 B | 0.204 B | -0.2% |
|
|
| `scroll` | 1.730 B | 0.912 B | 0.939 B | +2.9% |
|
|
|
|
Verified at each commit: fmt, clippy with `-D warnings`, 109 suite and 20
|
|
core tests, the oracle at 100 seeds, the shrinker at 400 trees of depth 5,
|
|
1000 seeds at depth 6 and 2000 at depth 4 over all fifteen cases, and the
|
|
five reference renders plus `tabs` resized to 900x1200 and `random` to
|
|
1280x800, all byte-identical on Venus.
|
|
|
|
### Where the `many` gap actually comes from (2026-09-17, traced)
|
|
|
|
The two sections above blame the placement pin and then the extent contract.
|
|
Neither is the cause. Disabling the pin (unsound, a bound) still redraws
|
|
**487** distinct widgets a frame at seed 13 against `e44dea3`'s 159, and a
|
|
per-widget trace of one `many` frame shows what does it: **local redraws
|
|
defer to the parent, and the deferrals chain to the root.**
|
|
|
|
`redraw` refuses a dirty widget whose given box is not as long as its offer
|
|
and marks its parent instead. Under this protocol that is nearly every
|
|
widget under a self-sized container. `Span` hands its children its own
|
|
placement across itself as their *frame* (`UiRegion::from_axis(axis,
|
|
UiSpan::FULL, *own.axis(!axis))`), and that placement is `FULL` while the
|
|
span is being measured and its answer once it is placed, so a child's offer
|
|
frame across the span is the whole window and its given frame is the span's
|
|
height or width. The trace has 43 deferrals in one frame, in chains such as
|
|
424 → 425 → 437 → 449 → 483 → 487 → 491 with `given=(52, 40)
|
|
offered=(1920, 40)` at every step. Each container reached that way runs its
|
|
body at both placements, redrawing its subtree at two geometries.
|
|
`e44dea3` defers under the same rule but its given and offer differ only by
|
|
the widget's own alignment slack, so its chains stop after a level or two:
|
|
19 deferrals, 159 distinct widgets.
|
|
|
|
**Re-asking locally at the offer** is the fix that follows, on branch
|
|
`wip/local-reask` (one commit over `34cafb6`, pushed). `ActiveData` keeps
|
|
`offer_region` beside `given_region`; `redraw` draws the widget once in the
|
|
offer's frame at the offer's lengths and placement, and again at the given
|
|
box where the two differ. Uninstrumented, 200 frames, seed and depth as
|
|
before, against `e44dea3`:
|
|
|
|
| phase | `e44dea3` | `34cafb6` | `wip/local-reask` |
|
|
| --- | ---: | ---: | ---: |
|
|
| `many`, seed 1, depth 8 | 0.263 ms / 157 draws / 95 distinct | 0.549 / 263 / 108 | 0.499 / 239 / 93 |
|
|
| `many`, seed 13, depth 8 | 0.896 / 524 / 159 | 4.727 / 1904 / 508 | 1.282 / 647 / 294 |
|
|
| `size`, seed 1 | 0.016 / 16 draws | | 0.010 / 3 |
|
|
| `size`, seed 13 | 0.094 | | 0.003 |
|
|
| `resize`, seed 1 | 0.019 / 13 draws | 27 draws | 0.028 / 22 |
|
|
| `scroll`, `repaint` | 1 us | | 1 us |
|
|
|
|
With the pin also disabled on that branch (bound), draws fall a further 15%
|
|
and distinct widgets do not move. **What is left is the offer-then-place
|
|
double pass**: a dirty container is drawn at its offer placement, where its
|
|
children get the offer geometry, and again at its placed one, and any child
|
|
whose `Holds` is a point (`Branch`, `Scroll`, wrapping text at a width it
|
|
was not measured at) is drawn on both passes. That pass exists because the
|
|
frame a span gives its children across itself is derived from the span's
|
|
own answer, so it cannot be the same on both asks.
|
|
|
|
**The branch is not sound.** The suite, the debug oracle, 100 seeds and the
|
|
shrinker at 400 trees of depth 5 pass; the oracle at 1000 seeds of depth 6
|
|
diverges on two. Both reduce to a self-sized container whose answer changes
|
|
under a local redraw:
|
|
|
|
- seed 532, `reorder`: `Pad{0} > Stack[ Span{RIGHT}[ Wrapped, Wrapped,
|
|
Rect[x:178px, y:123px] ] ]`, rotating the span's children. Warm places
|
|
a text at 947..1115 x 536..659, cold at 986..1076 x 483..712.
|
|
- seed 398, `every-size`: `Span{LEFT}[ Rect, Rect, Stack[ Rect,
|
|
Branch{ probe: Wrapped, wide: Rect, narrow: Span{DOWN}[ Rect[x:129px,
|
|
y:leftover] ] } ] ]` with a size rule on the stack. Warm places a widget
|
|
at 11..183, cold at 6.5..187.5 on x.
|
|
|
|
Reduce them with `SHRINK_SEED=<seed> SHRINK_DEPTH=6 SHRINK_CASE=<case>` on
|
|
that branch before building on it.
|
|
|
|
**The cross-axis frame is the protocol question, not the pin.** Bryan's
|
|
rule that a widget's frame does not change between the measuring and the
|
|
placing ask is broken across every span, because the only sensible
|
|
reference for a child's fraction across a span is the span's own box, and
|
|
that box is the span's answer. `e44dea3` has the same double pass and pays
|
|
for it by deferring to the parent; what it does not have is a frame that
|
|
changes under a widget. Whether to keep frame and placement apart at all
|
|
turns on this and on nested-span semantics; see the 2026-09-17 assessment
|
|
handed to Bryan.
|
|
|
|
## 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: measured children for an answer,
|
|
all painted children for a drawing. 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 where its measurement contract holds. Its final
|
|
drawing is checked independently and may need redrawing even while the answer
|
|
stands. Drawing validity is translated back through the chosen placement for
|
|
the parent's drawing contract, not intersected into the retained answer.
|
|
- `Painter::widget_at(child, frame, placement)` supplies an optional chosen
|
|
extent on each axis. Unchosen axes place the measured answer by the child's
|
|
alignment. `measure_len` leaves a fresh measurement at the offer until the
|
|
parent assigns a slot; it does not first place an intermediate answer.
|
|
- Placement must not replace the fraction reference. The failed earlier
|
|
attempts moved `ActiveData::region` with the drawing and made local redraws
|
|
ask differently rounded questions. The experiment retains `placement`
|
|
separately and replays original local frames when recomposing geometry.
|
|
- 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, including during resize. A deferred
|
|
child leaves its parent marked, so no clean answer can hide an unsettled
|
|
size dependency. `dirty_size_under` has been deleted.
|
|
- 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. **Transparent frames**, the plan near the top of this document. This is
|
|
the fundamental change and comes before anything built on the placing
|
|
ask; it subsumes the parked `Inset`/`Outset` and stack-fraction branches
|
|
(their tests land with step 7 and 8) and the `wip/local-reask` branch,
|
|
which is superseded and should be deleted once step 6 passes.
|
|
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.
|