469 lines
21 KiB
Markdown
469 lines
21 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:
|
||
|
||
| 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:
|
||
|
||
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 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`.
|
||
|
||
**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. Coordinates start at a region
|
||
node (or the root) and every box under it is a region in that node's
|
||
coordinates; a widget's frame is a *length* of the node's box, per axis, and
|
||
is only what fractions resolve against:
|
||
|
||
```rust
|
||
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 node's box in pixels. The frame in pixels is `frame.to_px(node_px)`
|
||
/// and the box's is `extent.size().to_px(node_px)`.
|
||
pub(super) node_px: 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:
|
||
|
||
```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),
|
||
)))
|
||
};
|
||
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::resolve` becomes `region.within(&self.extent)`; the second
|
||
`within` through the frame goes.
|
||
- `in_parent` no longer maps a child's frame holds through the frame length
|
||
per level: every widget under one node holds for the node's pixel box, so
|
||
the holds `and` directly, and only a region node maps through its box.
|
||
- `Part::of` gains one `within_len` per `From` end. `Of` and `All` are as
|
||
they were.
|
||
- `DrawInfo::frame`/`frame_abs`, `ActiveData::frame`/`frame_abs`, `local`
|
||
in `Placing`, `recompose_subtree` and `asked_px` all change meaning:
|
||
`frame` is a `UiVec2` length, `frame_abs` goes, a node's move entry is the
|
||
node's frame length anchored at its box start (so `rel` inside means the
|
||
frame), and `asked_px` walks to the node rather than the root.
|
||
- `Stack` needs 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),
|
||
since `narrow` no 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 `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.
|
||
|
||
### 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 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
|
||
|
||
- 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.
|