Keep the transparent-frames plan as written beside what it landed as

This commit is contained in:
iris-ai committed 2026-09-18 12:32:57 -04:00
1 parent 1777a92205
commit 152bed7ec4
1 file changed
+387 -8
+387 -8
View File
@@ -136,14 +136,46 @@ pub fn widget<'s, W: ?Sized>(&'s mut self, id: &'s StrongWidget<W>) -> DrawResul
}
```
`Span` is the plan's, without the known-length shortcut (step 7, not done):
it reads `far = painter.extent_len(axis)`, measures each child at
`Place::Within(Part::From(along(cursor, far)))`, and places it at
`Place::Fill(Part::From(along(from, start)))`, with `Place::Within(Part::All)`
across itself. `Stack` gives its sizing child `Fill(All)` and the rest
`Within(All)`; `Scroll` measures at `Fill(All)` and places at
`Fill(From(px content box))`; `Pad` is transparent and insets by
`Within(Of(..))`; `Masked` sets its mask over its own box.
`Span` is the plan's, without the known-length shortcut (step 7, not done),
and reads nothing about where it sits:
```rust
let far = painter.extent_len(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(Part::All);
for child in &self.children { // measure
let room = Place::Within(Part::From(along(cursor, far)));
let len = painter
.widget_at(child, UiRegion::FULL, axis.pair(room, across))
.len(axis);
cursor.px += len.px + self.gap;
cursor.rel += len.rel;
lens.push(len);
}
for (child, len) in self.children.iter().zip(&lens) { // place
let slot = Place::Fill(Part::From(along(from, start)));
let placed = painter.widget_at(child, UiRegion::FULL, axis.pair(slot, across));
}
```
`Pad` is transparent and says its inset in its own box's lengths, which is
what keeps it from reading how long that box is:
```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),
)))
};
```
`Stack` gives its sizing child `Fill(All)` and the rest `Within(All)`;
`Scroll` measures at `Fill(All)` and places at `Fill(From(px content box))`;
`Masked` sets its mask over its own box.
A report is still `LayoutLen { px, rel, leftover }` per axis, a fraction of
the reporting widget's frame, so it passes a transparent parent unchanged and
@@ -305,6 +337,353 @@ box goes with it (what the code does, so an inset inside a row draws its
child across the whole row), or the box is a part and the frame does not
narrow (`Part::Of`, what `Pad` does), or a third thing Bryan decides.
**The two decisions, in the form they need answering.**
1. *What is a widget's answer the answer to?* Options as they stand: keep
the deferral, so a mismatched box is always the parent's question (what
the code does, and what #18 does); or give a container one drawing rather
than two, so there is no second geometry to disagree about, which means
deciding a child's box before drawing it; or name the measuring geometry
explicitly in the ask rather than deriving it, which is the retained
`offer_part` taken further.
2. *What is `Pad`?* An outset (what the code does), an inset that takes the
child's box with it (so an inset in a row draws its child across the
row), or "outset pixels, inset `rel` and `leftover`", which needs a way
to say a box in a narrowed frame that the grid cannot express today.
### The plan as written, for reference
What follows is the plan as it was handed over on 2026-09-18, kept verbatim
so that what was asked for can be read against what landed. Where the two
differ, the sections above are the code: `Part::Of` is not in it, lazy
`Within` placement is in it and was removed, `extent_len` takes an axis, and
steps 6 to 9 are not done.
Its own preamble:
> 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.
#### 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 the longest child in *frame pixels* among the
`px` and `rel` parts of its children's reports, as 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. A child's `leftover`
across a span contributes nothing to that: across, children overlap rather
than divide anything, so a share can only mean "as tall as the span", and
`Place::Within(None)` already fills the extent for a leftover answer. Only
when no child has a `px` or `rel` part does the span report `leftover`
itself and take its parent's room (Bryan, 2026-09-18: "returning rest if
any have it is probably fine", refined to this because it costs nothing).
So `row![text, rect]` is as tall as the text with the rect filling it, and
`row![rect, rect]` fills its column's share. **Deliberately not done**: a
fully transparent `leftover` across a span, where the span's height would
be the larger of its tallest fixed child and the share its parent hands
back. That needs the parent's division to come back down after the report,
a second resolution pass; Bryan deferred it until the core is settled. Do
not attempt it as part of this plan.
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
ten seeds, the shrinker 400 at depth 5 and the long oracle 1000 at depth 6,
so a defect past seed 400 at depth 4 had nowhere to show. Both are fixed by
`4bd8607`. The scan that found them, worth running again after any change
to layout:
```rust
// tests/scan.rs, deleted once it had done its job
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());
}
}
});
```
261 s for 2000 seeds at depth 4 over all fifteen cases, and clean at
`4bd8607`. `Rng::new` is `seed | 1`, so an even seed and the odd one above
it are one tree: 1120 and 1121 are the same counterexample, as are 1838 and
1839.
**Two ideas outrank everything else in this document** (Bryan, 2026-09-17).
First, a changed tree lays out exactly as if it had been drawn that way from
the start; that is what the retained machinery is for and what the oracle
and shrinker check. Second, widgets are predictable: `px` is that many
pixels, `rel(0.5)` is half of the containing widget's area however many
siblings there are and wherever it sits among them, and `leftover` is a
share of the room left once every sibling's `px` and `rel` are resolved. The
invariants listed further down were accumulated by agents chasing single
failures; any of them may be simplified or deleted if those two ideas still
hold.
## Review of 2026-09-17
A fresh read of `core/src/fixed.rs`, `orientation/`, `ui/holds.rs`,