Plan the transparent-frames layout protocol

Decided with Bryan on 2026-09-18: a child's frame is forwarded through
spans, stacks and scrolls and narrowed only by what is decided from above,
so rel is never a fraction of a self-sized box; placements are parts of
the parent's extent in frame units from its start, so a moved extent
re-places its children exactly; the placement pin becomes a symbolic
length; and a local redraw asks at the offer placement without deferring.
Lists the steps in order with the check for each and the failures that
mean stop rather than patch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Fable 5.1 committed 2026-09-17 22:18:49 -04:00
1 parent cb4557f1bc
commit 2bee0b4b53
1 file changed
+316 -8
+316 -8
View File
@@ -27,6 +27,317 @@ traced on 2026-09-17 and is not what the earlier sections say; read **Where
the `many` gap actually comes from** before anything else in this document
about performance, and take the earlier sections' explanations as history.
**The current work is the plan in the next section**, decided with Bryan on
2026-09-18. It is what the next agent does, on top of `34cafb6`, and it
supersedes the "Frame and extent" sections' description of `Span`, `Pad`,
`Stack`, the placement pin and `measure_len` wherever they differ.
## Plan: transparent frames (2026-09-18)
Decided with Bryan on 2026-09-18 after the trace below. The mechanism was
checked against the code of `34cafb6` and against the two counterexamples
on `wip/local-reask`; the pieces that were in doubt are called out. Do the
steps in order and run each step's check before the next. **If a check fails
and the fix that suggests itself is a tolerance, a pin, a deferral, a second
layout method, or a special case in `Span`, stop and report instead**: those
are exactly the patches that have been made around this design before, and
each one made the next problem harder to see.
### Decided by Bryan
- **Containers are transparent.** The frame a widget's fractions are of is
forwarded from its parent through a span, a stack and a scroll unchanged,
and through an inset narrowed by its margins. Any number of nested spans
lay out against one frame. The reason: `px` already passes through, `rel`
should behave the same way, and `leftover` is already the way to say
"fill the containing widget", so `rel(1.0)` meaning that too would be two
ways to say one thing.
- A frame is narrowed only by what is decided from above: a declared length
on the widget (`.width(rel(0.5))` on a span narrows its children's frame
too), an inset's margins, the root. A box that is an *answer* (a row's
height, a stack sized by a child) is never anything's frame.
- `rel` overflows on purpose when it sums past one or has pixels beside it.
- `Inset` and `Outset` take `px`, `rel` and `leftover` margins. Outset adds
its margins to the child's report and moves the child in; Inset draws the
child in a frame with the margins subtracted and reports the child's size
plus what it subtracted. An inset resolves what it subtracts, so `px` and
`rel` margins narrow the frame symbolically and a `leftover` margin is
resolved in pixels from the extent after the child answers, like a span's
shares. A general `Pad` would outset pixels and inset `rel` and
`leftover`. **Do not build these yet**; make them a few lines each to
write.
- Along its own axis a span places a child whose length is known in pixels
at its final slot while measuring, so it is drawn once, and moves a child
that is in the wrong place, `px` or `rel`, by translation rather than
drawing it again. Across itself it may still re-place by the answer.
- Layers may later be tied to another widget (a popup near an anchor). A
position is never defined by two widgets; positions compose up the tree.
### The protocol
Two boxes per widget, both in the *parent's frame coordinates*:
- **frame** -- what a declared or reported fraction is a fraction of.
`UiRegion::FULL` for a transparent parent; narrowed by `ask_box` for a
declared length and by an inset. Its *length* is the same on every ask of
the widget, which is the property the whole retained model rests on.
- **extent** -- where the drawing goes. Given by the parent as a `Place`
per axis, **relative to the parent's extent start, in frame units**:
```rust
/// Where a child goes along one axis, as a part of this widget's extent.
/// Spans are frame lengths from the extent's start, so a span's slot is
/// `from..start` and a moved extent re-places every child by re-adding
/// its start, exactly. `None` is the whole extent.
pub enum Place {
/// The child's answer, aligned inside the part by the child's alignment.
Within(Option<UiSpan>),
/// Exactly the part; the answer is not placed inside it again.
Fill(Option<UiSpan>),
}
```
The widget's `draw` sees only its extent: `px_len`/`px_size` are the
extent's pixels and narrow the extent range, `holds` widens it. Primitives
and masks are written in extent coordinates (`FULL` is the extent); there is
no `DrawRegion` and no frame-coordinate primitive. A container reads
`extent_len() -> UiVec2`, the extent's *symbolic* length in frame units,
which pins its drawing to that length and to nothing about the start. A
span that divides room also reads `frame_px_len(axis)` (today's
`region_px_len`) and widens through `frame_holds` (today's `region_holds`),
which narrow the frame range. `placement()`, `region()`, `box_of`,
`measure_len`, `widget_within`, `DrawRegion` and `ExtentPlacement` go.
The parent side is one call, with a shorthand:
```rust
pub fn widget_at<'s, W: ?Sized>(
&'s mut self,
id: &'s StrongWidget<W>,
frame: UiRegion, // in this widget's frame coordinates; FULL forwards it
place: [Place; 2], // relative to this widget's extent start, in frame units
) -> DrawResult<'s, 'a, W>;
/// The transparent default: the frame as given, the answer aligned in the extent.
pub fn widget<'s, W: ?Sized>(&'s mut self, id: &'s StrongWidget<W>) -> DrawResult<'s, 'a, W> {
self.widget_at(id, UiRegion::FULL, [Place::Within(None); 2])
}
```
`Span`, measuring and placing, with the row being its own extent:
```rust
fn draw(&mut self, painter: &mut Painter) -> Size {
let axis = self.dir.axis;
let far = painter.extent_len().axis(axis); // symbolic; pins the length
let along = |from: Len, to: Len| match self.dir.sign {
Sign::Pos => UiSpan::new(from, to),
Sign::Neg => UiSpan::new(far - to, far - from),
};
let across = Place::Within(None);
// Measure. A child whose length is already known -- a rule, a hint --
// with no share before it is placed at its slot here and never again.
let mut cursor = Len::ZERO;
let mut shares_before = false;
let mut lens = Vec::with_capacity(self.children.len());
for child in &self.children {
let known = painter.size_hint(child, axis).filter(|_| !shares_before);
let place = match known {
Some(len) if len.leftover == Weight::ZERO => {
Place::Fill(Some(along(cursor, cursor + Len::from_parts(len.rel, len.px))))
}
_ => Place::Within(Some(along(cursor, far))),
};
let len = painter.widget_at(child, UiRegion::FULL, axis.pair(place, across)).len(axis);
shares_before |= len.leftover != Weight::ZERO;
cursor += Len::from_parts(len.rel, len.px + self.gap);
lens.push(len);
}
// total, room = far - fixed, the shares decision through frame_px_len and
// frame_holds(through(room)): unchanged from today.
// Place. A child already at its slot is an exact reuse; one whose slot
// moved is recomposed, since its length did not change.
for (child, len) in self.children.iter().zip(&lens) {
// undraw a share with nothing to share, accumulate fixed/taken, then:
painter.widget_at(child, UiRegion::FULL, axis.pair(Place::Fill(Some(along(from, start))), across));
}
Size::from_axis(axis, total, ortho)
}
```
Across itself a span reports `leftover` if any child does; otherwise the
longest child in *frame pixels* among `px` and `rel` children, reporting
that child's own `Len`. Comparing in pixels is safe here because the frame
is decided from above and nothing feeds back; the read is a `frame_px_len`
and narrows the frame range, and at the crossover both candidates are the
same number of pixels, so the drawing is the same on either side of it.
The shapes the rest of the containers take (write them, they are short):
```rust
// Stack: the sizing child takes the whole extent, the rest are aligned in it.
painter.widget_at(child, UiRegion::FULL, [Place::Fill(None); 2]).size() // sizing
painter.widget_at(child, UiRegion::FULL, [Place::Within(None); 2]); // others
// Scroll: viewport is the extent; content is a pixel box offset by the scroll.
painter.widget_at(&self.inner, UiRegion::FULL,
self.axis.pair(Place::Fill(Some(UiSpan::px(anchor - amt, anchor - amt + content_len))), Place::Fill(None)));
// Inset with px/rel margins m: a narrowed frame and a narrowed extent.
let frame = m.narrow(UiRegion::FULL); // Len subtraction, exact
let len = painter.extent_len();
painter.widget_at(child, frame, [Place::Within(Some(UiSpan::new(m.lead.x, len.x - m.trail.x))), /* y */]).size()
+ m.total() // report child plus margins
// Outset: the same placement with the frame forwarded (UiRegion::FULL).
// Leftover margins: measure the child with Within(None), divide the room
// left in the extent by weight as a span does, then place with Fill.
```
A report is still `LayoutLen { px, rel, leftover }` per axis. A fraction in
it is of the reporting widget's frame, so through a transparent parent it
passes unchanged and through a narrowing parent (declared, inset) it is
composed by `within_len(frame.len())` as `in_parent_frame` does today. A
stack sized by a child that reports `rel(0.5)` therefore reports `rel(0.5)`,
is placed at half the frame, and hands its child the whole extent while the
child's frame is still the full one: the fraction is applied once.
### Retained state and reuse
Per widget (`ActiveData`), replacing `region`/`placement`/`given_region`/
`offer_len`/`offer_placement`:
- `frame: UiRegion` in the parent's frame coordinates, and `place: [Place; 2]`
as last given; `offer_place: [Place; 2]` from the first ask of the
parent's draw at the offer. There is no `offer_frame`: the frame's length
is the same on every ask, and `redraw` asserts it in debug.
- `frame_abs`, `extent_abs`: the two composed into `parent_move`
coordinates, for writing primitives and for `window_region`; rewritten by
recomposition.
- `answer: Option<(Size, LayoutHolds)>` from the offer ask; `holds:
LayoutHolds` for the drawing, where
```rust
pub struct LayoutHolds {
pub frame: [Holds; 2], // frame pixel lengths
pub extent: [Holds; 2], // extent pixel lengths
pub extent_len: [Option<Len>; 2], // symbolic extent length, where read
}
```
The `placement: Option<UiRegion>` pin is gone. Nothing may depend on where
an extent starts.
- primitives and the mask retained in extent-local coordinates, as now.
**Reuse** of a drawing at an ask: same layer, parent move and region-node
choice; frame pixels inside `frame`; the extent resolved from `place`
(`Fill` is the part; `Within` is the answer aligned in the part, or the part
where the answer fills) has pixels inside `extent` and, where pinned, the
same symbolic length. A frame that moved recomposes the subtree from
retained local coordinates (today's `recompose_subtree`). An extent that
moved **re-places every child** through its retained `place`
(`extent_abs.start + place`, then the child's own reuse test), stopping at
region nodes; this is today's `reposition` over `extent_children`,
generalised to all children because every child is now placed relative to
the extent. A child whose reuse fails there is drawn again at its retained
place. Along a span this is what moves `px` and `rel` children whose slots
shifted: the span's redraw re-issues `Fill(from..start)` with the same
lengths and different starts, and the child recomposes.
**A `Within` ask does not place the answer immediately.** The child draws
in the whole part, and the aligned answer box is applied by the next ask of
that child in the same parent draw, or, for a child not asked again, by a
pass at the end of the parent's draw over `children`. That is today's
`measure_len` rule made the rule for every open axis; it is what keeps a
span child at one draw plus one recomposition rather than two
recompositions.
**Dependencies composed into the parent** (today's `in_parent`): a child's
`frame` range goes through the child's frame length into the parent's frame
range. A child's `extent` range goes into the parent's *frame* range through
the part's length where the part is a span (a frame length), and into the
parent's *extent* range where the part is `None` (the whole extent). Answer
dependencies come only from children whose answer was read, drawing
dependencies from every child drawn, as today. Pins do not compose: a child
pinned on its symbolic length is checked when it is re-placed.
**Local redraw** (`redraw`): no deferral on lengths. Draw at `offer_place`
with the drawing left there where the given place differs (measuring),
compare answer and holds with what was retained, keep a still-covering old
guarantee as today, mark the parent only on a change, then place at the
given `place` (a reuse where the holds admit it). Deferral remains only for
a changed declared length or alignment and for an undrawn widget. The
`given_px != offered_px` branch and `offer_len` are deleted, not disabled.
### Steps, each with its check
Work on a branch from `34cafb6` in `/home/bob/repos/iris-layout-experiment`
(its `target` is its own; the baseline's is `target-own`). The checks are
`cargo test --workspace` in debug, `cargo test --release --test generated`
(fast oracle), and after steps 4, 6 and 7 the long ones:
```sh
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
```
1. **`Place` and `widget_at(child, frame, [Place; 2])`**, with `widget` as
the shorthand, replacing `widget_at`/`widget_within`/`widget`/
`measure_len`. Internally keep `draw_inner` but make `DrawInfo` carry
`frame` and `place`; delete `DrawRegion`, `ExtentPlacement`,
`reads_placement`, `region()`, `placement()`, `box_of`. Add
`extent_len()`, rename `region_px_len`/`region_holds` to `frame_*`.
Check: it compiles with the call sites moved in step 2; no test yet.
2. **Call sites**: `Span` as above (without the known-length shortcut yet),
`Stack`, `Scroll`, `Pad` (as an inset by pixels, unchanged behaviour),
`Masked`, `Branch` in `random.rs`, `Text` (`glyphs` in extent
coordinates), the examples. Check: suite and fast oracle. Expected to
pass with `Span` reading `extent_len` on both axes and pinning it.
3. **Re-place every child on an extent move**, generalising `reposition`;
delete `extent_children`. Check: suite, fast oracle,
`retained::a_span_ruled_across_itself_moves_its_child_without_redrawing_it`
and a new test: a row whose first child grows by a pixel rule moves the
two after it without drawing them (count draws with the `Counted` widget
in `tests/cases/retained.rs`), once with a `px` second child and once
with a `rel` one.
4. **The symbolic-length pin replaces the placement pin**; `LayoutHolds`
as above. Check: suite, fast oracle, shrinker at 400/5, and the rig's
`many` at seed 13, depth 8 (`IRIS_SEED=13 IRIS_DEPTH=8 IRIS_PHASE=many`):
"reuse outside: the placement it was pinned to" is gone as a counter and
distinct widgets a frame should already be well under `34cafb6`'s 508.
5. **Lazy `Within`** with the end-of-draw pass. Check: suite;
`retained::a_span_does_not_place_its_measurement_before_assigning_the_childs_slot`
still counts three draws.
6. **`redraw` without the deferral**, as above; delete `offer_len` and the
`given_px != offered_px` branch. Check: suite, fast oracle, shrinker,
oracle at 1000/6 -- this is the step `wip/local-reask` failed at seeds
532 and 398, and both must pass now because the frame no longer changes
under the widget. If either still fails, shrink it and report; do not
restore the deferral. Then the rig: `many` at seeds 1 and 13, depth 8,
against `e44dea3` in `/home/bob/repos/iris-layout-baseline`
(`CARGO_TARGET_DIR=$PWD/target-own`). Expect distinct widgets near
`e44dea3`'s 95 and 159 and no deferral at all.
7. **Span's known-length shortcut and the cross-axis report** as written.
New tests: a `px` child after a `px` child is drawn once on a cold
layout and never on repaint; nested rows two deep with `rel(0.5)` give
half the *root* (or half a declared `.width(rel(0.5))` ancestor) wherever
the child sits; a row with a `rel(0.5)`-tall child reports `rel(0.5)`
across. Check: everything, including the long runs and the five
reference renders (`view`, `minimal`, `random`, `tabs`, `text`) against
`34cafb6` -- expect `random` and `tabs` to move where nested spans or
span heights change meaning, and look at them rather than diffing.
8. **Prove `Inset`/`Outset` are easy**: write them as test widgets in
`tests/cases/layout.rs` with `px`, `rel` and `leftover` margins, a dozen
lines each, and assert the child's box; do not add them to the crate.
9. Measure all six phases against `e44dea3` with the uninstrumented rig,
record the table here, and update the "Retained-layout invariants" list:
delete the bullets about `reports_of`, `decided`, `dirty_size_under`,
the placement pin and the offer-length deferral, and add the frame rule.
Expected against `e44dea3` at seed 1 and 13, depth 8: `size` and `scroll`
keep their wins; `many` within a few tens of percent, from the container
body running at the offer placement and at the placed one; `resize` at or
below its 13 draws. A `many` result above 2x is a sign something above was
not done as written, not a reason for a new mechanism.
**Widen one fuzzer axis at a time, and record which.** Seeds **1121** and
**1839** at **depth 4** failed on `ea6dbae` and on every commit before it,
and nothing in the routine verification reached them: the fast oracle takes
@@ -1229,14 +1540,11 @@ In order, from the review above and Bryan's steer (2026-09-17):
`a0693ac` and `e44dea3`; see the two sections above. The re-parenting
half turned out to be two defects rather than the predicted one, and
neither was the `depth()` assertion.
2. **Frame and extent**, as written above. This is the fundamental change
and comes before anything built on the placing ask. It lands the two
parked branches' tests (the stack test unchanged, `Inset`/`Outset` with
the rename and the `.pad()` audit), flips
`a_span_reads_a_child_report_as_a_fraction_of_the_row`, and deletes
`box_of`, `reports_of`'s composition in `in_parent_frame`, and the
`through(lens)` translation in `draw_inner`. Run the long fuzzers and
the render set once for it.
2. **Transparent frames**, the plan near the top of this document. This is
the fundamental change and comes before anything built on the placing
ask; it subsumes the parked `Inset`/`Outset` and stack-fraction branches
(their tests land with step 7 and 8) and the `wip/local-reask` branch,
which is superseded and should be deleted once step 6 passes.
3. Write `ActiveData::answer` in one place.
4. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the
reconstruction in `redraw`.