1509 lines
79 KiB
Markdown
1509 lines
79 KiB
Markdown
# Handoff
|
|
|
|
Where the work in flight stands for a session picking it up cold. Keep current
|
|
invariants, measurements, and failed hypotheses here; this is not a decisions
|
|
log. Pruned on 2026-09-18: the frame/extent prototype's chronicle went, its
|
|
four surviving findings and two failed hypotheses stayed, and the
|
|
transparent-frames plan became a record of what it landed as.
|
|
|
|
## Where things stand
|
|
|
|
Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18
|
|
`split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`, head
|
|
**`e44dea3`**, pushed. It holds LAYOUT.md §2's position chain, `leftover`,
|
|
the `Holds` retained-layout contract, region nodes, built-in alignment and
|
|
size rules, fixed-point layout, a box in pixels threaded down the draw, and
|
|
a report read as a fraction of the containing widget. No PR review was
|
|
present when checked on 2026-09-15.
|
|
|
|
The separate frame/extent experiment lives in
|
|
`/home/bob/repos/iris-layout-experiment`. **The transparent-frames protocol
|
|
is implemented** on `wip/transparent-frames`, head **`49cec82`**, three
|
|
commits over `34cafb6` (`wip/region-and-placement`, the experiment as it
|
|
was). The app's framework pin is unchanged, and **none of this is ready to
|
|
replace #18**: two fuzzer seeds still settle differently warm than cold, and
|
|
`many` and `resize` cost more than #18 on a deep tree. What it does, what it
|
|
cost and what it found are in **Transparent frames: what landed** below;
|
|
read that before anything else here about `Span`, `Pad`, `Stack`, the
|
|
placement pin or `measure_len`, all of which it supersedes.
|
|
|
|
## Transparent frames: what landed (2026-09-18)
|
|
|
|
Decided with Bryan on 2026-09-18 and implemented the same day. His rules are
|
|
kept below because they are the design; the protocol section says what the
|
|
code now does, and **What it cost** and **What is not done, and why** say
|
|
where it stands. The step list the plan carried is gone: steps 1 to 5 are in
|
|
`1956be3`, the measurements that follow are in `0954770`, the review pass is
|
|
`49cec82`, and steps 6 to 9 are the open questions at the end.
|
|
|
|
### 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 a declared length,
|
|
and that is the only narrowing in the code today. 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, as a part of the parent's own box.
|
|
|
|
```rust
|
|
/// What of a widget's own box a child is given, along one axis.
|
|
pub enum Part {
|
|
/// The whole of it.
|
|
All,
|
|
/// Frame lengths from where the box starts, which is what a container
|
|
/// dividing room among its children speaks: a child's report is a length
|
|
/// of the frame, so the cursor that sums those reports is one too. A
|
|
/// moved box re-places every child by re-adding its start, exactly.
|
|
From(UiSpan),
|
|
/// A part of the box in its own coordinates, which is what a container
|
|
/// that insets one speaks: taking eleven pixels off the end needs no
|
|
/// length, where saying the same thing in frame lengths would make the
|
|
/// container read its own box -- and a box chosen from its own answer
|
|
/// then feeds back into the answer.
|
|
Of(UiSpan),
|
|
}
|
|
|
|
/// Where a child goes along one axis, as a part of this widget's box.
|
|
pub enum Place {
|
|
/// The child's answer, aligned inside the part by the child's alignment.
|
|
Within(Part),
|
|
/// Exactly the part; the answer is not placed inside it again.
|
|
Fill(Part),
|
|
}
|
|
```
|
|
|
|
`Part::Of` is not in the plan; **it is what the measurements asked for**, and
|
|
the reasoning is in its doc comment above. `Part::From` is the plan's
|
|
extent-relative span.
|
|
|
|
The widget's `draw` sees only its own box: `px_len`/`px_size` are that box's
|
|
pixels and narrow the extent range, `holds` widens it. Primitives and masks
|
|
are written in its coordinates; there is no `DrawRegion` and no
|
|
frame-coordinate primitive. A container reads `extent_len(axis)`, the box's
|
|
*symbolic* length along one axis in frame units, which pins its drawing to
|
|
that length and to nothing about the start -- one axis at a time, because a
|
|
span dividing one of them holds for any length of the other. A span that
|
|
divides room also reads `frame_px_len(axis)` (was `region_px_len`) and widens
|
|
through `frame_holds` (was `region_holds`). `placement()`, `region()`,
|
|
`box_of`, `measure_len`, `widget_within`, `DrawRegion` and `ExtentPlacement`
|
|
are gone.
|
|
|
|
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],
|
|
) -> DrawResult<'s, 'a, W>;
|
|
|
|
/// The transparent default: the frame as given, the answer aligned in the box.
|
|
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(Part::All); 2])
|
|
}
|
|
```
|
|
|
|
`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
|
|
is composed by `within_len(frame.len())` through a narrowing one. **A stack
|
|
sized by a child that reports `rel(0.5)` therefore reports `rel(0.5)` and the
|
|
fraction is applied once** -- the parked `wip/stack-fraction-twice` defect is
|
|
closed by the protocol rather than by a guard, and `placed_extent` is where:
|
|
it takes the answer's length *from* the part rather than composing it into
|
|
the part.
|
|
|
|
### 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 `frame_abs` in
|
|
`parent_move`'s; `extent: UiRegion` in the frame's own coordinates, which
|
|
`window_region` and recomposition compose as `extent.within(&frame_abs)`.
|
|
There is no `offer_frame`: the frame's length is the same on every ask.
|
|
- `place: [Place; 2]` as last given, `offer_place` from the ask its answer
|
|
came from, and `offer_part: UiRegion` -- the box that ask gave it. The box
|
|
is kept and not worked out again from where the parent's own box is now: a
|
|
parent drawn again in the box its answer chose gives its children boxes it
|
|
never measured anything in.
|
|
- `answer: Option<(Size, LayoutHolds)>` from that ask; `holds: LayoutHolds`
|
|
for the drawing, where
|
|
|
|
```rust
|
|
pub struct LayoutHolds {
|
|
pub frame: [Holds; 2], // frame pixel lengths
|
|
pub extent: [Holds; 2], // pixel lengths of the widget's own box
|
|
pub extent_len: [Option<Len>; 2], // its symbolic length, where read
|
|
}
|
|
```
|
|
|
|
The `placement: Option<UiRegion>` pin is gone. Nothing may depend on where
|
|
a box starts.
|
|
|
|
- primitives and the mask retained in the widget's own box's coordinates.
|
|
|
|
**Reuse** of a drawing at an ask: same layer, parent move and region-node
|
|
choice; frame pixels inside `frame`; the box 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 (`recompose_subtree`). A box that moved **re-places every
|
|
child** through its retained `place` (`reposition`, generalised from the old
|
|
`extent_children` to all children because every child is now a part of it).
|
|
|
|
**Dependencies composed into the parent** (`in_parent`): a child's `frame`
|
|
range goes through the child's frame length into the parent's frame range. A
|
|
child's own-box range goes into the parent's *frame* range through the
|
|
part's length where the part is `From` (a frame length), into the parent's
|
|
*own-box* range through the part where it is `Of`, and straight into it
|
|
where the part is `All`. A pin composes only for `All`. Answer dependencies
|
|
come from children whose answer was read, drawing dependencies from every
|
|
child drawn.
|
|
|
|
**Local redraw** (`redraw`) still defers where the box it is given is not as
|
|
long as the box it was measured in -- see **What is not done, and why**.
|
|
|
|
### What it cost
|
|
|
|
Widget draws / distinct widgets / update, from
|
|
`tests/layout_diagnostics.rs`, at depth 8. Draw counts are deterministic, so
|
|
these are single runs rather than medians; `e44dea3` is #18's head and
|
|
`34cafb6` the commit this branch starts from.
|
|
|
|
| seed 1 | e44dea3 | 34cafb6 | here |
|
|
| --- | --- | --- | --- |
|
|
| cold | 369/261/10.6 | 463/274/13.3 | 516/288/12.0 |
|
|
| repaint | 1 | 1 | 1 |
|
|
| many | 157/95/0.33 | 263/108/0.59 | 187/119/0.52 |
|
|
| size | 16/12/0.018 | 3/3 | 3/3/0.010 |
|
|
| scroll | 2/0.002 | 1 | 1/0.004 |
|
|
| resize | 13/13/0.019 | 22/15/0.032 | 24/76/0.090 |
|
|
|
|
| seed 13 | e44dea3 | here |
|
|
| --- | --- | --- |
|
|
| cold | 1330/707/20.3 | 2940/982/28.3 |
|
|
| many | 524/159/1.09 | 1091/423/2.39 |
|
|
| resize | nothing drawn | 2215/510/6.56 |
|
|
|
|
So `size` and `scroll` keep the experiment's wins, `many` is better than the
|
|
commit it starts from and still well short of #18, and **`resize` on a deep
|
|
tree is where it loses badly**: #18 draws nothing at all at seed 13, because
|
|
every fraction scales and every `Holds` admits the new window.
|
|
|
|
Three measured findings, each already applied:
|
|
|
|
- **Lazy `Within` placement costs more than it saves.** The plan's step 5 --
|
|
leave a child's answer to be placed at the end of the parent's draw --
|
|
puts the drawing in the part first and in the answer's box after, and
|
|
where it does not hold for both that is two drawings rather than one.
|
|
Seed 1's resize went from 391 widget draws to 29 with it removed. The test
|
|
that pinned three draws for a numeric leaf in a span went with it.
|
|
- **An inset said in frame lengths makes a container read its own box.**
|
|
"Less eleven pixels at the end" needs the length, and a container whose box
|
|
is its own answer then depends on its own answer: `Pad` drew sixty-four
|
|
times in one resize frame at seed 13, chasing its own width. `Part::Of`
|
|
says it as a part of the box instead and composes without a length.
|
|
- **Pin one axis at a time.** `extent_len` pinning both made a span
|
|
dividing one axis hold for one length of the other, and a resize broke
|
|
every span whose cross-axis answer moved.
|
|
|
|
### What is not done, and why
|
|
|
|
**Step 6, `redraw` without the deferral, does not hold.** Removing it --
|
|
asking the measuring question locally and placing the answer afterwards --
|
|
makes seeds 104 (`align`) and 210 (`reorder`) at depth 5 settle differently
|
|
warm than cold. The plan said to stop and report rather than restore it; it
|
|
is restored, in the form the protocol allows (`info.part.size() !=
|
|
offered.part.size()`), and this is the report.
|
|
|
|
**What is underneath it.** A `Place` is relative to the box the container is
|
|
being drawn in, so the same ask expression resolves to a different box
|
|
depending on which of its own boxes the container is drawing in -- and a
|
|
container is routinely drawn twice, once in the box its parent measured it
|
|
in and once in the box its own answer chose. So "the box this widget's
|
|
answer was measured in" cannot be recovered from the ask, which is why
|
|
`offer_part` is retained; and whether a given draw is a measurement cannot
|
|
be recovered either, which is where the two open seeds live:
|
|
|
|
- **seed 2, `repaint`, depth 5**: `Stack > Pad{6,14,16,15} > Span{DOWN}`
|
|
with a wrapping text under the span. The span answers 880 px measured in
|
|
the pad's first box and 874.24 px measured in the box the pad's answer
|
|
then chose, and both are fixed points of "measure in the box, place at the
|
|
answer, measure again". Cold reuses the drawing and keeps the first; a
|
|
repaint redraws and lands on the second.
|
|
- **seed 1 at depth 4 and 108 at depth 5, `reorder`**: the same shape
|
|
through `Branch`, which chooses a subtree from a measured length, so the
|
|
two fixed points are two different trees.
|
|
|
|
Four bookkeeping rules were tried for "which draw is a measurement": the
|
|
ask's first-ness alone, the place matching the retained offer place, the box
|
|
matching the box the answer was measured in, and a retained flag on the
|
|
drawing. Each fixes some seeds and breaks others, which is the signal that
|
|
the question is the protocol's rather than the bookkeeping's. What the
|
|
baseline does instead is never act on it: a local redraw whose box is not
|
|
the offer's defers to the parent, and the parent re-asks from a geometry
|
|
that a cold layout also reaches.
|
|
|
|
**Also not done**: step 7 (`Span`'s known-length shortcut and the cross-axis
|
|
report in frame pixels), step 8 (`Inset`/`Outset` as test widgets) and the
|
|
`LazySpan`/`SizeRule` work that sits on top of this.
|
|
|
|
**`Pad` is an outset and that is visible.** `examples/text.rs` has
|
|
`wtext(..).width(rel(1.0))` inside a `.pad(16)`: under the old reading that
|
|
was the padded box, and under transparent frames it is the window, so the
|
|
labels now sit at the window's edges and the left one is clipped. `view` and
|
|
`minimal` are byte-identical to `34cafb6`; `tabs` moves its red square 3 px;
|
|
`random` is a generated tree and moves where nested spans do. The plan's
|
|
"a general `Pad` would outset pixels and inset `rel` and `leftover`" is the
|
|
answer to this and is **not expressible in the protocol as it stands**: an
|
|
inset that narrows the frame *and* places the child in a part of its own box
|
|
needs the child's box expressed in the child's narrowed frame, and
|
|
`(0.5·B - 20)/(B - 20)` is not `rel + px`. Either the frame narrows and the
|
|
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`,
|
|
`ui/painter.rs`, `ui/render_state.rs` and the position widgets, outside of
|
|
doing work on them, with each finding checked by a scratch test.
|
|
|
|
**Verdict.** The concepts are sound and stay. Fixed point on a `1/1024`
|
|
grid is the right base for a layout that decides "same box or not" by
|
|
equality. Threading the pixel box down the draw, with `Holds::through` the
|
|
exact preimage of that one multiply, is the strongest idea in the code:
|
|
layout has one route to every length and the reuse test is its exact
|
|
inverse. Offer, given and placed is the ordinary measure-then-arrange model.
|
|
What needed work was the bookkeeping around the second ask, one boundary in
|
|
`Span` computed by an expression other than the drawing it guards, and a
|
|
`rel` that meant two things. The two-step residual is structural and no
|
|
grid width fixes it; where it becomes visible is the shader's snap.
|
|
|
|
### Decided by Bryan
|
|
|
|
- **`rel` is a fraction of the containing widget's whole area.** In a span,
|
|
`rel(0.5)` is half the span whatever else is in it and wherever it sits.
|
|
It is never a fraction of what was left after earlier children. This
|
|
supersedes the 2026-09-16 reading that a report is a fraction of the box
|
|
the widget was given, wherever that box is a remainder rather than the
|
|
child's whole area.
|
|
- **`draw` stays the only layout method on `Widget`** (Bryan, 2026-09-17,
|
|
reversing the same day's acceptance of a measure/draw split). Ease of
|
|
writing a widget is half the reason. The real one is that a second method
|
|
holding the same layout drifts from the first, which a span makes
|
|
extremely easy, and the shared logic then gets pulled into helpers both
|
|
call that still have to be applied carefully in each. Where the framework
|
|
needs a widget's layout twice it runs the same body again with the
|
|
painter in a different state, or hands it more through the painter.
|
|
- **A widget's frame does not change between the ask that measures and the
|
|
ask that places.** Fractions are of the frame; its own box reaches the
|
|
widget through the painter. This is what transparent frames implements.
|
|
|
|
### A report is a fraction of the containing widget (landed, `ffd79f3`)
|
|
|
|
`rel(0.5)` is half the span whatever else is in it and wherever the child
|
|
sits. A report used to come back composed through the box it was offered,
|
|
and a span offers each child the room from its cursor, so a nested span
|
|
taking half of what it was given took a quarter of a row whose first half
|
|
was spoken for -- where the same half written as a rule on the child took
|
|
half the row.
|
|
|
|
The ask carried the base separately (`reports_of`) for a day; transparent
|
|
frames replaced it with the frame, which is the same statement made once per
|
|
widget rather than once per ask. The offer is still the remainder, because a
|
|
text has to wrap at the width actually there.
|
|
|
|
**A span can now overflow itself without bound**, which is the consequence
|
|
Bryan's rule asks for: two children reporting half each take the whole row
|
|
and a third starts past the end. Under the old reading `total.rel` could
|
|
not exceed one, so `Span`'s `fixed <= 0` branches were only reachable
|
|
through declared fractions; they are ordinary now, and with them boxes of
|
|
negative length passed down to children.
|
|
|
|
### An answer is not an answer while anything under it is dirty (`0e0d4af`, superseded by `a0693ac`)
|
|
|
|
**Superseded: `dirty_size_under` is deleted.** Once a resize goes through
|
|
the settling walk the ordering makes the whole category unreachable rather
|
|
than checked, which is Bryan's steer and the better answer. The defect and
|
|
its reasoning are kept below because they say what the ordering is buying.
|
|
|
|
|
|
`draw_inner` took an answer from `try_reuse`, which checks only whether the
|
|
widget itself is marked, where `retained_answer` beside it also refused one
|
|
while anything the widget read a size from was dirty. A widget whose drawing
|
|
happened to be reusable therefore handed back the answer it gave before that
|
|
descendant changed, and nothing puts it right: the comparison that tells a
|
|
reader its child's answer moved is in `redraw`, and a widget settled inside
|
|
its parent's own draw never goes through it. The placing ask redraws the
|
|
subtree, the descendant's mark is cleared there, and the parent keeps a
|
|
number the tree no longer agrees with.
|
|
|
|
So `dirty_size_under` was not the optimization its comment claimed: it was
|
|
what made an answer an answer, until the walk made the state it guarded
|
|
against impossible to be in.
|
|
|
|
Found at seed 564, depth 6, `shuffle-every-other`, reachable only once a
|
|
span could overflow itself. No hand-built tree ever reproduced it, and the
|
|
seeds at depth 4 above fail for some other reason.
|
|
|
|
### A text is handed back a box its own line fits in (landed, `4bd8607`)
|
|
|
|
A wrapping text reported the width it used through `Px::from_f32`, which
|
|
takes the nearest step and is under the line the shaper measured half the
|
|
time. A parent that sizes itself from that report -- a stack taking a span's
|
|
width, the span taking its widest child's -- then hands the text back a box
|
|
its own longest line does not fit in, and a greedy break there is one line
|
|
longer. Warm kept the break it had; cold made the narrower one.
|
|
|
|
Two tolerances were holding that together and both are gone:
|
|
|
|
- `TextBuffer::shape` answered any width within `BREAK_EPSILON_PX = 0.05` of
|
|
the longest line from the break in hand. Fifty steps of the grid, and a
|
|
structural decision taken on a hair's breadth -- the thing the `Span`
|
|
boundary invariant below already forbids. It is `want >= layout.width()`
|
|
now, exactly.
|
|
- The `Holds` range the text declares started at the nearest step to its
|
|
longest line, so it admitted boxes that line does not fit in. It starts at
|
|
`Px::ceil_from_f32` of it now.
|
|
|
|
Neither was the fix. **The fix is the report**: `Size::from_px(
|
|
PxVec2::ceil_from_f32(tex.size))`, the step at or above what was measured,
|
|
so the box that comes back fits. With it in place either tolerance could
|
|
have stayed and the case passes; both are wrong on their own terms, so both
|
|
went. `Fixed::ceil_from_f32` is new and is the only rounding on the grid
|
|
that is not to the nearest step.
|
|
|
|
The general shape, and the third time this branch has hit it: **a value that
|
|
comes back as a box has to be rounded away from the measurement, not to the
|
|
nearest step.** Rounding to nearest is right for a value being carried;
|
|
it is wrong for a bound.
|
|
|
|
### A frame settles strictly bottom-up (landed, `a92c6ac`)
|
|
|
|
The queue was already deepest-first, but a widget that could not settle
|
|
where it was called `redraw` on its parent from inside itself, which drew a
|
|
shallow widget while dirty widgets deeper in other subtrees were still
|
|
pending. A parent drawing over a subtree that has not settled reads answers
|
|
about to move, and the one that settles does so inside the parent's draw --
|
|
where its mark comes off and nothing compares what it now answers.
|
|
|
|
A widget that cannot settle defers instead: it marks its parent, stays
|
|
marked, and waits in `deferred` until the walk reaches the parent's depth,
|
|
which cannot happen before everything deeper has settled.
|
|
|
|
```rust
|
|
loop {
|
|
let next = rsc.widgets().needs_redraw.iter().copied()
|
|
.filter(|id| !self.deferred.contains(id))
|
|
.max_by_key(|&id| self.depth(id));
|
|
let Some(id) = next else { break };
|
|
if !self.redraw(id, rsc) {
|
|
self.deferred.insert(id);
|
|
}
|
|
}
|
|
```
|
|
|
|
Bryan's, 2026-09-17, and the right answer where `0e0d4af` was a check:
|
|
"then that entire category of issue can't even occur".
|
|
|
|
**The walk is sound on its own, and the second entry point is closed**
|
|
(`a0693ac`). By induction on depth: when a widget at depth d draws fresh,
|
|
every dirty widget deeper has been popped, so each is settled or deferred,
|
|
and a deferred one has marked its parent. A clean child asked by that draw
|
|
therefore has a clean subtree, because anything dirty under it would have a
|
|
dirty parent, and so on up to the child itself.
|
|
|
|
The entry point that was left was `update` drawing the root for a resize
|
|
before the walk ran, top-down over a tree with dirty widgets still in it.
|
|
It is closed by marking the root instead, so layout is one walk a frame and
|
|
`dirty_size_under` is gone at both call sites. **The root is marked only
|
|
where the new output falls outside what its answer holds for**: that range
|
|
is the intersection of everything under it, so admitting the new output
|
|
says the whole tree stands, and nothing above the root moved. Marking it
|
|
unconditionally cost the root its own `Holds` -- a leaf root that scales
|
|
with its box was drawn again on every resize, which two tests caught.
|
|
|
|
The rig says the cost is scheduling only: on `resize`, one queue pop, one
|
|
local redraw and one depth read appear and one failed reuse attempt goes,
|
|
and every other counter on `cold`, `repaint`, `many`, `size`, `scroll` and
|
|
`resize` is identical.
|
|
|
|
### A subtree that changes hands is recorded on both sides (landed, `e44dea3`)
|
|
|
|
A subtree can be reused whole under a different parent -- same box, same
|
|
layer, same region node, clean -- and nothing in the drawing says it moved.
|
|
Two things read who its parent is, and both were wrong after one of these.
|
|
|
|
- The **old parent still listed it**, and a parent's next draw undraws
|
|
whatever is missing from that list. Two spans under one root, with the
|
|
root swapping which of them it holds, drew the subtree under the new span
|
|
and then erased it when the old one drew.
|
|
- Its **depth** was the one it had under the old parent, which is what the
|
|
settling walk orders by, so a change made under it afterwards settled at
|
|
the wrong point in the frame.
|
|
|
|
Both are written where `draw_inner` already records what the ask decided:
|
|
`active.parent` is replaced and the old parent's `children` repaired, and
|
|
`try_reuse` re-walks the subtree's depths -- only where the top of it
|
|
moved, which is what makes that free in the ordinary case. Pinned by
|
|
`retained::a_subtree_that_changed_parents_is_not_undrawn_by_the_one_it_left`
|
|
and `..._settles_at_the_depth_it_moved_to`; each fails without one half.
|
|
|
|
The fuzzer never re-parents (`reshuffle` only trades children between a
|
|
span and its own spares), which is why nothing generated reached either.
|
|
|
|
### A span's leftover boundary is its own inverse (landed, `53b00c6`)
|
|
|
|
The decision used a rounded division where the room the children get is a
|
|
floored multiply, so the boundary and the drawing it guarded were two
|
|
expressions for one length. `room` is that length as a `Len`, `room.to_px`
|
|
is the multiply, and `Holds::through` is its exact preimage:
|
|
|
|
```rust
|
|
let room = Len::rel_max() - Len::from_parts(total.rel, total.px);
|
|
let mut shares = false;
|
|
if total.leftover > Weight::ZERO {
|
|
shares = room.to_px(painter.px_len(axis)) > Px::ZERO;
|
|
let holds = match shares {
|
|
true => Holds::from(Px::STEP..=Px::MAX),
|
|
false => Holds::from(Px::MIN..=Px::ZERO),
|
|
};
|
|
painter.holds(axis, holds.through(room));
|
|
}
|
|
```
|
|
|
|
The three branches were the sign of `1 - rel`, which `through` reads
|
|
already. Forty lines became twelve and one `div` left layout. The general
|
|
rule stands and is now demonstrated: **derive a boundary through the
|
|
inverse of the expression that draws, never by a second expression for the
|
|
same length.**
|
|
|
|
### Two branches parked, one of them now closed
|
|
|
|
**`wip/stack-fraction-twice` is closed by transparent frames.** A stack
|
|
sized by a child that reported a fraction applied that fraction twice --
|
|
half a row became a quarter -- because the placing ask resolved the fraction
|
|
in the box the answer had already chosen. Under the protocol above a report
|
|
is a fraction of the frame and `placed_extent` takes it *from* the part
|
|
rather than composing it into the part, so it is resolved once. No oracle
|
|
could see it (warm and cold shrank alike), so the test that came with the
|
|
branch is what pins it.
|
|
|
|
**`wip/padding-outset-and-inset` is superseded** by the `Pad` question at the
|
|
end of "What is not done, and why". What it got right is kept: padding goes
|
|
outside what it pads, and an `Inset` is a separate widget. What stopped it --
|
|
a child declaring `rel(0.5)` under an `Inset` coming out 47.5 px of the 190
|
|
inside -- is the same second application of a fraction, and the protocol
|
|
removes it.
|
|
|
|
### `Span`'s leftover boundary is a third expression for the room
|
|
|
|
The decision uses a rounded division, `total.px.div(fixed)`, while the room
|
|
the children get is a floored multiply, so the two disagree at the boundary.
|
|
Measured with 300 px, `rel(2/3)` and a `leftover` child:
|
|
|
|
| row width | leftover child | its threaded length |
|
|
| --- | --- | --- |
|
|
| 900.000 | undrawn | |
|
|
| 900.001 | drawn | 0 steps |
|
|
| 900.002 | drawn | 0 steps |
|
|
|
|
Harmless at two steps, but the three-branch block collapses into the inverse
|
|
that already exists. `room` is computed a few lines below the block as
|
|
`Len::rel_max() - Len::from_parts(total.rel, total.px)`, and its `to_px` is
|
|
exactly the threaded length the leftover children share:
|
|
|
|
```rust
|
|
let room = Len::rel_max() - Len::from_parts(total.rel, total.px);
|
|
let mut shares = false;
|
|
if total.leftover > Weight::ZERO {
|
|
shares = room.to_px(painter.px_len(axis)) > Px::ZERO;
|
|
let holds = match shares {
|
|
true => Holds::from(Px::STEP..=Px::MAX),
|
|
false => Holds::from(Px::MIN..=Px::ZERO),
|
|
};
|
|
painter.holds(axis, holds.through(room));
|
|
}
|
|
```
|
|
|
|
`through` already handles a negative fraction and a zero one, so the
|
|
`fixed < 0` and `fixed == 0` branches go with it. The general lesson: `mul`
|
|
floors while `div`, `div_int` and `ratio` round to nearest, so a boundary
|
|
derived with a division guards a drawing made with a multiply. Derive
|
|
boundaries through `through`, or make the grid floor everywhere.
|
|
|
|
### Where the residual comes from, and the snap
|
|
|
|
The `e44dea3` baseline's two-step allowance came from inverse remapping and
|
|
alignment composed by different routes. The frame/extent continuation below
|
|
retains local widget frames as well as local primitive coordinates, recomposes
|
|
in the same order as a cold draw, and removes inverse remapping. Its oracle
|
|
requires exact pixel-region equality. That experiment is not yet the pinned
|
|
framework; the baseline's snap decision below still applies there.
|
|
|
|
Where it does matter is `snap_floor` in `prelude.wgsl`, which adds half a
|
|
layout step before flooring: that absorbs float error and not a layout
|
|
step, and truncation makes "one step under an integer" the common residue.
|
|
A third of 900 px is 299.999 on the grid and lands at 299 on screen, which
|
|
is the pixel `08c9d5a` moved `tabs`'s arcs by. Rounding to the nearest
|
|
pixel absorbs both the truncation and the two-step residual everywhere
|
|
except within two steps of a half pixel, where layout never lands on
|
|
purpose, and keeps integer widths for equal fractional parts:
|
|
|
|
```wgsl
|
|
fn snap_floor(v: vec2<f32>) -> vec2<f32> {
|
|
return floor(v + 0.5);
|
|
}
|
|
```
|
|
|
|
Approved by Bryan on 2026-09-17, together with rounding on the CPU; see
|
|
item 5 under "Next" for why both, and what each does not fix. The check is
|
|
the reference render set plus the oracle; expect `tabs` to move its arcs
|
|
back.
|
|
|
|
### Smaller items
|
|
|
|
- The comment on the `local == UiRegion::FULL` shortcut in `widget_at` says
|
|
composing through `FULL` "is not quite the identity in f32". On the grid
|
|
it is exact; the shortcut is performance only now.
|
|
- An undrawn `leftover` child still contributes its gap, so a vanished
|
|
child leaves a double gap.
|
|
- Nested spans pass `leftover` weight up, so three leftover children in one
|
|
inner span beside one in another get three quarters to one quarter. No
|
|
other layout system does that, and the doc's old example of two and two
|
|
did not distinguish it from per-span division. Confirm it is wanted.
|
|
- `Fixed::div` by zero answers `MIN`/`MAX` while `ratio` answers `ZERO`;
|
|
both are caller bugs under `debug_assert`, but the fallbacks differ.
|
|
|
|
### Before transparent frames, and what survives from it
|
|
|
|
The frame/extent prototype that `34cafb6` is the head of separated a
|
|
widget's fraction reference from where its drawing sits, and the
|
|
transparent-frames protocol above is that idea finished. Four of its
|
|
findings still hold and are why the code is shaped this way:
|
|
|
|
- **An answer and a drawing each retain their dependencies.** A child's size
|
|
answer constrains its parent only when the parent reads that answer; the
|
|
child's drawing constrains the parent's retained drawing whether its size
|
|
was read or not. `Painter` collects the two separately (`answer_under` and
|
|
`under`), which is what stopped a stack sized by one child from
|
|
remeasuring because an unmeasured overlay wrapped at another width.
|
|
- **A wider contract does not invalidate an existing guarantee.** When a
|
|
local redraw comes back with the same size under a contract covering the
|
|
old one, the old one is kept; comparing whole contracts by equality
|
|
doubled `scroll` cycles.
|
|
- **No measurement is different from a measured zero**: `ActiveData::answer`
|
|
is optional, and a previously undrawn pure share must not answer with the
|
|
placeholder zero it never gave.
|
|
- **The settling walk takes the deepest mark from a `BTreeSet` keyed by
|
|
depth**, and what ends it is the mark set rather than the queue.
|
|
|
|
Two failed hypotheses from it, kept because they are cheap to repeat:
|
|
|
|
- **The placement pin was blamed for the `many` gap and is not the cause.**
|
|
Disabling it (unsound, a bound) still redrew 487 distinct widgets a frame
|
|
at seed 13 against `e44dea3`'s 159. What the per-widget trace showed
|
|
instead was local redraws deferring to their parents and the deferrals
|
|
chaining to the root -- 43 deferrals in one frame, in chains such as
|
|
424 → 425 → 437 → 449 → 483 → 487 → 491, because a span handed its
|
|
children its own placement as their frame and that placement changed
|
|
between the measuring ask and the placed one. Transparent frames is the
|
|
answer to that and it works: the frame no longer changes under a widget.
|
|
- **`wip/local-reask`** re-asked a dirty widget at its offer instead of
|
|
deferring, under the *old* protocol, and diverged at seeds 532 and 398 of
|
|
depth 6. The same shape is what "What is not done, and why" is about; the
|
|
branch is superseded and can be deleted.
|
|
|
|
## How layout is decided
|
|
|
|
### Fixed point
|
|
|
|
Decided with Bryan on 2026-09-15. Layout decides on a grid rather than in
|
|
floats.
|
|
|
|
- **`Fixed<SHIFT>` is an `i32` counting `1 / 2^SHIFT`.** Adding and
|
|
subtracting are exact; `mul` drops to the step below (Bryan, 2026-09-16:
|
|
truncation is preferable); `div`, `div_int` and `ratio` round to nearest;
|
|
`to_scale` takes the nearest step. Two routes to one place that land on
|
|
one number are the same place, so everything downstream compares for
|
|
equality.
|
|
- **`Px` is `1/1024` px, `Rel` is `1/2^24` of a box, `Weight` is `1/65536`
|
|
of a share.** `PX_SHIFT` and `REL_SHIFT` are the only statement of the
|
|
first two; the shader's copy is prepended from them by
|
|
`render::module_source`. `Px` was `1/64` first, where one rounding's
|
|
residue was 0.016 px and enough to move a box. Range is +/-2.1M px and
|
|
conversion to `f32` is exact to 16,384 px.
|
|
- A weight is not a fraction: a list divides its room by the total of its
|
|
weights, and `Rel::ratio` turns two weights into a share on the finer
|
|
grid.
|
|
- **Arithmetic wraps** (`4febabf`, Bryan: a coordinate past the range will
|
|
not draw reasonably anyway, so wrap and break clearly). Saturating cost a
|
|
twelfth of layout's instructions. `MIN` and `MAX` stand in for an
|
|
unbounded end and are only ever compared against; `from_f32` is the one
|
|
operation that clamps, and `Holds` keeps a saturating `narrow`.
|
|
- A pointer, a wheel notch, a shaped glyph advance and a window size arrive
|
|
as floats and go on the grid where they arrive. `Vec2` is what the GPU
|
|
and the platform speak; `PxVec2` is what layout decides in.
|
|
- **Do not widen the grid to chase a residue.** Every failure this branch
|
|
saw was one value reached by two expressions, sitting on a boundary
|
|
defined by the same value coming back the other way. No precision shrinks
|
|
a residue that is the whole distance.
|
|
|
|
### A box in pixels is one multiply from its parent's
|
|
|
|
`ActiveData` keeps a widget's box as lengths of its parent's box --
|
|
`given_len`, and `offer_len` for the box it was first asked about --
|
|
`DrawInfo` carries the pixel lengths themselves (`px`, `offered_px`), and a
|
|
draw threads them down one `Len::to_px` at a time: the box its parent gave
|
|
it, then the part of that box its own answer placed its drawing in, which
|
|
`placed_lens` states once for both `placed_box` and the walk.
|
|
`Painter::px_size` and `px_len` read that value, and
|
|
`UiRenderState::asked_px` takes the same steps back up the parent chain
|
|
when a local redraw starts part-way down the tree. Neither chain has a
|
|
coordinate frame in it, so a region node cannot break either, and warm and
|
|
cold reach every length by the same expression.
|
|
|
|
- **`Holds::through` is the exact preimage of `px + floor(rel * box)`**:
|
|
`floor(rel * B) >= lo - px` is `rel * B >= (lo - px) << REL` and
|
|
`floor(rel * B) <= hi - px` is `rel * B < (hi - px + 1) << REL`, two
|
|
`div_toward`s once the sign of `rel` has said which bound is which. The
|
|
answer is an interval even for a single length, because a floor is not
|
|
invertible. The range has to contain the box a drawing was made in (the
|
|
`Holds` assertion in `draw_at`, debug only) and must not contain a box
|
|
the drawing does not hold for (the oracle); being the preimage makes
|
|
those one statement rather than a trade-off.
|
|
- **Symbolic regions are for the GPU, hit testing and remaps alone.**
|
|
`Moves::resolve` is the only walk left and it is the vertex shader's.
|
|
Nothing layout decides is composed back up the move chain.
|
|
- **`px` is not stored on `ActiveData`, deliberately.** A resize every
|
|
widget's `Holds` admits redraws nothing, so a stored pixel length would
|
|
be stale on every widget in the tree with nothing to say so. `asked_px`
|
|
walks up only where a widget is already being redrawn; the mean chain is
|
|
2.8 levels.
|
|
- **The window is not a move entry** (`5b78002`). A chain bottoms out in
|
|
`MoveIdx::NONE`; the window is applied where a fraction becomes pixels,
|
|
`to_px(output_size)` on the CPU and the uniform in the shader. A resize
|
|
rewrites no retained entry and re-uploads nothing but the uniform; its
|
|
cost is whatever `Holds` redraws.
|
|
- Failed hypothesis, kept as the shape of the mistake: an offer composed
|
|
back up the chain fell back to `FULL` under a region node and was resolved
|
|
against that node's *placed* box, so everything under a `Scroll` was
|
|
re-asked at the content's width and confirmed its own answer. Pinned by
|
|
`unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered`.
|
|
The old chain with an allowance in `through` passed that case and the old
|
|
chain with the exact `through` failed it; both halves had to land at once.
|
|
|
|
### What the fuzzers tolerate
|
|
|
|
The frame/extent continuation removes `AGREE_STEPS`: warm and cold pixel
|
|
regions must compare exactly. This is distinct from the equal-share test:
|
|
when a row's grid-step count is not divisible by the number of children,
|
|
individual share widths can differ while every rerun of that layout must
|
|
still agree exactly. The PR #18 baseline still allows two position steps;
|
|
its earlier failure at one step (`resize-size`, seeds 384 and 162 at depth 5)
|
|
is a useful regression target for the experimental recomposition.
|
|
|
|
## Retained-layout invariants
|
|
|
|
- `Holds` is the interval of box lengths for which a widget's drawing and
|
|
reported size stay valid. Reading `Painter::px_len` or `px_size` narrows
|
|
it to the length read; `Painter::holds` widens it. Parent validity is the
|
|
intersection of what its children induce: measured children for an answer,
|
|
all painted children for a drawing. The contract is trusted: a
|
|
widget declaring a wrong range is a defective widget, and Iris adds no
|
|
defensive work to recover from one.
|
|
- A retained drawing can be reused only when its `Holds` contains the new
|
|
pixel box on both axes, its parent node is unchanged, its region-node
|
|
choice matches the retained structure, it is on the layer it is asked
|
|
for, and the widget is clean. A valid ordinary subtree moves without
|
|
redrawing by recursive remap; a region node moves by one entry.
|
|
- **A retained drawing belongs to the layer it was made on.** A container
|
|
that measures a child by drawing it measures on the layer that child will
|
|
draw on -- `Painter::child_layer_at` -- or it pays two draws a frame.
|
|
- The first box a parent asks about is the offer; a later box chosen from
|
|
the answer is the final box, not another answer. A dirty widget is
|
|
re-asked in the box its parent gave it, and only where that box is as
|
|
long as the offer; anything else is its parent's question, with the mark
|
|
left on. Lengths and not whole boxes: what a drawing depends on is its
|
|
lengths, so the same lengths elsewhere is the same question.
|
|
- An answer is reusable where its measurement contract holds. Its final
|
|
drawing is checked independently and may need redrawing even while the answer
|
|
stands. Drawing validity is translated back through the chosen placement for
|
|
the parent's drawing contract, not intersected into the retained answer.
|
|
- **A widget's frame passes through every container that only divides room,
|
|
and its length is the same on every ask.** What narrows it is decided
|
|
above the widget: a declared length, and an inset once there is one. A box
|
|
that is an answer -- a row's height, a stack sized by a child -- is never
|
|
anything's frame. `Painter::widget_at(child, frame, [Place; 2])` says both
|
|
things about an ask: what the child's fractions are of, and what of this
|
|
widget's own box the drawing takes.
|
|
- **A fraction is resolved once, against the frame.** A report comes back
|
|
raw and is composed into the parent's frame by `within_len` only where the
|
|
parent narrowed the frame; `placed_extent` takes the reported length
|
|
*from* the part rather than composing it into the part.
|
|
- **Nothing may depend on where a box starts.** A container reads
|
|
`extent_len(axis)` for the length it divides, which pins that length
|
|
symbolically, and places children as parts of its own box, so moving the
|
|
box re-places them without drawing anything again.
|
|
- A widget that clips to its box reports its box: `Scroll` and `Masked`
|
|
report `LEFTOVER` on both axes, and a `debug_assert` holds any widget
|
|
that set a mask this draw to it. Overflowing is otherwise ordinary, which
|
|
is why the assertion is narrowed to mask-setters. Where content shorter
|
|
than a `Scroll`'s viewport sits is the scroll's own alignment, and its
|
|
"fits at the start of any box" widening is gated on near alignment.
|
|
- **A widget's own mask is not the one it inherited.** `ActiveData` keeps
|
|
both; they differ exactly where the widget called `set_mask`, which says
|
|
whose mask a move rewrites, and a local redraw is handed the inherited
|
|
one. Pinned by
|
|
`retained::a_masked_widget_redrawn_on_its_own_sets_its_mask_again`.
|
|
- A span is as long across itself as its longest fixed child, unless a rule
|
|
gives that length outright (`Painter::has_exact_size`), in which case it
|
|
does not read its children there at all. Any relative or `leftover` child
|
|
makes it report `leftover`. (The plan's step 7 changes this to the longest
|
|
`px`-or-`rel` child compared in frame pixels; not done.) **Do not choose between a fixed and a relative
|
|
child in pixels at the span's current width**: that admits multiple
|
|
self-sizing fixed points, and generated seed 13 settled differently warm
|
|
and cold under it. The same circularity is what a cap containing
|
|
`leftover` would put into `SizeRule::Max`.
|
|
- `Span`'s leftover/no-leftover split is a strict layout decision, not a
|
|
rounding tolerance: its `Holds` range must use the same exact boundary as
|
|
drawing. A tolerant endpoint retained zero-height children in seed 16; a
|
|
boundary moved off where boxes land was needed in floats and is not on
|
|
the grid. **A structural decision may not be taken on a hair's breadth**
|
|
that two routes can disagree about. Pinned by
|
|
`unsettled::a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over`.
|
|
- A pixel comparison is equality. A length given in pixels is that many
|
|
pixels wherever it ends up, structurally: `Len::within` adds a part's own
|
|
pixels rather than scaling them. Pinned by
|
|
`a_length_in_pixels_is_that_many_pixels_however_it_is_nested`. A length
|
|
given as a share is not: equal shares come out one or two steps apart
|
|
because positions, not lengths, are what gets rounded, so the row fills
|
|
and no two children leave a seam
|
|
(`equal_shares_differ_by_at_most_two_steps_and_fill_the_row`).
|
|
- **A move that keeps a box's length is a translation, and exact.** A box
|
|
that changed length re-expresses each part as a fraction of the new one,
|
|
which rounds. This inverts the float-era rule; `tests/cases/drift.rs`
|
|
pins that the grid does not drift either way.
|
|
- `Scroll` must return the answer from the first box it asked about,
|
|
whether retained or fresh; returning the final placed answer advanced one
|
|
fixed-point iteration (seed 86). Content that fills the viewport unscrolled
|
|
is handed back as it came, because the same box written as its own
|
|
length in pixels does not round alike.
|
|
- An asked-but-undrawn size dependency names the widget that asked as its
|
|
parent (seed 10). `Painter` records size-dependency edges only when a
|
|
parent reads a child's size or hint; an undrawn measured child stays
|
|
recorded so a later change reaches whoever decided not to draw it.
|
|
- Dirty widgets settle deepest-first, including during resize. A deferred
|
|
child leaves its parent marked, so no clean answer can hide an unsettled
|
|
size dependency. `dirty_size_under` has been deleted.
|
|
- Declared non-`leftover` lengths are resolved by the parent where the
|
|
widget is drawn, so a declared-length change redraws the parent. A rule
|
|
wins on the axis it names and the widget under it never learns of it.
|
|
**A cap may not contain `leftover`**: a cap must read the report, so rule
|
|
and report are one equation, and a share puts the row's division into it
|
|
-- the multiple-fixed-point failure again. A cap is pixels and a fraction,
|
|
which is what `Len` is.
|
|
- Text shaping is retained separately from line breaking; a greedy break
|
|
holds from its longest produced line through the width it was made at,
|
|
reported through `Painter::holds`.
|
|
- Region nodes: a node holds a whole `UiRegion` in its parent node's
|
|
coordinates, `FULL` is the identity, widgets opt in with `.region_node()`
|
|
or `Widgets::set_region_node`, and changing it redraws the subtree once.
|
|
`.scrollable()` sets it once; raw `Scroll::new` does not. A removed node's
|
|
move entry stays alive until every descendant has migrated. `Span` and
|
|
`Align` add no nodes.
|
|
- Alignment is one `f32` per axis (Bryan, 2026-09-15), default the middle
|
|
on both because the edges assume a direction. One widget keeps one length
|
|
per axis; a second length needs a second widget, `Wrapper` via
|
|
`.wrapper()` (Bryan, 2026-09-16, `d21a215`).
|
|
|
|
## Verification at the current head
|
|
|
|
At `49cec82` on `wip/transparent-frames`: `cargo fmt --all --check`, clippy
|
|
with `-D warnings`, 108 suite tests and 20 core tests in debug, the 11
|
|
generated cases, and the six rig phases in the table above. The shrinker at
|
|
400 trees of depth 5 **fails**, at seeds 2 (`repaint`) and 108 (`reorder`);
|
|
the long oracle at 1000/6 and the 2000/4 scan have not been run since they
|
|
would only find more of the same. The five reference renders were taken and
|
|
compared with `34cafb6`: `view` and `minimal` byte-identical, `tabs` 2,332
|
|
pixels, `text` and `random` as described above. Venus on the RX 7900 XT,
|
|
confirmed by `vulkaninfo --summary` in the same session.
|
|
|
|
At `e44dea3`, which is what #18 would merge:
|
|
|
|
- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets --
|
|
-D warnings`, `cargo test --workspace`: green, 92 suite tests, 19 core
|
|
unit tests, 11 generated cases. Only the long runs and the profiling rigs
|
|
are ignored; no known defect is.
|
|
- The release oracle at 100 seeds in 14.2 s, and **120 seeds in debug** in
|
|
59 s -- the debug run exercises the `Holds` assertion in `draw_at`.
|
|
- All fifteen shrinker cases at 400 seeds of depth 5 in 57 s, the oracle at
|
|
1000 seeds of depth 6 in 143 s, and **2000 seeds at depth 4 over all
|
|
fifteen cases** in 260 s. The last is not routine and should be: it is
|
|
the only run that has ever found anything past seed 400.
|
|
- `view`, `minimal`, `random`, `tabs` and `text` byte-identical at
|
|
1920x1200 against `25e456e`, as is `tabs` under the recorded replay.
|
|
`random` live-resized from 1920x1200 to 1280x800 is byte-identical to a
|
|
cold 1280x800 render. Rendered through Venus on the host's RX 7900 XT,
|
|
confirmed against `vulkaninfo --summary` in the same session -- an
|
|
llvmpipe fallback makes the same PNG and nothing in it says so.
|
|
- All rig counters identical on `cold`, `repaint`, `many`, `size` and
|
|
`scroll` across `25e456e`. `resize` gains one queue pop, one local
|
|
redraw and one depth read and loses one failed reuse attempt, which is
|
|
the root going through the walk; drawn widgets, widget draws, draw
|
|
requests and primitive writes do not move.
|
|
|
|
Everything above is verification of what was changed, not a claim that the
|
|
branch is correct.
|
|
|
|
**A claim about a render holds for the commit it was checked at and no
|
|
further.** `tabs` changed twice across `d3b0ebf` with nobody looking; take
|
|
the oracle as the reference and the five renders as a spot check.
|
|
|
|
**Run the long two before believing a rounding change**, and run the
|
|
ordinary suite in debug:
|
|
|
|
```sh
|
|
cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees
|
|
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
|
|
```
|
|
|
|
Depth is what finds things, but so is breadth: every late defect before
|
|
2026-09-17 surfaced at depth 5 or 6, and the two open ones were found by
|
|
running 2000 seeds at depth 4, which nothing routine does. Widen one axis
|
|
at a time and record which.
|
|
|
|
## Performance
|
|
|
|
**Threading a box in pixels down the draw is free on cold layout and 9-13%
|
|
off the retained paths** (2026-09-17). Instructions:u, medians of 21 runs of
|
|
binaries built in one worktree, seed 1 at depth 8, against `5b78002`:
|
|
|
|
| phase | before | after | |
|
|
| --- | --- | --- | --- |
|
|
| `cold`, 200 frames | 313.1M | 312.9M | -0.04% |
|
|
| `resize` | 408.1M | 405.6M | -0.61% |
|
|
| `many` | 1,924M | 1,756M | -8.75% |
|
|
| `scroll` | 357.3M | 323.4M | -9.49% |
|
|
| `repaint` | 363.3M | 315.4M | -13.18% |
|
|
|
|
`cold` and `resize` compare directly: all twenty-five work counters are
|
|
identical. The other three do less work: `repaint` goes from 23 draw
|
|
requests and 13 widget draws to 1 and 1, because `redraw` composes nothing
|
|
and a widget whose box moved without changing length settles itself instead
|
|
of escalating.
|
|
|
|
### How to measure here
|
|
|
|
- **Check the work counters before comparing two commits' times.** The rig
|
|
prints drawn widgets, widget draws and primitive writes; a comparison is
|
|
only worth reading when they match. `random.rs`'s `Branch` picks a
|
|
subtree by a measured pixel length, so the fixture's shape moves with the
|
|
thing measured; `Edits::fixed_branches` pins it for timing and the oracle
|
|
keeps measured branches on purpose. A 3x this section once reported was
|
|
that artifact.
|
|
- **`perf stat` in this VM returns garbage readings** for both
|
|
`instructions:u` and `cycles:u`, roughly a quarter of the time, off by a
|
|
factor of five to fifteen. Take medians of nine or more and report how
|
|
many readings a filter kept. Cycles spread 1-3% between sets of one
|
|
unchanged binary and 6.7% in the worst; instruction counts hold to 0.02%
|
|
within a binary and move 0.5% across a rebuild, so build the baseline
|
|
beside the thing measured and quote a delta. `ex_div_busy` held to 0.1%.
|
|
- **What moves cycles is whether `UiSpan::within` inlines.** It is the
|
|
hottest line in layout; `nm` shows it as a symbol when it does not.
|
|
Shrinking its body until the inliner takes it won; `#[inline]` on the
|
|
body it had lost 1.5% cycles. Shrink it, do not annotate it.
|
|
- `Holds::through` divides twice per call and accounts for essentially all
|
|
of a run's `i64` divisions: 21.3M cycles of a 500-frame `many`, 2.8%.
|
|
The float head divided twice there too.
|
|
|
|
### Tried and rejected, with numbers
|
|
|
|
- A float reciprocal for `AxisRemap::apply_scalar`'s division: +6% cycles.
|
|
`Holds::through`'s division has not been tried.
|
|
- Branchless `shift_round`: +6.7% cycles alone, and worse again with the
|
|
short-circuits removed. Size, not the branch, is what keeps `within` out
|
|
of line.
|
|
- Removing the per-child hash lookup in `remap_subtree`: 0.0%.
|
|
- Short-circuiting `apply_scalar` where the fraction is nought or one: +17%.
|
|
- Short-circuits guarding a saturating multiply stopped paying once the
|
|
multiply wrapped. Re-price a short-circuit before keeping it.
|
|
- Rust does not contract `a + b * c`; the float head never had an FMA to
|
|
compare the grid's multiply against.
|
|
|
|
Wrapping (`4febabf`) was -8.6% instructions and -6.6% cycles. Truncating
|
|
(`08c9d5a`, with `Fixed::scaled`'s zero test and `within`'s `is_full` tests
|
|
removed as one commit, since they are worth 61M instructions apart and 115M
|
|
together) costs a share a thousandth of a pixel of its row, makes a flipped
|
|
span sit a step from its mirror, and moved an antialiased edge in `tabs` by
|
|
one pixel. See "Where the residual comes from" for what that last one is.
|
|
|
|
## Rigs and reproduction
|
|
|
|
Ordinary framework verification:
|
|
|
|
```sh
|
|
cd /home/bob/repos/iris-pr18
|
|
cargo fmt --all --check
|
|
cargo clippy --workspace --all-targets -- -D warnings
|
|
cargo test --workspace
|
|
```
|
|
|
|
The ordinary tests are modules of one `tests/suite.rs` target; pick a module
|
|
with `cargo test --test suite layout::`. `profile.test` uses
|
|
`debug = "line-tables-only"`, which halved the test-target rebuild.
|
|
|
|
`tests/generated.rs` compares a warm incremental tree with a cold tree of
|
|
the same state; `IRIS_GENERATED_SEED`, `IRIS_GENERATED_SEEDS` and
|
|
`IRIS_GENERATED_DEPTH` select what it covers. `tests/shrink.rs` reduces a
|
|
failing tree over the same fifteen cases and the same trees --
|
|
`iris::random::plan(seed, depth, &edits)` and `build(rsc, &plan)`, so a
|
|
failing seed reduces directly and the oracle prints the command:
|
|
|
|
```sh
|
|
SHRINK_SEED=18 SHRINK_DEPTH=6 SHRINK_CASE=repaint-some \
|
|
cargo test --release --test shrink -- --ignored --nocapture
|
|
```
|
|
|
|
The cases live in `tests/scenario/mod.rs`, included by both targets by
|
|
`#[path]`; a case only one rig knows is how the two drifted apart once. Turn
|
|
what the shrinker finds into a test of its own rather than leaving a seed as
|
|
the record. Both fuzzers take a thread per core but one. A `git bisect`
|
|
once named a commit that could not be the cause; read the tree rather than
|
|
the bisect when that happens.
|
|
|
|
`tests/layout_diagnostics.rs` is the retained CPU rig: `IRIS_PHASE` selects
|
|
`cold`, `many`, `repaint`, `size`, `scroll` or `resize`, the
|
|
`layout-diagnostics` feature gives the explanatory counters, and an
|
|
uninstrumented release binary under `perf` gives totals. Dump the counters
|
|
with
|
|
|
|
```sh
|
|
IRIS_SEED=1 IRIS_DEPTH=8 IRIS_FRAMES=500 IRIS_PHASE=many \
|
|
<instrumented binary> --ignored --nocapture \
|
|
| grep -E '^ +[a-z].*[0-9.]+$' | grep -v ' ms$' | sort
|
|
```
|
|
|
|
and `diff` two runs; identical output is what says a change is free.
|
|
|
|
The float head is checked out at `/home/bob/repos/iris-float-cmp`, at
|
|
`5ed9e87` with `Edits::fixed_branches` applied uncommitted. Its counters do
|
|
not match the grid's and will not, so a comparison against it is a bound
|
|
rather than a measurement.
|
|
|
|
The headless reference set runs one process at a time because the rig
|
|
reuses one compositor; comparison worktrees need 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 replay used for the reference check:
|
|
|
|
```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
|
|
```
|
|
|
|
## Next
|
|
|
|
In order, from the review above and Bryan's steer (2026-09-17):
|
|
|
|
1. **Two questions for Bryan, both from "What is not done, and why".** What
|
|
a widget's answer is the answer *to*, when a container is drawn in two of
|
|
its own boxes in one frame -- which is what the two open fuzzer seeds
|
|
turn on and what step 6 needs before the deferral can go. And what `Pad`
|
|
should be now that a frame passes through: an outset (what the code does,
|
|
and `examples/text.rs` shows what it looks like), an inset that takes the
|
|
child's box with it, or the plan's "outset pixels, inset `rel` and
|
|
`leftover`", which the protocol cannot express as it stands.
|
|
2. **The rest of transparent frames**: step 7 (`Span`'s known-length
|
|
shortcut and the cross-axis report in frame pixels), then step 8
|
|
(`Inset`/`Outset` as test widgets, once the question above is answered).
|
|
`wip/local-reask` is superseded and can be deleted.
|
|
3. Write `ActiveData::answer` in one place -- `try_reuse` hands back what
|
|
the last drawing reported, which is a measurement only where that drawing
|
|
was one.
|
|
4. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the
|
|
reconstruction in `redraw`.
|
|
5. **Round on the CPU and snap to the nearest pixel in the shader**, as
|
|
one change with one verification. Bryan approved the snap on 2026-09-17
|
|
(rendering may change wherever it brings the screen closer to what the
|
|
user's code says: three equal sections of 1000 px need one of them
|
|
rounded up), and to CPU rounding the same day. The reason for that one
|
|
is different: a
|
|
`Rel` is off by at most `2^-25` of its box, so with round-to-nearest
|
|
every product whose true value is a whole number of steps is exact for
|
|
boxes under about 8,000 px, where truncation leaves half of them one
|
|
step short and layout then decides "does not fit" on a container the
|
|
user meant to fit exactly. Use the branchless round-half-up form,
|
|
`(a * b + (1 << (BY - 1))) >> BY`, not the sign-branching
|
|
`shift_round`; re-derive `Holds::through` for `round` (its two shifted
|
|
bounds move by half a `Rel` step); check with `nm` that `UiSpan::within`
|
|
still inlines; expect a couple of percent of instructions and re-run the
|
|
long fuzzers and the render set once for both.
|
|
6. The smaller items: the stale `f32` comment, the gap of an undrawn child,
|
|
confirm nested `leftover` weights, one zero-divisor fallback. Add to
|
|
them: a span that overflows itself hands a child a box of negative
|
|
length, which is ordinary now rather than a corner, and nothing states
|
|
what a widget may assume about one.
|
|
7. `LazySpan`, the next LAYOUT.md §2 item. Region nodes cover the movable
|
|
subtree case; do not restore a separate child-placement API.
|
|
8. `SizeRule::{Min, Max, Clamp}`, restoring the `max_width`/`max_height`
|
|
builders `8220a78` deleted. The clamp boundary is a hard layout decision
|
|
with an exact `Holds` split at the crossover, both sides in `Px`. Still
|
|
awaiting Bryan: whether a `Max` narrows the box the child draws in, or
|
|
only what the parent reports for it.
|
|
9. `Scroll` taking a direction rather than one axis.
|
|
|
|
`docs/LAYOUT.md` §4, §5 and the density section are stale: they name
|
|
`Painter::place`, `SetSize`, `desired_width`, `apply_rest`, `Len::dp`,
|
|
`Aligned` and `MaxSize`, none of which exist. Do not restore
|
|
`OnResize::Translate` or `OrthoSize`.
|
|
|
|
Other queued work, in dependency order: `UiRenderState` behind
|
|
`Rc<RefCell<_>>`; density-independent pixels; input restructuring (pointer
|
|
capture, drag slop and axis, cancellation, mask-aware hit testing,
|
|
timestamps); retained paints, selection, overlays and shared runtime state;
|
|
generic desktop/Android hosts and reusable example/APK tooling;
|
|
application-owned fonts and replaceable glyph-atlas buckets; positioned
|
|
text overflow and cluster-safe ellipsis.
|
|
|
|
The archive is a reference, not a patch: it predates returned `Size`, the
|
|
current box chain and the current length types. Recreate changes on current
|
|
types and keep app/session concepts out of Iris.
|