Document tested frame and extent prototype and remaining costs

This commit is contained in:
iris-ai committed 2026-09-17 14:50:22 -04:00
1 parent 3084491b9b
commit 0080bcbcba
2 files changed
+86 -131

No files matched your search

+75 -131
View File
@@ -424,147 +424,91 @@ back.
- `Fixed::div` by zero answers `MIN`/`MAX` while `ratio` answers `ZERO`;
both are caller bugs under `debug_assert`, but the fallbacks differ.
### Frame and extent
### Frame and extent: retained prototype
Read on 2026-09-17, against Bryan's question of whether the approach is
fundamentally wrong or needs adjusting. The pieces the earlier review
approved are sound and the fuzzers agree: fixed point, the pixel chain
threaded down, `Holds::through` as the exact preimage, and the bottom-up
settle, with every residual they leave one or two steps in a position. One
thing is wrong, and it is the protocol rather than any widget: **the
placing ask replaces the box a widget's children resolve fractions against
with the widget's own answer.** Every "fraction twice" item is that defect,
and it has lasted because it was fixed per widget four times (`decided`,
`reports_of`, `box_of`, the stack branch's `resolve`, `Inset` against
`Pad`), each of which stops the re-resolution at one level while it happens
again inside the child's second draw, which no flag on the parent's ask
can reach.
The original proposal was implemented by Claude as `5fcace1` on
`wip/region-and-placement` in `/home/bob/repos/iris-pr18`. It kept the fraction
reference stable but failed five layout cases and three draw-count cases.
The correction is saved as `efb416b`, pushed to origin's
`wip/region-and-placement`, in the isolated checkout
`/home/bob/repos/iris-layout-experiment`; **it is not on PR #18**. The original
checkout is unchanged. Keep the experiment's build directory separate: Cargo
can accept a different worktree's artifacts as fresh when a target directory
is shared. The comparison checkout's source timestamps can precede its build.
The repository asserts both answers. `tests/cases/layout.rs` pins a nested
span's `rel(0.5)` child at a quarter of the row and calls it correct ("half
of that final box is what its own child takes"); the parked stack branch's
test pins the same shape as a half. Both are consequences of the one line
where a child's region composes within the widget's current box, which the
placing ask has made the answer:
**The distinction stays.** `Painter::region` is the frame, the parent's chosen
reference for fractions. `placement` is the extent in that frame. A sizing
report must not replace the reference against which that report was obtained.
`px_len` reads the extent's pixel length; `region_px_len` reads the frame's.
Text wraps at the available extent, not the whole fraction reference.
```rust
// painter.rs, widget_at
let within = match local == UiRegion::FULL {
true => self.region,
false => local.within(&self.region),
};
```
**The offer includes availability.** A span can keep the same reference frame
while offering a text less room after an earlier sibling. `known_len` therefore
accepts the placement as well as the frame. The first measurement's
`offer_placement` survives later placing evaluations, and local redraw asks
that original question before restoring the assigned slot. Frame-length
equality alone no longer identifies the measurement question.
The reuse path gives the same result, because `AxisRemap::Scale`
re-expresses the child as a fraction of the new box. `Len::within` itself
is correct geometry and was never the problem.
**An answer and a drawing each retain their dependencies.** `LayoutHolds`
keeps frame ranges, extent ranges, and an optional raw placement snapshot.
`px_len` narrows one extent axis without making position or the other axis a
dependency; `holds` widens that extent range without erasing a frame read.
`Holds::through` still inverts one fixed mapping exactly. A measured answer
and the final drawing can only substitute for one another when those
independent inputs satisfy the retained contract. The old fallback checked
only frame ranges, so text placed at a fixed pixel width answered a narrower
window's measurement with its previous line breaks.
**The protocol is one draw body evaluated twice, and that is right.** The
first evaluation measures and the second places, and the retained
machinery skips the second wherever it can. Only what the second is told
its box is has to change. The widget stays in its **frame**, the box it was
offered, and is handed its **extent** as a region in the frame's
coordinates:
**Geometry retains its reference.** `DrawRegion::{Frame, Extent}` records local
primitive and mask coordinates before composition. Ordinary `primitive()`
follows the extent; explicit `UiRegion` arguments remain frame-relative.
Text glyphs use extent-relative origins. `Painter::widget()` records a child
that inherits placement, so a pass-through wrapper and its valid descendants
can be repositioned without rerunning their draw bodies. `placement()` is
still a conservative raw read for arbitrary widget computations.
```rust
pub struct Painter<'a> {
/// This widget's frame: the box it was offered, in `move_idx`
/// coordinates. Every region this draw writes is in its coordinates
/// and every fraction in one is of it. It does not change between the
/// ask that measures and the ask that places.
pub(super) frame: UiRegion,
/// What of the frame this widget's answer took, in the frame's own
/// coordinates: `FULL` while the answer is not yet known, and the
/// placed answer once its parent has chosen where it sits.
extent: UiRegion,
/// The frame in pixels; the extent is one `Len` of it.
pub(super) px: PxVec2,
/// Whether this draw read its extent, which makes the drawing one that
/// holds only for that extent, the way `px_len` does for a length.
reads_extent: bool,
}
**No measurement is different from a measured zero.** The first wider scan
found seeds 560 (`shuffle-swap-for-three`) and 1690 (`shuffle-add-three`) at
depth 4. A previously undrawn pure share had only supplied a hint; its
placeholder zero answer became reusable once it was drawn during placement.
`ActiveData::answer` is now optional. Both seeds pass after that correction,
and `adding_text_to_a_reverse_row_keeps_its_shared_height` reproduces the
zero-height failure with a small tree.
impl Painter<'_> {
pub fn extent(&mut self) -> UiRegion {
self.reads_extent = true;
self.extent
}
}
```
Ordinary verification currently passes: formatting, workspace clippy with
`layout-diagnostics`, all workspace tests (96 suite tests), and the fast
generated cases. New regressions also inspect primitive coordinates for mixed
frame/extent drawings, including a region node, and a content change after
moving an inherited extent. A `tabs` resize from 1920x1200 to 900x1200 matches
a cold 900x1200 screenshot with zero differing pixels. The release oracle
passes 2000 seeds at depth 4 and 1000 seeds at depth 6 over all fifteen cases.
The shrinker passes 400 seeds at depth 5, and the debug oracle passes 120
seeds at depth 4 with assertions enabled.
`px_len` stays the frame's length, which is what a text wraps at and what
`Span`'s leftover decision reads. `widget_at` composes the child's region
within `self.frame` and resolves the child's fractions, declared or
reported, against the child's own offer, which `ActiveData::offer_len`
already records. `draw_inner` places the answer as a length of the offer
rather than of the region passed this time:
**Do not land this as a performance improvement.** The old stretching and
wrapper reuse tests pass, and one numeric-size test improves from three draws
to two. Cold work also improves against the broken `5fcace1` prototype: 604 to
535 widget draws and 15,296 to 10,384 primitive writes on seed 1, depth 8.
But against the last working `e44dea3`, the same rig does substantially more
nested layout work:
```rust
let lens = placed_lens(answer.0, declared, info.decided);
let extent = lens.within_len(info.offer_len);
let placed = placed_box(region, extent, align);
```
| phase | widget draws before | prototype | primitive writes before | prototype |
| --- | ---: | ---: | ---: | ---: |
| cold | 369 | 535 | 9635 | 10384 |
| repaint | 1 | 1 | 1 | 1 |
| many | 157 | 310 | 4721 | 6970 |
| size | 16 | 275 | 106 | 5980 |
| scroll | 2 | 2 | 0 | 0 |
| resize | 13 | 328.5 | 0 | 6721.5 |
Where the parent hands back exactly the answer the slack is zero and the
multiply exact, so `decided` stops being load-bearing. `try_reuse` compares
frames, not placed boxes: a nested span's frame is the row on both asks, so
nothing remaps and its rect stays at half the row. Remapping is for a frame
that moved, a translation in the common case and a scale on a resize, the
two cases `AxisRemap` has.
**For a widget author the rule is one sentence: regions are written in the
frame, and `painter.extent()` is "my box".**
```rust
// span.rs: children sit across the row inside the span's extent.
let across = painter.extent().axis(!axis);
let region = UiRegion::from_axis(axis, span, across);
// pad.rs: the inner sits in the pad's extent less the padding.
painter.widget_within(&self.inner, self.padding.region_of(painter.extent()))
// stack.rs: every child gets the stack's extent; `box_of` is deleted.
let region = painter.extent();
```
`Span`'s cursor arithmetic does not change: it is already a sum of the
children's answers, which are fractions of the row.
Checked by hand against the failing shapes:
- Nested span: the inner's frame is the row on both asks; its rect is 200
px. `a_span_reads_a_child_report_as_a_fraction_of_the_row` flips to
assert 200..400 for the inner.
- Stack sized by a `rel(0.5)` child: the child's extent and the stack's are
the same half of the frame, zero slack, exact. Seed 1091's three steps
were `placed_box` scaling a nonzero slack per level.
- Pad and Inset: the inner's fraction is of its offer and its extent sits in
`extent().inset(padding)`. The 47.5 px was the offer changing between
asks.
- Pad around a 40 px rect, bottom aligned: on the second ask the pad's
extent is 60 px, the inner region 40 px, slack zero, rect at 20..60. This
is the case that needs the second evaluation at all, and why a frame
alone is not enough.
- A rule: a stack declared `width(rel(0.5))` holding a `rel(0.5)` rect gives
a quarter of the row, correctly. A rule sets the frame; a report does not.
Reading the extent narrows reuse the way reading a pixel length does. A
widget that never calls `extent()` has a first drawing that holds for any
extent and keeps it: `Stack` without a sizing child, `Scroll`, every leaf,
`Span` along its axis. One that reads it is redrawn on the second ask only
where the extent differs from the frame, and its children reuse through
their own `Holds` since their frames did not move. Suppressing primitives
on the first evaluation would be an optimization over this, not a
requirement. The one place it costs more than today is a stack whose
sizing child reports less than the frame: `box_of` narrowed the other
children's first draw so they landed right at once, and with `extent()`
they draw at `FULL` and again at the extent. They are usually a background
rect; let the rig's counters say whether it matters.
Fixed point, the pixel chain and `Holds::through` are untouched. The chain
already threads `offer_len` beside `given_len` and `offered_px` beside
`px`; the change is that the offer chain becomes the coordinate base and
the placed chain is derived from it, rather than the other way round.
These are instrumented work counts, not elapsed-time speedups (50 frames per
phase except cold, which is one; 578 generated widgets, 191 finally active).
Raw placement reads in containers and repeated offer/placement evaluations
remain conservative. Retaining local coordinates also costs memory per
primitive. The experiment demonstrates selective reuse with one `Widget::draw`
body, not a finished performance replacement. Optimize the container protocol
under the same correctness tests before adopting it. The parked `Inset`/`Outset`
API changes are not included in this experiment.
## How layout is decided