407 lines
19 KiB
Markdown
407 lines
19 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 transparent frames 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` (the
|
|
untracked `target-own/` there is its build output). It is the reviewed
|
|
baseline this work must preserve or improve. `/home/bob/repos/iris-pr18` has
|
|
since moved to a different WIP branch; do not use that checkout as #18.
|
|
|
|
The continuation is `/home/bob/repos/iris-layout-experiment`, branch
|
|
`wip/transparent-frames`, head **`4328eac`**, five commits over **`34cafb6`**.
|
|
It implements transparent frames, pins the two open failures, and corrects
|
|
`Scroll`'s content length, but it is not ready to replace #18:
|
|
|
|
- the shrinker disagrees warm versus cold at seed 2 (`repaint`) and seed 108
|
|
(`reorder`) at depth 5;
|
|
- `many` and `resize` still do more work than #18 on a deep tree;
|
|
- a resolved `leftover` share does not yet narrow its child's frame;
|
|
- step 3 exposed a widget-contract question that must be planned before the
|
|
retained protocol can change; see **Step 3 stop** below.
|
|
|
|
The app's Iris pin is unchanged. The step 3 experiment described below was
|
|
reverted; the experiment checkout was clean at `4328eac` when last inspected.
|
|
|
|
## 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. The investigation tried those
|
|
shapes and found that the protocol was asking an unanswerable question.
|
|
|
|
## Decisions
|
|
|
|
Decided with Bryan on 2026-09-17 and 2026-09-18.
|
|
|
|
### One draw method, in a box decided from above
|
|
|
|
`Widget::draw` remains the only layout method. A second measure method would
|
|
duplicate layout and drift from drawing; a shared helper would merely move
|
|
that obligation without removing it.
|
|
|
|
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. Measuring asks may be
|
|
provisional while a parent is dividing room. Once the parent decides a slot,
|
|
the child is evaluated in that slot; placing an answer is reuse or translation,
|
|
not another `draw_at` in an answer-derived box.
|
|
|
|
The current `offer` bit cannot express this. It is derived from
|
|
`place == offer_place`, but one parent draw can evaluate a child in the
|
|
parent's room, in a slot the parent decided, and in a box derived from the
|
|
child's own answer. The fresh answer is right in some of those boxes and a
|
|
retained answer in others. Remove the question rather than adding state that
|
|
tries to answer it.
|
|
|
|
The target retained model has one answer per widget. Its `Holds` contract says
|
|
which parts it remains valid for. `redraw` re-asks it in the part of its last
|
|
parent ask. The offer machinery can then go: answer gating, `offer_place`,
|
|
`offer_part`, `at_offer`, and `measured()`.
|
|
|
|
Keep `Part::Of`. It expresses a part of a widget's own box without making the
|
|
container read that box's length, and was a sound addition to the experiment.
|
|
Its dropped `extent_len` pin exposed seed 2, but composing every such pin
|
|
through `Of` is not the repair: that experiment broke seed 220 and the
|
|
region-node regression.
|
|
|
|
### Frames are narrowed by every length decided from above
|
|
|
|
Containers that only divide room are transparent: absent a length decision,
|
|
they forward the parent's frame. A declared `px` or `rel` length narrows the
|
|
child's frame. **A resolved `leftover` share narrows it in exactly the same
|
|
way.** The code's exclusion of `leftover` in `declared_lens` is a bug.
|
|
|
|
A share is known only after the deciding span has measured fixed children and
|
|
divided its room. The measuring ask therefore cannot settle a `leftover`
|
|
child's frame. The placing ask supplies the resolved share as both its slot
|
|
and narrowed frame, and fully evaluates the child there. This matters across
|
|
the span too: a wrapping child's height may change once its width share is
|
|
known, so the span reads that child's cross-axis answer from the decided-box
|
|
evaluation, not from the provisional one.
|
|
|
|
A box a widget merely reports does not narrow its descendants' frames. The
|
|
frame changes because a parent decided a declared length or a share, not
|
|
because the child's own answer happened to have that length.
|
|
|
|
### `Pad` remains an outset
|
|
|
|
Padding goes outside what it pads. There is no mixed "outset pixels, inset
|
|
fractions and shares" `Pad`.
|
|
|
|
The clipped `examples/text.rs` render is evidence of the unresolved-share
|
|
bug, not intended behavior and not a reason to replace `rel(1.0)` with
|
|
`leftover`. If a 900 px row gives a padded child a 450 px share, that share
|
|
narrows the pad's frame; after 16 px padding on each side, `rel(1.0)` inside
|
|
the pad is 418 px and fits. Keep the example so it verifies that rule.
|
|
|
|
### A share never adds room beyond the deciding box
|
|
|
|
`Scroll` currently calls `apply_leftover`, which turns any `leftover` in the
|
|
content answer into a whole additional viewport. A row reporting `600 px +
|
|
leftover` in a 900 px viewport therefore gets a 1500 px content box, its text
|
|
re-wraps in room it was not measured in, and layout advances one fixed-point
|
|
iteration each time it is evaluated.
|
|
|
|
That behavior is wrong. A share takes the room left in the viewport. Resolve
|
|
scroll content from only the fixed part of the answer and make it at least the
|
|
viewport:
|
|
|
|
```rust
|
|
let fixed = Len::from_parts(answer_len.rel, answer_len.px).to_px(container_len);
|
|
self.content_len = fixed.max(container_len);
|
|
```
|
|
|
|
For `600 px + leftover` this is 900 px: the share receives 300 px and nothing
|
|
scrolls. Pixel content still scrolls when its fixed length exceeds the
|
|
viewport. A lone share fills the viewport. The content box no longer invents
|
|
new room, so `Scroll` is not an own-answer-box exception to the decided-box
|
|
rule.
|
|
|
|
### Existing fixed-point and box-chain design stays
|
|
|
|
The fixed-point grid, `Holds::through`, the pixel box threaded down the draw,
|
|
region nodes, and the warm/cold equality rule are sound. Their durable
|
|
invariants are in `docs/LAYOUT.md`. Transparent frames fixed fraction
|
|
resolution; the remaining defect is evaluating container bodies in multiple
|
|
boxes and trying to infer which evaluation counted as measurement.
|
|
|
|
## Why the open seeds fail
|
|
|
|
The complete traces, shrunk trees and counter-experiments are in
|
|
`docs/LAYOUT_LOG.md`. The short version a worker needs is:
|
|
|
|
- **Seed 2 (`repaint`)**: a stack measures a non-sizing subtree in room the
|
|
stack will never have, then reuses that answer in the stack's one-line box.
|
|
A dropped `Part::Of` pin makes the reuse look valid. Composing that pin fixes
|
|
this seed but breaks seed 220 and an existing region-node test, so it is not
|
|
the fix.
|
|
- **Seed 108 (`reorder`)**: a nested span correctly evaluates a branch in its
|
|
final 300 px box, but `draw_inner` discards the fresh 286 px answer for a
|
|
retained 438.9 px answer from an earlier 450 px evaluation because the
|
|
place expression changed. Always keeping the fresh answer fixes this seed
|
|
but breaks four seeds and two tests under `Scroll`.
|
|
- Those `Scroll` failures are the `apply_leftover` feedback loop above. Once
|
|
that loop is removed, no legitimate container needs to lay children out in
|
|
a box derived from its own answer.
|
|
|
|
The worker faithfully implemented the earlier plan, tried four definitions
|
|
of "measurement", restored the safe deferral when each failed elsewhere, and
|
|
stopped. Do not resume that search.
|
|
|
|
## Implementation plan
|
|
|
|
Work in `/home/bob/repos/iris-layout-experiment` from `49cec82`. 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. Pin the two failures as focused tests
|
|
|
|
**Done in `b842e4f`.** The named tests reproduce the mismatch at `49cec82` and
|
|
remain intentionally red until the protocol repair:
|
|
|
|
- `unsettled::repainting_a_stack_uses_the_box_its_sizing_child_decided`;
|
|
- `unsettled::reordering_nested_spans_keeps_the_answer_from_the_decided_box`.
|
|
|
|
Turn the shrunk seed 2 and seed 108 trees from `docs/LAYOUT_LOG.md` into fast,
|
|
named regression tests. Each must demonstrate the present warm/cold mismatch
|
|
at `49cec82`, then pass because both paths select the same tree and boxes—not
|
|
because the assertion was weakened.
|
|
|
|
Also retain these nearby regression tests while changing the protocol:
|
|
|
|
- `unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered`
|
|
- `unsettled::a_span_given_the_box_its_answer_decided_matches_a_cold_layout`
|
|
- the seed 86 `Scroll` fixed-point case
|
|
- the tests for a length in pixels staying that many pixels and for an exact
|
|
leftover split
|
|
|
|
Check the ordinary suite and each new test individually.
|
|
|
|
### 2. Correct `Scroll`'s content length
|
|
|
|
**Done in `4328eac`.** The four focused cases pass, as do the release fast
|
|
oracle and the depth-5 counterexample seeds 184, 246, 292 and 372. The debug
|
|
suite has 112 passing tests and only the two intentionally red tests above.
|
|
|
|
Replace its `apply_leftover` content sizing with the fixed-part calculation
|
|
above. Add focused cases for:
|
|
|
|
- `600 px + leftover` in 900 px resolves to 900 px;
|
|
- fixed content wider than the viewport still scrolls;
|
|
- a lone `leftover` child fills without scrolling;
|
|
- the wrapping-text-plus-share case is stable warm and cold.
|
|
|
|
The seed 86 test stays until the full protocol has landed, even if its old
|
|
special rule becomes moot.
|
|
|
|
Check the suite, fast oracle, and the known Scroll counterexamples from the
|
|
fresh-answer experiment (seeds 184, 246, 292 and 372).
|
|
|
|
### 3. Evaluate children in parent-decided boxes
|
|
|
|
**STOP: this step needs planning before implementation continues.** A direct
|
|
implementation was tried and fully reverted. It always kept the fresh answer,
|
|
made placement reuse-or-translate (asserting if the retained drawing did not
|
|
hold), asked `Span` children provisionally in the whole box before decided
|
|
placement, gave `Stack` overlays the sizing child's reported box, and changed
|
|
the random `Branch` tail to `Part::Of(40px..FULL)`. Both new focused tests
|
|
passed, but 17 ordinary suite tests failed.
|
|
|
|
Several failures state a load-bearing contract contrary to this plan rather
|
|
than exposing another container bug. `ReadsBox` and `ReadsWidth` deliberately
|
|
read the offered box, report one quarter of it, and their tests require two
|
|
draws: one to answer and one in the answer-derived box. The tests' own comment
|
|
says those draws come in pairs. `a_widget_asked_again_on_another_layer_is_drawn_there`
|
|
likewise requires a second draw when placement changes the layer. Other
|
|
failures include root resize/one-step validity tests, region-node placement,
|
|
and `a_one_pixel_line_keeps_its_pixel_through_a_chain`.
|
|
|
|
The unresolved decision is therefore public widget semantics, not another
|
|
definition of measurement: either a widget must now guarantee that a drawing
|
|
holds for the answer box it reports (and the contrary tests, documentation,
|
|
and any real widgets must be redesigned), or placement sometimes has to run
|
|
the body in an answer-derived box, contradicting the decided-box rule. Do not
|
|
resume step 3 until that choice and its migration are written into the plan.
|
|
|
|
Change placement so an answer-derived box never runs a container body.
|
|
Placing becomes reuse-or-translate. Remove the offer/measurement gate and
|
|
its retained bookkeeping only as each caller stops needing it; do not leave
|
|
a parallel old path.
|
|
|
|
Three current widgets must stop depending on measuring boxes they will never
|
|
own:
|
|
|
|
- `Span`: do not read `extent_len` unconditionally. Slots depend on `far`
|
|
only when shares exist (the decided slot fills its part) or for negative
|
|
direction; compute negative-direction slots from `total`. Pin the extent
|
|
length only in those cases.
|
|
- `Stack`: draw non-sizing children in `From(0..size)` on an axis where the
|
|
sizing child's answer is `px`/`rel`, and `All` where it is `leftover`,
|
|
instead of drawing them in `All` of the measuring room.
|
|
- `Branch` in the random rig: express "the rest of my box" as
|
|
`Of(40px..FULL)` rather than reading `extent_len(Y)`.
|
|
|
|
Every drawing must hold for the answer box it supplies. The two focused tests
|
|
from step 1 and the existing region-node and decided-box tests must pass here.
|
|
|
|
### 4. Make resolved shares narrow frames
|
|
|
|
Give a `leftover` child its resolved slot as its narrowed frame at the placing
|
|
ask. A span becomes a decided two-pass layout:
|
|
|
|
1. measure fixed children and collect share weights;
|
|
2. divide the deciding box's remaining room;
|
|
3. place/evaluate each child in its decided box, with a share child's frame
|
|
narrowed to that share;
|
|
4. derive the span's cross-axis answer from those decided evaluations where a
|
|
child's answer can depend on its share.
|
|
|
|
Do not put `leftover` back into a declaration helper before it has a resolved
|
|
length; unlike `px` and `rel`, its frame cannot be known during the first pass.
|
|
|
|
Add tests that a `rel(1.0)` child directly inside a half share is half the row,
|
|
and that the same child inside `.pad(16)` is the share less 32 px. The existing
|
|
`examples/text.rs` case should render inside its padding without changing its
|
|
width rule.
|
|
|
|
### 5. Remove obsolete machinery and settle the retained path
|
|
|
|
Once all callers use the decided-box path, delete answer gating,
|
|
`offer_place`, `offer_part`, `at_offer`, `measured()`, and the local-redraw
|
|
deferral whose only purpose was distinguishing measurement from placement.
|
|
Write `ActiveData::answer` in one place, and keep `DrawInfo` on `ActiveData`
|
|
rather than copying fields and reconstructing it in `redraw`.
|
|
|
|
Run the ordinary suite, fast oracle and shrinker before doing performance
|
|
work. Both new focused tests must pass on cold, repaint and reorder paths.
|
|
|
|
### 6. Restore the expected retained cost
|
|
|
|
Implement the known-length `Span` shortcut only after correctness is stable:
|
|
a `px` child after a `px` child should draw once cold and never on repaint.
|
|
Report cross-axis sizes from the decided evaluation, including wrapping
|
|
share children. Compare all six diagnostic phases with `e44dea3`; investigate
|
|
work-counter differences before interpreting time.
|
|
|
|
Expected direction, not a license to weaken correctness: `size` and `scroll`
|
|
keep their wins, `many` approaches #18's distinct-widget counts, and `resize`
|
|
returns to about #18's 13 draws at seed 1. Record final counters in the
|
|
temporary log and durable conclusions in `docs/LAYOUT.md`.
|
|
|
|
### 7. Full verification and landing
|
|
|
|
Run, in the experiment checkout:
|
|
|
|
```sh
|
|
cargo fmt --all --check
|
|
cargo clippy --workspace --all-targets -- -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
|
|
```
|
|
|
|
Then repeat the 2000-seed depth-4 scan over all fifteen cases. It is the only
|
|
run that found seeds 1121 and 1839 before their fix; depth and breadth find
|
|
different defects. `Rng::new` uses `seed | 1`, so adjacent even/odd seed
|
|
pairs describe the same tree. The temporary scan target used this body and
|
|
was deleted after the run:
|
|
|
|
```rust
|
|
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());
|
|
}
|
|
}
|
|
});
|
|
```
|
|
|
|
Run all six `layout_diagnostics` phases at seeds 1 and 13, depth 8, against
|
|
`e44dea3`. Compare work counters first; use medians only after the work is the
|
|
same. Check that `UiSpan::within` still inlines with `nm`.
|
|
|
|
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 transparent frames 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.
|
|
|
|
## Verification already performed
|
|
|
|
At `49cec82`: formatting, warning-clean clippy, the debug suite (108 suite
|
|
tests and 20 core tests), the 11 generated cases, and all six diagnostic
|
|
phases ran. The 400-seed depth-5 shrinker fails at seed 2 (`repaint`) and 108
|
|
(`reorder`); the long 1000/6 oracle and 2000/4 scan were intentionally not
|
|
run after that. Reference renders against `34cafb6`: `view` and `minimal`
|
|
were byte-identical; `tabs` differed by 2,332 pixels; `text` exposed the
|
|
unresolved-share bug; `random` moved where nested spans do.
|
|
|
|
At `e44dea3`, the #18 baseline: format, clippy and workspace tests passed;
|
|
the release oracle at 100 seeds, debug oracle at 120 seeds, all fifteen
|
|
shrinker cases at 400/5, the oracle at 1000/6, and the 2000/4 scan passed.
|
|
The five reference renders and the `tabs` replay were byte-identical to their
|
|
baseline, and a live-resized `random` matched a cold render. These statements
|
|
describe those commits only; rerun them after changing layout.
|
|
|
|
## 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.
|
|
- Test-only `Inset` and `Outset` can demonstrate their semantics after the
|
|
protocol is stable. `Pad` itself remains an outset.
|
|
- 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.
|