Bring LAYOUT.md §2 and §3 to what shipped

§2 described `Painter::place`, `move_offsets`, `resolved_region` and
`set_instance`; three of those four names no longer exist. What shipped is
the opt-in region node, a box rather than a translation, and remapping for
everything that did not opt in.

§3 described `Widget::on_resize` and its `Scale`/`Redraw`/`Translate`
answers, which are gone: the `Holds` interval says the same thing per drawing
rather than per widget type, and says how far. The 0.05 px comparison it
quoted is equality on the grid now.

The trait in §1 has two methods rather than three, and the line numbers it
cited have all moved; they are dropped rather than corrected, since the names
are enough to find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-16 03:50:58 -04:00
1 parent 617331f913
commit 2d860587a4
1 file changed
+82 -89
+82 -89
View File
@@ -4,9 +4,10 @@ A widget draws once and records its size on the `Painter`. Reading a child
`DrawResult::size()` records a retained size dependency; drawing the child `DrawResult::size()` records a retained size dependency; drawing the child
without reading that result does not make the parent's size depend on it. without reading that result does not make the parent's size depend on it.
§1 and §2 have landed in Iris (#16 and #18) and the notes below have been §1, §2 and §3 have landed in Iris (#16 and #18) and the notes below have been
brought to what shipped rather than what was proposed; §3 to §6 describe the brought to what shipped rather than what was proposed; §4 to §6 describe the
same design as it stands. `docs/HANDOFF.md` has the invariants same design as it stands, and name types that have since been replaced where
they were written before it. `docs/HANDOFF.md` has the invariants
the code now rests on and what is still to do. the code now rests on and what is still to do.
## Design ## Design
@@ -36,11 +37,12 @@ pub trait Widget: Any {
fn draw(&mut self, painter: &mut Painter) -> Size; fn draw(&mut self, painter: &mut Painter) -> Size;
fn size_hint(&self, axis: Axis) -> Option<Len> { None } fn size_hint(&self, axis: Axis) -> Option<Len> { None }
fn on_resize(&self, axis: Axis) -> OnResize { OnResize::Redraw }
} }
``` ```
Two methods, not three: `on_resize` was proposed here and shipped, and §3
below replaced it with the `Holds` interval a widget declares while drawing.
A widget returns what it used of the box it was given. A child A widget returns what it used of the box it was given. A child
draw returns a `DrawResult` that keeps the painter borrowed; calling `.size()` draw returns a `DrawResult` that keeps the painter borrowed; calling `.size()`
on that result reads the child's retained size and records that the current on that result reads the child's retained size and records that the current
@@ -48,15 +50,14 @@ widget depends on it. Dropping the result without reading it draws the child
without making the parent's own size depend on the child's. without making the parent's own size depend on the child's.
No `available` parameter: `Painter` already carries the region the parent No `available` parameter: `Painter` already carries the region the parent
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already handed down (`Painter::region()`) and already exposes the pixel-resolved form
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface (`px_size()`) and the output surface size (`output_size()`). Passing it again
size (`output_size()`, `:152`). Passing it again would be the same value would be the same value under a second name. `desired_width`/`desired_height`
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`) and `WidgetAxisFns::desired_len` are deleted outright — not
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
deprecated, not kept as a fallback — because a widget that implements both deprecated, not kept as a fallback — because a widget that implements both
`draw` and `desired_*` for the same thing is exactly the "two names for one `draw` and `desired_*` for the same thing is exactly the "two names for one
concept" the code rules call out, and it is what today's `Span::desired_ortho` concept" the code rules call out, and it is what today's `Span::desired_ortho`
(`iris/src/widget/position/span.rs:98-152`) already complains about in its (as it was then) already complains about in its
own comment: "this literally copies draw so that the lengths are correctly own comment: "this literally copies draw so that the lengths are correctly
set in the context, which makes this slow and not cool." Folding sizing into set in the context, which makes this slow and not cool." Folding sizing into
`draw` deletes that duplicate simulation, not just moves it. `draw` deletes that duplicate simulation, not just moves it.
@@ -67,97 +68,89 @@ lying hint fails a debug assertion when the widget is drawn.
### 2. O(1) subtree movement ### 2. O(1) subtree movement
Every active widget owns a slot in `UiData::move_offsets`. A slot stores an A widget opts into one independently movable region with `.region_node()`, or
absolute-pixel delta and its parent slot; each primitive instance stores the `Widgets::set_region_node` at runtime; `.scrollable()` sets it once as its
slot of the widget that drew it. The vertex shader walks this bounded chain convenient default. A node holds a whole **box** -- a `UiRegion` in its parent
and adds the accumulated translation. Moving a subtree therefore writes one node's coordinates, `UiRegion::FULL` being the identity -- and each primitive
slot instead of rewriting every descendant primitive. instance names the node it was drawn under. Moving a subtree through a node
writes one entry. A widget without the property shares the nearest ancestor's
node, and moving it remaps its retained primitive, mask and active regions
instead, stopping at any descendant node after rewriting that one entry.
The parent chain is required for independently movable nested subtrees, such A box rather than a translation, because a pixel-space offset would scale a
as a swipeable row inside a scrolling list. A flat offset table would require child that has to keep its pixel length; the fraction and the offset in a
rewriting the row whenever an ancestor moved and would restore the very `UiScalar` are what tell the two apart. The parent chain is what makes nested
O(subtree) work this design removes. Chain depth is bounded in both Rust and movable subtrees work -- a swipeable row inside a scrolling list -- and a flat
WGSL. table would rewrite the row whenever an ancestor moved, which is the
`O(subtree)` work this removes. `CHAIN_LIMIT` bounds the walk at 64 in both
Rust (`core/src/ui/mod.rs`) and WGSL, so a malformed cycle resolves the same
way on each side.
`Painter::place` draws a child whose box its parent decides and may decide `Moves::resolve` performs the same walk on the CPU for hit testing,
again, and gives that child a slot of its own; `widget` and `widget_within` do accessibility and window-coordinate queries, and the shader's `resolve_move`
not, and share the nearest ancestor's. A slot carries a whole **box**, not a mirrors it. Coordinates cross as whole counts of `1/1024` px and `1/2^24` of
translation: a pixel-space scale and offset would scale a child that has to a box, which the shader decodes from constants the Rust side prepends: the
keep its pixel length, and the `rel`/`abs` pair is what distinguishes the two. grid is stated once. Masks carry their own node and resolve it independently,
(That slots carry translation only was an agent's choice on 2026-09-04, never so a stationary viewport clips content that moves inside it.
asked for, and #18 replaced it.)
`UiRenderState::resolved_region` performs the same chain walk on the CPU for Nodes follow `ActiveData`'s lifecycle. Removing one retires its entry only
hit-testing, accessibility, and public window-coordinate queries. Masks store after every descendant has migrated, since reusing the index sooner would
the move slot of their owning widget and resolve it independently in the make an old parent look current. Changing the property redraws the subtree
fragment shader, so a stationary viewport can clip moving content. once, to rebuild the coordinate boundary; it belongs to widget identity,
which is safe because a widget has one parent.
Slots follow `ActiveData`'s lifecycle. Removing a widget recursively retires
its slot only after descendants are gone, and a reused arena slot is reset
before new primitives can reference it. `Primitives::set_instance` also
cancels a dirty mark when provisional layout restores the original bytes, so
CPU-only measurement positions are never uploaded.
### 3. Resize scope ### 3. Resize scope
A resize is "the region a widget's parent offers it changes such that the A resize is "the region a widget's parent offers it changes such that the
widget's draw might produce different output" as opposed to a move, which widget's draw might produce different output" -- as opposed to a move, which
by construction cannot (§2 is scoped to pure translation). Two independent by construction cannot. Two independent narrowings apply, and both are
narrowings apply, and both are real, measured properties of the code as it measured properties of the code rather than new machinery:
stands rather than new machinery:
**(a) A window resize does not, by itself, require touching most widgets.** **(a) A window resize does not, by itself, require touching most widgets.**
`shader.wgsl` recomputes every primitive's pixel position from `window.dim` The shader recomputes every primitive's position from `window.dim` and the
and the primitive's stored `rel`/`abs` pair every frame, already, on the GPU. primitive's stored fraction and offset every frame, already, on the GPU. A
A widget laid out purely in `rel`/`abs` terms is therefore already correct widget laid out purely in those terms is therefore correct after a resize
after a resize with zero CPU work. Calls to `Painter::px_size` and with no CPU work at all.
`Painter::output_size` mark both concrete-pixel axes; `px_len(axis)` and
`output_len(axis)` mark only the axis actually read. Only widgets whose read
axes changed by more than 0.05 physical pixels become dirty. The comparison
is against each widget's last actual draw, so smaller changes accumulate
rather than disappearing event by event.
All pixel-dependent leaves are marked before layout begins, along with every What decides the rest is `Holds`, one interval of box lengths per axis:
chain of parents that read their sizes. Resize then settles the shallowest *give this widget any box in here and it draws the same thing and reports the
shared readers first, under the new output, so overlapping dependency paths same size*. A widget that never reads its box in pixels holds for every
are drawn once. Ordinary content changes use the opposite order: deepest length. Reading `Painter::px_len(axis)` or `px_size()` narrows the interval
dirty widgets first, with a changed returned size propagated one reader edge to the length read, and `Painter::holds` is how a widget widens it again by
at a time. Re-reporting the current output size is a no-op. saying what its drawing actually depends on -- a greedy line break holds from
its longest line up to the width it was made at. A parent holds for whatever
keeps every child it asked about or drew inside its own range, each child's
interval translated into lengths of the parent's box.
**(b) A widget's `available` (its parent's offered region) can change This replaced `Widget::on_resize` and its `Scale`/`Redraw`/`Translate`
without the widget's *content* changing — this is what `Widget::on_resize` answers, which said the same thing per widget type and could not say *how
answers, per axis.** When a container's own layout shifts (a sibling grew or far*. There is no per-widget resize mode now: a widget that reads nothing is
shrank, changing this widget's offered box), a widget that says `Scale` on the never redrawn for a resize, one that reads its width is redrawn when its
axes that changed is not redrawn: everything it drew is a fraction of its own width leaves the interval it declared, and the interval is the whole of the
slot's box, so writing that one box moves and stretches all of it. `Span`, statement. Do not restore `Translate`; a retained subtree that only moves is
`Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` say `Scale`; remapped through the box chain of §2, exactly.
`Scroll` and `MaxSize` read their box in pixels and cannot. `Redraw`, the
default, is correct always and free never. `Translate` — an unchanged drawing
placed somewhere else in a bigger box — is reserved: nothing reads it until a
widget can say where in that box its drawing belongs, which is the alignment
work.
**Size invalidation travels upward before drawing; drawing itself travels only Lengths are whole counts of `1/1024` px, so "the box changed" is equality
downward.** Every active widget retains the direct children whose size it read rather than a tolerance: a change too small to reach the next step is not a
through `DrawResult::size()` or `Painter::known_len`. `redraw_updates` takes change, and one that reaches it is, however little of a pixel it is worth.
one id from the dirty set, follows only those dependency edges upward and marks
that path dirty, then redraws its highest already-dirty ancestor. Drawing that
ancestor consumes the marks of every dirty descendant it reaches; the loop
then takes whatever remains. Drawing never synchronously invalidates or invokes
a parent, so there is no layout recursion and no provisional child draw on a
different layer.
An exact `size_hint` stops propagation when both axes still equal the retained **(b) Size invalidation travels upward before drawing; drawing itself travels
size. Otherwise propagation is deliberately conservative: the child may have only downward.** Every active widget retains the direct children whose size it
changed size, and only its dependent ancestors can assign the final boxes. read through `DrawResult::size()` or `Painter::known_len`. `redraw_updates`
Unchanged descendants still take `draw_inner`'s retained skip-or-move path. takes one id from the dirty set, follows only those dependency edges upward
An active widget also retains which offered-box and output axes flowed into and marks that path dirty, then redraws its highest already-dirty ancestor.
the size it reported, directly or through a child size it read. A container Drawing that ancestor consumes the marks of every dirty descendant it
may use that answer for the same prospective box when every observed input is reaches; the loop then takes whatever remains. Drawing never synchronously
still within 0.05 physical pixels; content dirtiness anywhere in its size invalidates or invokes a parent, so there is no layout recursion.
dependency subtree rejects the answer. This is a generic constraint rule, not
a text exception. Wrapped text is merely the common example: it reads width, Dirty widgets settle deepest-first, and `dirty_size_under` stops a reader
so changing only height leaves its answer valid. taking a retained answer while something below that answer is still dirty --
an optimisation against laying out twice rather than a second validity
mechanism. An exact `size_hint` stops propagation when both axes still equal
the retained size; otherwise propagation is deliberately conservative, since
only a dependent ancestor can assign the final boxes. This is a generic
constraint rule, not a text exception. Wrapped text is merely the common
example: it reads width, so changing only height leaves its answer valid.
### 4. Wrapped text, and "needs child height before choosing width" ### 4. Wrapped text, and "needs child height before choosing width"