Measured at a888717 and at the new f6242aa on wip/hint-first, with the inset experiment recorded as tested and rejected. The plan is now review, render and land.
24 KiB
Handoff
Where the Iris retained-layout work stands for a worker picking it up cold.
This file contains current decisions, the implementation plan and its checks.
The durable layout design and measurement method are in docs/LAYOUT.md.
The temporary investigation record is in docs/LAYOUT_LOG.md; delete that
log when the one-ask protocol lands, after moving any fact that must survive.
Where things stand
Canonical upstream Iris main is ca2b4b2 (#17, the headless rig). PR
#18's pushed branch is split/18-position-chain at e44dea3. Its
detached comparison checkout is /home/bob/repos/iris-layout-baseline. It is
the reviewed baseline this work must preserve or improve.
The continuation is /home/bob/repos/iris-layout-experiment, now on branch
wip/hint-first at f6242aa, one commit over wip/one-ask at
a888717 (nine commits over 4328eac, the head of wip/transparent-frames,
which is unchanged). Both branches are pushed to origin. wip/one-ask
replaced the old step 3 plan with the one-ask protocol below, 1512d84 and
23523ee made the frame a length of the window, and e8a5792 is what the
step 1 review found. f6242aa is the step 5 result: a span takes a child's
length from its hint or rule and asks it once, in its slot. It passes every
check:
check at f6242aa |
result |
|---|---|
cargo fmt --all --check, clippy -D warnings, with and without layout-diagnostics |
clean |
cargo test --workspace (debug) |
123 suite, 20 core, 11 generated, all green |
cargo test --release --test generated |
11/11 |
| shrinker, 400 seeds, depth 5, all sixteen cases | agree, 63 s (34,488 widgets) |
| 1000 seeds at depth 6 | agree, 277 s |
| 2000-seed depth-4 scan, all sixteen cases | agree, 283 s (82,203 widgets) |
cold layout of 400 trees at depth 5, dumped and diffed against a888717 |
byte-identical (34,488 widgets) |
renders, the tabs replay and the random resize |
not re-run since a888717; step 3 below |
The cold-layout dump is tests/layout_dump.rs, new at f6242aa: it prints
every widget's box for many grown trees so two commits can be diffed on cold
layout, which the warm/cold oracle cannot see a change to.
What implementing it corrected in the plan is in docs/LAYOUT_LOG.md; the
four that would have shipped as wrong layout are a share inside padding
losing the padding twice, a root resolving its own rule twice, a rule changed
over two pads relocating the column under them instead of dividing it again,
and a resize leaving a short scroll's window-tall content where it was. Each
is pinned as a named test.
The renders and the replay are done and recorded in docs/LAYOUT_LOG.md:
against #18, minimal and tabs are byte-identical (before and after the
reference gesture), random differs in two pixels of glyph antialiasing,
text moves one padded block one pixel, and a live resize of random
matches a cold render at that size byte for byte. Re-run at a888717, all
five are byte-identical to the same renders at a30971e, so the scroll fix
below changed none of them.
Not done: the pre-submit review of f6242aa, the render set at it, the doc
pruning and the landing (steps 1, 3 and 6 below). Step 5's measurements and
the two ideas it tested are in docs/LAYOUT_LOG.md under "What the step 5
measurements found".
The worker's older step 3/4 experiment is preserved as branch
wip/step3-experiment (one commit over 4328eac) and as
~/repos/iris-step3-experiment.patch. It is evidence, not the protocol.
The app's Iris pin is unchanged.
The two rules to protect
These outrank the accumulated machinery:
- A changed tree lays out exactly as if it had been drawn that way from the start. The warm/cold oracle and shrinker test this.
- Lengths are predictable.
pxis that many pixels;rel(0.5)is half of the frame decided for the widget, wherever it sits;leftoveris a share of the room left after every sibling'spxandrellengths are resolved.
Do not fix a failure with a tolerance, another measurement flag, a special
case in Span, or another layout method.
What the previous plan got wrong
The full account is in docs/LAYOUT_LOG.md. The short version, because it
is the third plan for this repair and the next one should not repeat it:
- Every plan kept the second draw. The old protocol drew a widget in the
box it was asked in, then drew it again in the box its own answer placed
it in whenever the first drawing's
Holdsdid not cover that box. All the offer machinery --offer_place,offer_part,at_offer,measured(), the local-redraw deferral -- existed to remember which of the two draws was the question. The plans tried to define that bit better; the defect was that there were two draws at all. - The step 3 plan then over-corrected. It said "every drawing must hold
for the answer box it supplies", and the worker implemented exactly that as
an assertion in
place. A wrappedTextasked at 45 px whose longest word is 89.5 px cannot satisfy it, and neither can any widget that reads its box and reports something other than it. The answer box is not a question, so no contract about it can be demanded of the widget. - It also let a caller narrow a frame by position. A frame narrowed to a region (the worker's share frames) does not move when the part it sits in moves; only a frame narrowed to a length, put back into the part on every placement, does.
The protocol now in the experiment
A widget draws once, in the box it is asked in. Its answer is placed inside
that box by re-expressing the drawing. Nothing is drawn again in a box an
answer chose. Holds is a contract about the ask box alone, consulted only
to decide whether a re-ask can be skipped. This is draw_inner at 1512d84:
let reused = (!stale)
.then(|| self.retained_answer(id, part, info))
.flatten()
.and_then(|answer| {
let extent = placed_extent(part, answer.0, declared, info.fill(), align);
self.try_reuse(id, part, extent, info, rsc).map(|()| answer)
});
let answer = reused.unwrap_or_else(|| {
if old.is_none() {
old = self.remove(id, false, rsc);
}
let answer = self.draw_at(id, part, info, old.take(), rsc);
let extent = placed_extent(part, answer.0, declared, info.fill(), align);
if extent != part {
self.relocate(id, extent, info, rsc);
}
answer
});
try_reuse checks the drawing against part and relocates it to extent;
the old place (redraw in the answer box) is gone, and with it every offer
field's purpose. ActiveData keeps part as the ask box, asked as where
it was asked and placed as where it was put (renamed in step 4).
A container that puts an answer somewhere other than where it asked says so with a new call that never runs the body:
/// Puts a child asked about in this draw somewhere else in this
/// widget's box: its answer, placed in this part instead. The drawing
/// is re-expressed there rather than made again -- what a row does once
/// it knows every slot, having measured each child from its cursor.
pub fn place_at<W: ?Sized>(&mut self, id: &StrongWidget<W>, place: [Place; 2])
A frame is narrowed by a length of the window, never a region and never a
fraction of the parent's frame -- a row's slot cannot be written as a
fraction of the row. The box stays whatever place names; only a declaration
places the box inside it, by the child's alignment, on every placement:
pub fn widget_at<'s, W: ?Sized>(
&'s mut self,
id: &'s StrongWidget<W>,
narrow: [Option<Len>; 2],
place: [Place; 2],
) -> DrawResult<'s, 'a, W>
Span takes each child's length along itself from its hint where one says
-- a rule, or a widget that always reports the whole of its box -- and
otherwise asks the child once from its cursor (Within(From(cursor..far))).
Then it moves a drawn fixed child to its slot, and asks every other child
once in its decided slot, a share with the frame narrowed to it. This is
Span::draw at f6242aa:
let size = match painter.size_hint(child, axis) {
Some(len) => {
measured.push(None);
len
}
None => {
let room = Place::Within(Part::From(along(cursor, far)));
let size = painter
.widget_at(child, [None; 2], axis.pair(room, across))
.size();
measured.push(Some(size));
size.axis(axis)
}
};
...
let slot = along(from, start);
let place = axis.pair(Place::Fill(Part::From(slot)), across);
let mut narrow = [None; 2];
if len.leftover > Weight::ZERO && shares {
narrow[axis as usize] = Some(slot.len());
}
let used = match (measured, narrow[axis as usize]) {
(Some(size), None) => {
painter.place_at(child, place);
size.axis(!axis)
}
_ => painter.widget_at(child, narrow, place).len(!axis),
};
A hint is the length the child's draw would report: Painter::size_hint
resolves a fraction in it against the asking widget's frame, which is the
frame a child asked with nothing narrowed gets, and pins that frame where it
did. Scroll, Masked, a Stack without a sizing child and the fixture's
Branch hint LEFTOVER, since each always reports it; Rect and ()
already did. A widget that reports a share its children gave it -- a span
with a Rect in it -- has no hint and is still asked twice, which is the
whole of what step 5 left.
Stack asks non-sizing children with Part::Sized(len) of what its sizing
child decided, on every axis that is not a share -- a box of that length
where their own alignment puts it, and that length as their frame -- and
Scroll asks its content once in the viewport and place_ats it to the
scrolled offset.
A local redraw asks the retained question again -- the same place of the box the parent was asked in -- and, if the answer stands, puts the fresh drawing back at the retained place of the box the parent's answer chose. Both halves are needed: seed 2 at depth 4 (a stack sized by its text) fails without the second.
A widget its parent asked more than once in one draw -- a share child, asked
in the room and then in its slot -- has two questions and one record, so it
cannot settle locally: redraw defers it to the parent the way it defers a
widget whose declared length changed (ActiveData::re_asked, set by
widget_at when the child is already in children). Seed 946 at depth 6
found the case: a fixed-height column that is a share while its rect fits
and a fixed width once it does not, so emptying it changes the room answer
and not the slot answer.
A symbolic length a child pinned now composes through Part::Of where the
part is the whole box less pixels, and pins the parent's own length otherwise
(in_parent). Dropping it let a zero Pad reuse a drawing across a narrowed
frame of the same pixel length; the shrinker found six such seeds at depth 5.
Decisions
Decided with Bryan on 2026-09-17 and 2026-09-18, kept where still true.
One draw method, in a box decided from above
Widget::draw remains the only layout method. A container's body runs only
in a box its parent offered or decided, never in a box derived from the
container's own answer. The experiment extends this to every widget: a
leaf is not drawn in its answer box either. Its drawing is re-expressed
there, which for a text means the block it shaped at the asked width is
positioned inside the box its reported size chose, and its lines do not
change. examples/text.rs and random have not been rendered since; do that
before landing and inspect any change.
Frames are narrowed by every length decided from above
A declared px or rel, a resolved share, and the box a stack's sizing
child decided (Bryan, 2026-09-18: the sizing child, if any, determines how
the rest are laid out) all narrow the frame.
declared_lens still excludes leftover, which is right: a share has no
length until the span divides its room, and it narrows the frame at the
placing ask instead.
Padding is an inset, and the frame is a length while the box is a region
Bryan, 2026-09-18: padding is an inset. It subtracts from both the child's
frame and its box and adds itself to the reported size, so rel(1.0) inside
padding fills the parent without overflowing. A span's frame never subtracts
siblings; only the padding subtracts from it. No outset kind and no mixed
kind for now; the name stays Pad. Worked example, 900 px row:
let row = (rect(Color::RED).width(24), wtext(PARAGRAPH).wrap(true).pad(16)).span(Dir::RIGHT);
The text is asked in 900 − 24 − 32 = 844 px and wraps there; a
rel(1.0) inside the same padding is 900 − 32 = 868 px and overflows the
row by exactly the icon's width. With the pad in a share instead, both are
the share less 32. An icon after the padded text overflows; a user who
wanted otherwise meant leftover.
Implemented at 1512d84. Pad reads its own frame (Painter::frame_len,
which pins it), takes the padding off, and hands that down as the child's
frame, while the box it gives is the inset part of its own box. The two are
different lengths whenever the box is narrower than the frame -- which is
exactly the wrapping case above.
A share never adds room beyond the deciding box
Scroll resolves its content length from the fixed part of the answer and
makes it at least the viewport (4328eac). Unchanged.
Existing fixed-point and box-chain design stays
Unchanged; see docs/LAYOUT.md.
Implementation plan
Work in /home/bob/repos/iris-layout-experiment on wip/hint-first from
f6242aa. Make each step a warning-clean commit, push it, and run its named
checks before the next. If a step exposes a different mechanism, stop and
update this handoff rather than papering over it.
The order is 1, 3, 6: review the step 5 commit, render at it, then land. Steps 2, 4 and 5 are done and kept below for what they decided.
Anything a fuzzer finds is shrunk first (SHRINK_SEED=<seed> SHRINK_DEPTH=<depth> SHRINK_CASE=<case>, which now prints each level's
frame, ask, box and size warm against cold), pinned as a named test in
tests/cases/unsettled.rs, then fixed under the two rules above. Do not add
a second draw back.
1. Review the step 5 commit
The six commits 4328eac..a888717 were reviewed as one diff (done at
a888717; it found one wrong layout, fixed at e8a5792 and pinned by
scroll::content_that_fits_is_placed_in_the_viewport_and_not_in_the_window,
and three stale comments). Still to do: the pre-submit review of
a888717..f6242aa, which the planner wrote and measured but did not
review as a separate pass. It touches Span::draw, Painter::size_hint,
four size_hint implementations and the dump rig. Things to look at:
Painter::size_hint's pin: it setsframe_own_lenwhere the hint has a fraction. A child asked in its slot afterwards pins the same frame throughin_parentanyway, so the pin only matters for a caller that reads a hint and never asks the child. Confirm that reading is right, or delete the pin and say why.- A hinted share child with no room is never asked and
Spancallspainter.undrawon it, which is what takes its last frame's drawing down. A drawn-in-the-room child got the same call before; check nothing else relied on the room draw having happened first (size_depsrecords the hint read, anddraw_at'saskedhandles a dep that is not a child). - The two comments in
Span::drawabove the passes, and theStackhint's comment.
Check: the ordinary suite, then cargo test --release --test generated.
Anything the review changes in Span or Painter re-runs the three long
fuzzers and the cold dump (commands under step 6).
2. Make the frame a length and the box a region
Done at 1512d84 and 23523ee. The settled rule is in docs/LAYOUT.md
under "Frames, decided boxes and padding"; what implementing it corrected in
the plan -- four of them wrong layout that would have shipped -- is in
docs/LAYOUT_LOG.md.
3. Render and replay
Done at adbedaf and re-run at a888717, against e44dea3 (#18); what
each render showed is in docs/LAYOUT_LOG.md. Not yet run at f6242aa.
Render the five examples, the tabs replay and the random resize at the
reviewed head and compare with the same renders at a888717 (a
git worktree add ../iris-a888717 a888717 with its own target directory,
one process at a time). Expected: byte-identical, every one -- the cold
dump already says so for 400 random trees, but the fuzzer grows no rel
rules and the examples do, so text is the render that can still differ.
Any difference is a finding: read the records before touching anything.
view has no counterpart in #18 and is compared with a888717 only. The
commands are under step 6.
4. Rename and delete
Done at a30971e. ActiveData and DrawInfo now say part for the box
a widget was asked in, asked for the place it was asked at and placed for
where its drawing was put; LayoutHolds::frame is window, since those
ranges are window pixels and the frame's own entry is the frame_len pin
beside them, and Painter::frame_own is window_own. answers_at had one
caller and is inlined there. ActiveData::measured is kept: place_in reads
it, and what it says -- the answer rather than the last drawing's report --
is worth a name.
5. Restore the expected retained cost
Done at f6242aa, by one change: a span takes a child's length from
its hint or rule and asks it once, in its slot, instead of drawing it in the
measuring room first. Work counters, seeds 1 and 13, depth 8, widget draws /
distinct widgets, beside e44dea3 (#18) and a888717 (before it):
| seed 1 | e44dea3 | a888717 | f6242aa |
|---|---|---|---|
| cold | 369/261 | 342/288 | 264/232 |
| many | 157/95 | 118/95 | 41/41 |
| size | 16/12 | 3/3 | 3/3 |
| scroll | 2 | 1 | 1 |
| resize | 13/13 | 44/13 | 36/13 |
| seed 13 | e44dea3 | a888717 | f6242aa |
|---|---|---|---|
| cold | 1330/707 | 1278/982 | 758/627 |
| many | 524/159 | 429/366 | 16/16 |
| resize | nothing | nothing | nothing |
The command is
IRIS_SEED=1 IRIS_DEPTH=8 cargo test --release --features layout-diagnostics \
--test layout_diagnostics -- --ignored --nocapture layout_cost
reading widget draws and distinct widgets from each phase's block, and
hottest widget draws with IRIS_PHASE=resize IRIS_FRAMES=2 for who is
drawn how often.
many, size and scroll are at their floor: every draw is a marked
widget, or the parent a marked widget deferred to. cold is under #18 at
both seeds. What is left is resize at seed 1, 36 draws of 13 widgets where
#18 drew 13, and the cold draws over the distinct count (32 at seed 1,
131 at seed 13). Both are one mechanism, understood and not yet worth a
change: a reported share -- a span whose children report leftover, a
stack sized by a child, a wrapper round either -- has no hint, so its parent
still asks it in the room and again in its slot, and each of those asks
draws, since the room drawing divided the room and does not hold for the
slot. Under the root's resize redraw that multiplies down the tree: the
hottest widget at seed 1 is a Span drawn 8 times in one frame, two
levels of reported shares under it drawn 4. The design that would remove it
is in docs/LAYOUT_LOG.md ("two answers per record"); do it only once an
app screen shows the cost, and measure that screen first.
Two ideas from the previous version of this step were tested and are not
to be done; the measurements are in docs/LAYOUT_LOG.md:
- Not reading the span's own length where no slot depends on it
(
wip/insetat5b181bc, pushed): identical counters at every phase. The symbolic pin survives a resize because every ask box is symbolically stable across one; the earlier "44 to 20" came from share spans reusing their room drawing in their slot, which is wrong layout rather than saved work. - Resolving a rule that is a fraction of the frame from the record instead
of redrawing:
reuse outside: a frame lengthis 3 draws of 264 cold at seed 1 and 1 of 758 at seed 13. Nothing to recover.
Report every phase at both seeds, work counters first, medians only when the work agrees.
6. Full verification and landing
Run, in the experiment checkout:
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo clippy --workspace --all-targets --features layout-diagnostics -- -D warnings
cargo test --workspace
cargo test --release --test generated
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
SHRINK_CASE=all SHRINK_SEEDS=2000 SHRINK_DEPTH=4 \
cargo test --release --test shrink -- --ignored --nocapture
The last line is the 2000-seed depth-4 scan over all sixteen cases; the
shrinker runs the same cases as the scan and reduces anything it finds, so
no temporary test body is needed any more. Rng::new uses seed | 1, so
adjacent even/odd seed pairs describe the same tree. All of these passed at
f6242aa; re-run whatever a later commit could affect.
A change that could move cold layout also gets the dump diff, which the oracle cannot replace:
IRIS_DUMP_SEEDS=400 IRIS_DUMP_DEPTH=5 cargo test --release --test layout_dump \
-- --ignored --nocapture | grep -E '^[0-9]+ [0-9]+ ' > /tmp/after.txt
once at the commit before and once after (check the earlier commit out in
the same checkout so the build is incremental; the rig file is untracked
there, copy it in with git show wip/hint-first:tests/layout_dump.rs), then
diff the two. Zero differing lines is the expectation for a cost change.
Render view, minimal, random, tabs and text at 1920x1200 and inspect
every intentional change. Also replay tabs and compare a live resize of
random with a cold render at the same size. Read the installed graphics
skill before rendering and confirm the renderer; an llvmpipe fallback can
produce a plausible PNG. The headless rig reuses one compositor, so run one
process at a time and give comparison worktrees separate target directories:
./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 reference replay is:
0 down 1728 24
80 up 1728 24
400 down 1836 1116
480 up 1836 1116
800 down 1836 1116
880 up 1836 1116
Before submitting, run the pre-submit review. Then land: move any surviving
fact from docs/LAYOUT_LOG.md into docs/LAYOUT.md (the settled protocol,
the measurement method including the dump rig, and the "not to be done"
results under step 5), delete the log, update this handoff to the next
actual task, and push every coherent commit. How the branch reaches
upstream is Bryan's call and has not been asked: wip/hint-first is some
sixty commits over #18's split/18-position-chain, which is still open, so
either #18 lands first and this follows as one PR, or this replaces #18.
Ask before opening anything. Update the app's Iris pin only when the Iris
change is ready.
Follow-on work, not part of this repair
- CPU round-to-nearest and shader nearest-pixel snapping are approved as one
separately verified change. Neither has landed. Re-derive
Holds::throughfor the new rounding and run both long fuzzers plus the render set. - Smaller layout items remain in
docs/LAYOUT_LOG.md: an undrawn share's gap, nested share weights, inconsistent zero-divisor fallbacks, and the stalef32identity comment. LazySpan, thenSizeRule::{Min, Max, Clamp}. A cap may not containleftover; whetherMaxnarrows the child's drawing box is still a real product decision.Scrolltaking a direction rather than one axis.
Other product work remains in docs/PLAN.md and the focused documents it
links. Do not mix it into the Iris layout branch.