394 lines
18 KiB
Markdown
394 lines
18 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 **`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 that has
|
|
finished:
|
|
|
|
| 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 | agreed at `3091fb8` (269 s); rerun at `0ef87eb` was running when this was written |
|
|
| 1000 seeds at depth 6 | found seed 946 at `3091fb8`, fixed and pinned in `0ef87eb`; rerun was running when this was written |
|
|
|
|
Read `/tmp/oracle-1000-6b.log` and `/tmp/shrink-2000-4b.log` for the reruns
|
|
if they still exist; otherwise run them (step 1).
|
|
|
|
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:
|
|
|
|
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 `3091fb8`:
|
|
|
|
```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, 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:
|
|
|
|
```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 parent's frame, never a region, and
|
|
is put back into the part 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 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_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 (new, planner's choice, not
|
|
yet Bryan's) the box a stack's sizing child decided 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.
|
|
|
|
### `Pad` remains an outset -- and is not yet what was decided
|
|
|
|
Bryan: padding goes outside what it pads; the pad forwards its frame less the
|
|
padding and draws the child inside that area, so `rel(1.0)` inside `.pad(16)`
|
|
inside a 450 px share is 418 px. **The experiment does not implement this.**
|
|
`Pad` still forwards the frame whole and insets only the box:
|
|
|
|
```rust
|
|
let inset = |lead: Px, trail: Px| {
|
|
Place::Within(Part::Of(UiSpan::new(
|
|
Len::from_parts(Rel::ZERO, lead),
|
|
Len::from_parts(Rel::ONE, -trail),
|
|
)))
|
|
};
|
|
```
|
|
|
|
so `rel(1.0)` there is 450 and overflows by the padding -- the clipped
|
|
`text.rs` render. The decided rule cannot be written in the current
|
|
representation without a cost Bryan has not seen:
|
|
|
|
- Narrowing the child's frame by the padding (`narrow = FULL - 32px`) makes
|
|
that length the child's *box* too, since a narrowed frame is its own box.
|
|
In a share or a declared box, frame and box coincide and the rule holds.
|
|
As a fixed child of a span measured from its cursor, the padded child is
|
|
then asked in `row - 32` rather than `room - 32`: a padded wrapped text
|
|
after a 24 px icon wraps as if the icon were not there and overflows the
|
|
row by 24 px. That row is the commonest thing in the app.
|
|
- Keeping `Part::Of` (status quo) keeps the text wrapping in the room and
|
|
makes `rel(1.0)` under a pad mean the whole frame.
|
|
- Having both -- `rel` of the frame less padding *and* a box that is the
|
|
room less padding -- needs the frame to be a length and the box a region in
|
|
the parent's coordinates, with `Part::From` scaling its span by the frame
|
|
length. That is a coordinate rewrite of `painter.rs`/`render_state.rs`, not
|
|
a widget change.
|
|
|
|
Ask Bryan which. Do not decide it in a worker session.
|
|
|
|
### 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. Read the long fuzzer results
|
|
|
|
`docs/LAYOUT_LOG.md` records the 1000/6 oracle and the 2000/4 scan at
|
|
`3091fb8` if they finished. If either found a seed, shrink it first
|
|
(`SHRINK_SEED=<seed> SHRINK_DEPTH=<depth> SHRINK_CASE=<case>`), pin it as a
|
|
named test in `tests/cases/unsettled.rs`, then fix it under the rule above.
|
|
Do not add a second draw back.
|
|
|
|
### 2. 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.
|
|
|
|
### 3. 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.
|
|
|
|
### 4. 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 `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.
|
|
|
|
Report every phase at both seeds, work counters first, medians only when the
|
|
work agrees.
|
|
|
|
### 5. 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 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:
|
|
|
|
```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
|
|
|
|
- The `Pad` decision above, and the coordinate rewrite if Bryan wants both
|
|
halves of the rule.
|
|
- 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.
|