The pre-submit review of the six one-ask commits found a scroll placing content that fits into a window-length box rather than its viewport, and three comments still calling window lengths frame lengths.
430 lines
20 KiB
Markdown
430 lines
20 KiB
Markdown
# 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 **`a888717`**, eight commits over `4328eac` (the head of
|
||
`wip/transparent-frames`, which is unchanged). It replaces the old step 3
|
||
plan with the one-ask protocol below, `1512d84` and `23523ee` make the frame
|
||
a length of the window, and `e8a5792` is what the step 1 review found. It
|
||
passes every check:
|
||
|
||
| check at `a888717` | 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, 73 s (34,488 widgets) |
|
||
| 1000 seeds at depth 6 | agree, 187 s |
|
||
| 2000-seed depth-4 scan, all sixteen cases | agree, 347 s (82,203 widgets) |
|
||
| renders, the `tabs` replay and the `random` resize against #18 | inspected, see below |
|
||
|
||
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 retained-cost work (step 5). Step 1's review is done and what
|
||
it found is in `docs/LAYOUT_LOG.md`.
|
||
|
||
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:
|
||
|
||
1. 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.
|
||
2. Lengths are predictable. `px` is that many pixels; `rel(0.5)` is half of
|
||
the frame decided for the widget, wherever it sits; `leftover` is a share
|
||
of the room left after every sibling's `px` and `rel` lengths 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 `Holds` did 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 wrapped `Text` asked 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`:
|
||
|
||
```rust
|
||
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 `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:
|
||
|
||
```rust
|
||
/// 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:
|
||
|
||
```rust
|
||
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:
|
||
|
||
```rust
|
||
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 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_at`s 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:
|
||
|
||
```rust
|
||
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/one-ask` from
|
||
`a30971e`. 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.
|
||
|
||
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 six commits
|
||
|
||
**Done at `a888717`.** The pre-submit review over the six commits as one
|
||
diff against `4328eac` found one wrong layout -- a scroll placing content
|
||
that fits into a window-length box rather than the viewport -- and three
|
||
comments left describing lengths as fractions of the frame. Both are in
|
||
`docs/LAYOUT_LOG.md`; the fix is `e8a5792`, pinned by
|
||
`scroll::content_that_fits_is_placed_in_the_viewport_and_not_in_the_window`.
|
||
|
||
### 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`**, against `e44dea3` (#18); what each render showed is
|
||
in `docs/LAYOUT_LOG.md`. `view` has no counterpart in the baseline, so it
|
||
was rendered but not compared. Run the set again after any further change
|
||
here -- the commands are under **Full verification** below, and a text
|
||
placed by re-expression rather than by a second draw is what shows first.
|
||
|
||
### 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
|
||
|
||
Work counters, seed 1 and 13, depth 8, widget draws / distinct widgets,
|
||
beside `e44dea3` (#18) and `0ef87eb` (before the frame became a length):
|
||
|
||
| seed 1 | e44dea3 | 0ef87eb | adbedaf |
|
||
| --- | --- | --- | --- |
|
||
| cold | 369/261 | 331/288 | 342/288 |
|
||
| many | 157/95 | 110/92 | 118/95 |
|
||
| size | 16/12 | 3/3 | 3/3 |
|
||
| scroll | 2 | 1 | 1 |
|
||
| resize | 13/13 | 40/15 | 44/13 |
|
||
|
||
| seed 13 | e44dea3 | 0ef87eb | adbedaf |
|
||
| --- | --- | --- | --- |
|
||
| cold | 1330/707 | 1179/982 | 1278/982 |
|
||
| many | 524/159 | 424/364 | 429/366 |
|
||
| resize | nothing | nothing | nothing |
|
||
|
||
`many`, `size` and `scroll` are better than #18 and within a few draws of
|
||
`0ef87eb`; `cold` is 3% and 8% more than `0ef87eb` for the same distinct
|
||
widgets, which is the frame pins making a widget answer again where it used
|
||
to be reused on a box that happened to match. `resize` at seed 1 still draws
|
||
44 times where #18 drew 13.
|
||
|
||
Measuring this is what found `adbedaf`: the fuzzer's own `Branch` pinned the
|
||
window rather than saying which side of its threshold it was on, which put
|
||
seed 1's resize at 131 and seed 13's at 828. A fixture that redraws
|
||
everything on a resize cannot tell a change that reuses well from one that
|
||
does not, so check the fixture before believing a regression.
|
||
|
||
Three mechanisms behind what is left, all 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 `0ef87eb` every
|
||
local change inside a share child redraws its span as well. Give `Span` a
|
||
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 on `far`; only its
|
||
slots do), which is the separation the worker's experiment made with
|
||
`answer_extent_len`. With that, a twice-asked child could keep both
|
||
answers and settle locally by re-asking both questions instead of
|
||
deferring. Measure `many` at seed 13 before and after; the seed 946 pin
|
||
must stay green throughout.
|
||
- **A positive-direction span with no shares pins `far`** it does not need,
|
||
so a resize redraws it. Read `extent_len` only where a slot depends on it
|
||
(shares, or `Sign::Neg`), and express the measuring room's far end without
|
||
the length. Measure `resize` at seed 1 before and after.
|
||
- **A rule that is a fraction of the frame pins the frame**, which is what
|
||
`cold` grew by. The answer for such an axis is a pure function of the rule
|
||
and the frame, so a reuse could resolve it again from the record instead
|
||
of redrawing -- `placed_extent` already takes `declared`. Worth trying
|
||
before anything subtler; the pin stays for the axes that read the frame.
|
||
|
||
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:
|
||
|
||
```sh
|
||
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.
|
||
|
||
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:
|
||
|
||
```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 reference replay is:
|
||
|
||
```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
|
||
```
|
||
|
||
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::through`
|
||
for 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
|
||
stale `f32` identity comment.
|
||
- `LazySpan`, then `SizeRule::{Min, Max, Clamp}`. A cap may not contain
|
||
`leftover`; whether `Max` narrows the child's drawing box is still a real
|
||
product decision.
|
||
- `Scroll` taking 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.
|