22 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/one-ask at 0ef87eb, two commits over 4328eac (the head of
wip/transparent-frames, which is unchanged). It replaces the step 3 plan
below with a smaller protocol change, and it passes every check:
check at 0ef87eb |
result |
|---|---|
cargo fmt --all --check, clippy -D warnings, with and without layout-diagnostics |
clean |
cargo test --workspace (debug) |
115 suite, 20 core, 11 generated, all green |
| the two decided-box pins from step 1, and the new seed 946 pin | pass (the first two were red at 4328eac) |
cargo test --release --test generated |
11/11 |
| shrinker, 400 seeds, depth 5, all fifteen cases | agree, 56 s |
| 2000-seed depth-4 scan, all fifteen cases | agree, 262 s |
| 1000 seeds at depth 6 | agree, 148 s (found seed 946 at 3091fb8, fixed and pinned in 0ef87eb) |
Not done: the renders and the tabs replay (step 2), and the pre-submit
review of the two commits.
The worker's uncommitted 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 3091fb8:
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, frame, 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.reposition(id, frame, 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 offer_part as the ask box, offer_place
as where it was asked and place as where it was put; the names are the old
ones and should be renamed (part, asked, placed) when this lands.
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 parent's frame, never a region, and is put back into the part 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 asks every child once from its cursor (Within(From(cursor..far))),
then moves fixed children to their slots and asks share children once more
in their decided slot with the frame narrowed to it:
let slot = along(from, start);
let place = axis.pair(Place::Fill(Part::From(slot)), across);
let used = match len.leftover > Weight::ZERO && shares {
true => {
let mut narrow = [None; 2];
narrow[axis as usize] = Some(slot.len());
painter.widget_at(child, narrow, place).len(!axis)
}
false => {
painter.place_at(child, place);
size.axis(!axis)
}
};
Stack asks non-sizing children in the box the sizing child decided, with
the frame narrowed to it on every axis that is not a share, 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.
The experiment does not implement this, and cannot in its representation:
a narrowed frame is its own box there (frame_and_extent sets the extent to
FULL of it), so 868 for rel and 844 for the wrap cannot both hold. Pad
still insets only the box through Part::Of and forwards the frame whole,
which makes rel(1.0) under it 900. Step 2 below is the representation
change that expresses the rule.
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/one-ask from
3091fb8. Make each step a warning-clean commit 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.
1. Review the two commits
Run the pre-submit review over 3091fb8 and 0ef87eb as one diff against
4328eac; they were written as a probe and reviewed only by their tests.
Anything a fuzzer finds later is shrunk first (SHRINK_SEED=<seed> SHRINK_DEPTH=<depth> SHRINK_CASE=<case>), pinned as a named test in
tests/cases/unsettled.rs, then fixed under the rule above. Do not add a
second draw back.
2. Make the frame a length and the box a region
The frame stops being a coordinate system. There is one coordinate unit,
the window: every box in the tree is a region in window units, a widget's
frame is a length in the same units and is only what fractions resolve
against, and a region node's move entry is a translation -- a region rel 1 long anchored at the node's box start -- rather than a box. The last
point is forced, not chosen: a node whose unit was its frame would need its
box expressed in frame units, which is a division of two lengths and not a
rel + px length (a .region_node() child of a pad in a span room is the
case). Pending Bryan's yes, 2026-09-18: it changes one cost. A node that
moves, or scrolls, is one entry write whatever narrowed it (today only a
narrowed node gets that; a transparent one is re-expressed), and a node
whose box changes length at the same window size is re-expressed as a
subtree (today a narrowed node is one write). The shader's resolve_move
composes a rel 1 entry as a translation already and does not change.
pub struct Painter<'a> {
/// This widget's frame as a length of its region node's box, per axis:
/// what a fraction it declares or reports, and a `From` span under it,
/// are fractions of. A length and not a region, so the box need not be
/// the frame -- padding takes from both without either becoming the other.
pub(super) frame: UiVec2,
/// Where this widget's drawing goes, in the node's coordinates.
pub(super) extent: UiRegion,
/// The window in pixels: the frame in pixels is `frame.to_px(window)`
/// and the box's is `extent.size().to_px(window)`. Every `Holds` range
/// is in these pixels, so `in_parent` combines them without `through`,
/// and a widget's own frame reads convert once: `Holds::at(frame_px)
/// .through(frame)`.
pub(super) window: PxVec2,
...
}
impl Part {
/// Where it lands in the coordinates `extent` is in. A `From` span is in
/// the asking widget's frame lengths, which `frame` says in the node's.
pub(crate) fn of(self, extent: UiSpan, frame: Len) -> UiSpan {
match self {
Self::All => extent,
Self::From(span) => UiSpan::new(
extent.start + span.start.within_len(frame),
extent.start + span.end.within_len(frame),
),
Self::Of(span) => span.within(&extent),
}
}
}
A child's frame is narrow.within_len(parent.frame) where the caller or a
rule narrowed it, else the parent's; a declared length also decides the box
(len placed in the part by alignment, as frame_and_extent does today),
where a caller's narrow decides the frame alone and the place decides the
box. Pad then says both halves of the rule:
let inset = |lead: Px, trail: Px| {
Place::Within(Part::Of(UiSpan::new(
Len::from_parts(Rel::ZERO, lead),
Len::from_parts(Rel::ONE, -trail),
)))
};
let less = |lead: Px, trail: Px| Some(Len::from_parts(Rel::ONE, -(lead + trail)));
let inner = painter
.widget_at(
&self.inner,
[less(self.padding.left, self.padding.right), less(self.padding.top, self.padding.bottom)],
[inset(self.padding.left, self.padding.right), inset(self.padding.top, self.padding.bottom)],
)
.size();
What this changes in the core, and why it is not merely more code:
Painter::resolvebecomesregion.within(&self.extent); the secondwithinthrough the frame goes.in_parentno longer maps a child's frame holds through the frame length per level: every widget holds for the window's pixels, so the holdsanddirectly, at region nodes too.Part::ofgains onewithin_lenperFromend.OfandAllare as they were.DrawInfo::frame/frame_abs,ActiveData::frame/frame_abs,localinPlacing,recompose_subtreeandasked_pxall change meaning:frameis aUiVec2length,frame_absgoes, a node's move entry is arel 1region at the node's box start, andasked_pxbecomes the window size.Stackneeds a part that is "a box of this length, placed by the child's alignment" (Part::Sized(Len), the same thing a declared length does), sincenarrowno longer makes the box.
The random rig and the shrunk tests do not change. Expected precision is the
same as today (one rel × rel per level of narrowing). Checks: the suite,
the fast oracle, the 400/5 shrinker, then both long fuzzers; the many,
resize and cold counters must not move by more than the reposition work
this removes; and two new tests, rel(1.0) inside .pad(16) after a 24 px
icon is 868 px while the wrapped text beside it wraps at 844, and the same
inside a 450 px share is 418 for both. examples/text.rs then renders
inside its padding.
3. Render and replay
Read the installed graphics skill and confirm the renderer. Render view,
minimal, random, tabs and text at 1920x1200 against 34cafb6 and
e44dea3, replay tabs, and compare a live resize of random with a cold
render at the same size (commands under Full verification below). A
text placed by re-expression rather than a second draw is the change most
likely to show here; inspect every intentional difference and record it.
4. Rename and delete
Rename ActiveData::offer_part to part, offer_place to asked, place
to placed, and DrawInfo likewise; delete ActiveData::measured in favour
of reading answer; delete answers_at if resize is its only caller and
inline it. Every use was written against the old names on purpose to keep
the probe's diff readable; do this as one mechanical commit. Suite, oracle.
5. Restore the expected retained cost
Work counters at 3091fb8, seed 1 and 13, depth 8, widget draws / distinct
widgets, beside e44dea3 (#18) and 49cec82 (the branch head before this):
| seed 1 | e44dea3 | 49cec82 | 0ef87eb |
|---|---|---|---|
| cold | 369/261 | 516/288 | 331/288 |
| many | 157/95 | 187/119 | 110/92 |
| size | 16/12 | 3/3 | 3/3 |
| scroll | 2 | 1 | 1 |
| resize | 13/13 | 24/76 | 40/15 |
| seed 13 | e44dea3 | 49cec82 | 0ef87eb |
|---|---|---|---|
| cold | 1330/707 | 2940/982 | 1179/982 |
| many | 524/159 | 1091/423 | 424/364 |
| resize | nothing | 2215/510 | nothing |
many and size are better than #18 at seed 1 and many draws fewer times
at seed 13, but it touches twice as many distinct widgets there, and resize
at seed 1 draws 40 times where #18 drew 13. Two mechanisms, both understood:
- A share child is asked twice per span draw -- in the measuring room
with the frame forwarded, then in its slot with the frame narrowed. Each
ask that reads pixels or pins a length draws, and since
0ef87ebevery local change inside a share child redraws its span as well. GiveSpana measure-only ask for the first pass: reuse the retained answer when its holds contain the room, without validating or relocating the drawing, and let the placing ask settle the drawing. The answer contract must then carry no symbolic pin (a span's total does not depend onfar; only its slots do), which is the separation the worker's experiment made withanswer_extent_len. With that, a twice-asked child could keep both answers and settle locally by re-asking both questions instead of deferring. Measuremanyat seed 13 before and after; the seed 946 pin must stay green throughout. - A positive-direction span with no shares pins
farit does not need, so a resize redraws it. Readextent_lenonly where a slot depends on it (shares, orSign::Neg), and express the measuring room's far end without the length. Measureresizeat seed 1 before and after.
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 fifteen 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.
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. Once the protocol lands, move
any surviving fact from docs/LAYOUT_LOG.md into docs/LAYOUT.md, delete
the log, update this handoff to the next actual task, update the app's Iris
pin only when the Iris change is ready, and push every coherent commit.
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.