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:
1 parent
cb4557f1bc
commit
2bee0b4b53
1 file changed
+316
-8
+316
-8
@@ -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`.
|
||||
|
||||
Reference in new issue
Block a user