689 lines
35 KiB
Markdown
689 lines
35 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-17: the chronicle of closed defects, superseded
|
|
verification lists and cross-fixture tables went, the rules and the lessons
|
|
stayed.
|
|
|
|
## 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
|
|
**`ea6dbae`**, 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, and a box in pixels threaded down the draw.
|
|
No PR review was present when checked on 2026-09-15.
|
|
|
|
**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 needs work is the bookkeeping around the second ask, one boundary in
|
|
`Span` computed by an expression other than the drawing it guards, and a
|
|
`rel` that means 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.
|
|
- **Splitting `Widget::draw` into a measure and a draw is acceptable.** His
|
|
original reason against it: a span whose children have fixed sizes can
|
|
place them exactly where they were measured and so draw once, and that
|
|
must survive. The smaller items below come first.
|
|
|
|
### `rel` means two things today
|
|
|
|
Measured in a 400 px row whose first child is 100 px wide:
|
|
|
|
| second child | box | meaning |
|
|
| --- | --- | --- |
|
|
| `rect.width(rel(0.5))`, declared | 100 to 300 | half the row |
|
|
| nested span reporting `rel(0.5)` | 100 to 250 | half the remainder |
|
|
|
|
The declared case is the rule; the reported case is wrong, and
|
|
`tests/cases/layout.rs::a_span_reads_a_child_report_as_a_fraction_of_what_it_offered`
|
|
pins the wrong behaviour and has to change. The two placement functions in
|
|
`core/src/ui/painter.rs` differ by one line:
|
|
|
|
```rust
|
|
// placed_box: a reported length is composed through the offered box
|
|
let len = lens.axis(axis).within_len(span.len());
|
|
|
|
// declared_box: a declared length is a fraction of the parent's own box
|
|
let len = Len::from_parts(len.rel, len.px);
|
|
```
|
|
|
|
Making `declared_box` compose the same way would be the one-line fix in the
|
|
*wrong* direction. The composition `in_parent_frame` does is right where the
|
|
offer is the child's whole area -- `Pad`'s inset, a `Stack` child, `Scroll`'s
|
|
content -- and wrong where the offer is a positional remainder, which is
|
|
`Span` along its axis. `Span` offers each child the room from the cursor to
|
|
the end because a text has to wrap at the width actually left, so the pixel
|
|
width of the offer and the base its fractions are taken of have to be
|
|
separated: the remainder for one, the row for the other. Candidate: the ask
|
|
carries what a report's fractions are of, defaulting to the offered box, and
|
|
`Span` passes its own extent along the row. A child that drew at half of its
|
|
remainder is then placed at half the row and redrawn there by the placing
|
|
ask, which is one draw more, and exactly what `place` already does for any
|
|
child whose placed box differs from its offer. A nested span whose child
|
|
asks for half of it reports half of the row, gets it, and its child gets
|
|
half of that: no circularity, because the base is the row and not the
|
|
nested span's own answer. Not designed yet; it is first in "Next".
|
|
|
|
### The offer's answer is overwritten by every ask
|
|
|
|
`ActiveData::answer` is documented as what the widget answered at its
|
|
offer. `Painter::widget_at` guards its write with `answers_offer`, but
|
|
`UiRenderState::draw_inner` writes the field unconditionally and returns
|
|
the same value, so the guard is dead and the placing second ask overwrites
|
|
the offer's answer with one about the placed box. The guard is from
|
|
`29c7881`; the unconditional write arrived with `d3b0ebf`.
|
|
|
|
```rust
|
|
// painter.rs, widget_at
|
|
let answers_offer = self.at_offer && px == offered_px;
|
|
let (size, holds) = self.state.draw_inner(...);
|
|
if answers_offer {
|
|
self.state.active.get_mut(&id.id()).unwrap().answer = (size, holds);
|
|
}
|
|
|
|
// render_state.rs, draw_inner, on every ask
|
|
active.answer = settled;
|
|
```
|
|
|
|
`known_len`'s first-ask write and `update`'s resize path also write the
|
|
field as though `draw_inner` did not; `known_len`'s stores the value it just
|
|
read from the same field. The oracle passes, so layout is not wrong. What it
|
|
can cost is churn: an answer about the placed box can miss
|
|
`retained_answer` on the next offer ask and fall through to a remap and
|
|
back. Not confirmed against the counters. Write the field in one place, and
|
|
keep the guarded one.
|
|
|
|
### `redraw` reassembles `DrawInfo` by hand
|
|
|
|
`ActiveData` copies eight fields of `DrawInfo` and `redraw` rebuilds the
|
|
struct field by field. This is where the mask defect fixed in `ea6dbae`
|
|
lived for as long as there was a local-redraw path.
|
|
|
|
```rust
|
|
let info = DrawInfo {
|
|
layer: active.layer,
|
|
parent: active.parent,
|
|
depth: active.depth,
|
|
parent_move: active.parent_move,
|
|
region_node: rsc.widgets().is_region_node(id),
|
|
mask: active.parent_mask,
|
|
given_len: active.given_len,
|
|
offer_len: active.offer_len,
|
|
px: given_px,
|
|
offered_px,
|
|
decided: active.decided,
|
|
};
|
|
```
|
|
|
|
Store the `DrawInfo` on `ActiveData` and write
|
|
`DrawInfo { px, offered_px, ..active.info }`: the duplicated fields and the
|
|
reconstruction go, and a new field cannot be forgotten. The pixel pair stays
|
|
out on purpose; see "px is not stored" below.
|
|
|
|
### `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
|
|
|
|
Both remaining steps are a symbolic region re-expressed by division rather
|
|
than recomputed the way a cold draw computes it: `AxisRemap::Scale` divides
|
|
to find a part's fraction of the old box, and `placed_box` scales a mixed
|
|
`Len` by the alignment. Neither touches the threaded pixel chain, so every
|
|
layout decision already agrees warm against cold; what differs is the
|
|
composed position the shader and hit testing see, by up to 0.002 px.
|
|
|
|
Closing `Scale` exactly is possible: keep each primitive's region in its
|
|
widget's own coordinates and recompose on a move with `within`, eight
|
|
multiplies and no division against the current four divisions and twelve
|
|
multiplies, exact by construction, sixteen bytes more per primitive. Closing
|
|
the alignment one means resolving alignment in pixels, which costs the
|
|
retained resize path. Neither is worth a thousandth of a pixel on its own.
|
|
|
|
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.
|
|
|
|
### Measure and draw, later
|
|
|
|
Splitting `Widget::draw` into a measure and a draw would delete `at_offer`,
|
|
`offered`, `answers_offer`, the placing second ask and the reuse dance after
|
|
it. It costs two methods on every widget and is a rewrite of a core that
|
|
passes a thousand seeds at depth six. The one-draw property Bryan wants
|
|
kept holds either way: a child with a fixed size needs no measure, and a
|
|
measured child costs a measure plus a draw where it now costs a draw plus a
|
|
reuse or a redraw. Last in "Next".
|
|
|
|
## 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
|
|
|
|
`AGREE_STEPS` in `tests/scenario/mod.rs` is **2**, and both steps are
|
|
positions. One is a box centred in a fraction of its parent against the
|
|
same box centred in its own pixels, 0.001 px on a handful of seeds. The
|
|
other is `AxisRemap::Scale` re-expressing a part as a fraction of a box that
|
|
changed length; one step fails the 400-seed shrinker on `resize-size`,
|
|
seeds 384 and 162, by 0.002 px while passing the 100-seed oracle. Two of the
|
|
earlier sources were fixed rather than tolerated (`bdab558`): `Scroll` wrote
|
|
a box it had been given back out as its own length in pixels, and `Span`
|
|
placed each child a step from where the last ended rather than as the fixed
|
|
parts before it plus one rounded share. See "Where the residual comes from"
|
|
above for what closing the rest would cost.
|
|
|
|
## 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. 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 only where both its measurement and the drawing in
|
|
its final placed box remain valid; the drawing's `Holds` is translated
|
|
back through `placed_lens` and intersected with the answer's.
|
|
- `Painter::widget_decided(child, region, [bool; 2])` says the parent chose
|
|
this box from the child's own answer along those axes, so the answer is
|
|
not placed inside it again. A report of "half of what you give me" has no
|
|
fixed point but zero, so the framework asks exactly twice: at the offer,
|
|
and in the box chosen from the answer, final on the decided axes. `Span`
|
|
decides the row axis, `Scroll` both, `Stack` both for its sizing child.
|
|
`Pad` overrides nothing: its inset is exactly the inner where the box is
|
|
its answer, and the slack is the inner's to sit in otherwise.
|
|
- **Placement cannot be applied after the fact.** Three attempts at "draw
|
|
the widget, then move its drawing to where its alignment says" failed,
|
|
because the move is a change of frame and no split of the stored state
|
|
carries it: moving `ActiveData::region` with the drawing made a later
|
|
local redraw ask a differently rounded question, and leaving it made
|
|
`placed` accumulate without bound because `try_reuse` returns a clean
|
|
subtree's size without walking into it. Alignment is applied where the
|
|
size is known -- `declared_box` for a rule, the placing second ask
|
|
otherwise.
|
|
- 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`. **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. `dirty_size_under` stops a reader
|
|
taking a retained answer while something below it is dirty; it is an
|
|
optimization against laying out twice, not a validity mechanism.
|
|
- 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 `32542d0`, then `ea6dbae` on top:
|
|
|
|
- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets --
|
|
-D warnings`, `cargo test --workspace`: green, 87 suite tests, 18 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.4 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 58 s, and at 1000
|
|
seeds of depth 6 in 147 s.
|
|
- `view`, `minimal`, `random`, `tabs` and `text` byte-identical at
|
|
1920x1200 against `5b78002`, and the `tabs` touch replay before and after
|
|
the gesture. `random` live-resized from 1920x1200 to 1280x800 is
|
|
byte-identical to a cold 1280x800 render.
|
|
- Twenty-five rig work counters identical on the `cold` and `resize`
|
|
phases, which is what makes those rows under "Performance" a measurement.
|
|
|
|
**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: nothing failed at 100 seeds of depth 4, which
|
|
was all the oracle ever routinely ran, and every late defect surfaced at
|
|
depth 5 or 6.
|
|
|
|
## 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. **`rel` as a fraction of the containing widget's whole area**, on
|
|
`Span`'s row axis, per the decision above. Fix the test that pins the
|
|
remainder reading, and add the two-child case from the table.
|
|
2. Write `ActiveData::answer` in one place.
|
|
3. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the
|
|
reconstruction in `redraw`.
|
|
4. `Span`'s leftover boundary through `Holds::through`.
|
|
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). CPU rounding is recommended for a different reason: 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.
|
|
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.
|
|
10. The measure/draw split, once the above is in.
|
|
|
|
`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.
|