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:
1 parent
617331f913
commit
2d860587a4
1 file changed
+82
-89
+82
-89
@@ -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
|
||||
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
|
||||
brought to what shipped rather than what was proposed; §3 to §6 describe the
|
||||
same design as it stands. `docs/HANDOFF.md` has the invariants
|
||||
§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; §4 to §6 describe the
|
||||
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.
|
||||
|
||||
## Design
|
||||
@@ -36,11 +37,12 @@ pub trait Widget: Any {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size;
|
||||
|
||||
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
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
No `available` parameter: `Painter` already carries the region the parent
|
||||
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already
|
||||
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface
|
||||
size (`output_size()`, `:152`). Passing it again would be the same value
|
||||
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`)
|
||||
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
|
||||
handed down (`Painter::region()`) and already exposes the pixel-resolved form
|
||||
(`px_size()`) and the output surface size (`output_size()`). Passing it again
|
||||
would be the same value under a second name. `desired_width`/`desired_height`
|
||||
and `WidgetAxisFns::desired_len` are deleted outright — not
|
||||
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
|
||||
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
|
||||
set in the context, which makes this slow and not cool." Folding sizing into
|
||||
`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
|
||||
|
||||
Every active widget owns a slot in `UiData::move_offsets`. A slot stores an
|
||||
absolute-pixel delta and its parent slot; each primitive instance stores the
|
||||
slot of the widget that drew it. The vertex shader walks this bounded chain
|
||||
and adds the accumulated translation. Moving a subtree therefore writes one
|
||||
slot instead of rewriting every descendant primitive.
|
||||
A widget opts into one independently movable region with `.region_node()`, or
|
||||
`Widgets::set_region_node` at runtime; `.scrollable()` sets it once as its
|
||||
convenient default. A node holds a whole **box** -- a `UiRegion` in its parent
|
||||
node's coordinates, `UiRegion::FULL` being the identity -- and each 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
|
||||
as a swipeable row inside a scrolling list. A flat offset table would require
|
||||
rewriting the row whenever an ancestor moved and would restore the very
|
||||
O(subtree) work this design removes. Chain depth is bounded in both Rust and
|
||||
WGSL.
|
||||
A box rather than a translation, because a pixel-space offset would scale a
|
||||
child that has to keep its pixel length; the fraction and the offset in a
|
||||
`UiScalar` are what tell the two apart. The parent chain is what makes nested
|
||||
movable subtrees work -- a swipeable row inside a scrolling list -- and a flat
|
||||
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
|
||||
again, and gives that child a slot of its own; `widget` and `widget_within` do
|
||||
not, and share the nearest ancestor's. A slot carries a whole **box**, not a
|
||||
translation: a pixel-space scale and offset would scale a child that has to
|
||||
keep its pixel length, and the `rel`/`abs` pair is what distinguishes the two.
|
||||
(That slots carry translation only was an agent's choice on 2026-09-04, never
|
||||
asked for, and #18 replaced it.)
|
||||
`Moves::resolve` performs the same walk on the CPU for hit testing,
|
||||
accessibility and window-coordinate queries, and the shader's `resolve_move`
|
||||
mirrors it. Coordinates cross as whole counts of `1/1024` px and `1/2^24` of
|
||||
a box, which the shader decodes from constants the Rust side prepends: the
|
||||
grid is stated once. Masks carry their own node and resolve it independently,
|
||||
so a stationary viewport clips content that moves inside it.
|
||||
|
||||
`UiRenderState::resolved_region` performs the same chain walk on the CPU for
|
||||
hit-testing, accessibility, and public window-coordinate queries. Masks store
|
||||
the move slot of their owning widget and resolve it independently in the
|
||||
fragment shader, so a stationary viewport can clip moving content.
|
||||
|
||||
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.
|
||||
Nodes follow `ActiveData`'s lifecycle. Removing one retires its entry only
|
||||
after every descendant has migrated, since reusing the index sooner would
|
||||
make an old parent look current. Changing the property redraws the subtree
|
||||
once, to rebuild the coordinate boundary; it belongs to widget identity,
|
||||
which is safe because a widget has one parent.
|
||||
|
||||
### 3. Resize scope
|
||||
|
||||
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
|
||||
by construction cannot (§2 is scoped to pure translation). Two independent
|
||||
narrowings apply, and both are real, measured properties of the code as it
|
||||
stands rather than new machinery:
|
||||
widget's draw might produce different output" -- as opposed to a move, which
|
||||
by construction cannot. Two independent narrowings apply, and both are
|
||||
measured properties of the code rather than new machinery:
|
||||
|
||||
**(a) A window resize does not, by itself, require touching most widgets.**
|
||||
`shader.wgsl` recomputes every primitive's pixel position from `window.dim`
|
||||
and the primitive's stored `rel`/`abs` pair every frame, already, on the GPU.
|
||||
A widget laid out purely in `rel`/`abs` terms is therefore already correct
|
||||
after a resize with zero CPU work. Calls to `Painter::px_size` and
|
||||
`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.
|
||||
The shader recomputes every primitive's position from `window.dim` and the
|
||||
primitive's stored fraction and offset every frame, already, on the GPU. A
|
||||
widget laid out purely in those terms is therefore correct after a resize
|
||||
with no CPU work at all.
|
||||
|
||||
All pixel-dependent leaves are marked before layout begins, along with every
|
||||
chain of parents that read their sizes. Resize then settles the shallowest
|
||||
shared readers first, under the new output, so overlapping dependency paths
|
||||
are drawn once. Ordinary content changes use the opposite order: deepest
|
||||
dirty widgets first, with a changed returned size propagated one reader edge
|
||||
at a time. Re-reporting the current output size is a no-op.
|
||||
What decides the rest is `Holds`, one interval of box lengths per axis:
|
||||
*give this widget any box in here and it draws the same thing and reports the
|
||||
same size*. A widget that never reads its box in pixels holds for every
|
||||
length. Reading `Painter::px_len(axis)` or `px_size()` narrows the interval
|
||||
to the length read, and `Painter::holds` is how a widget widens it again by
|
||||
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
|
||||
without the widget's *content* changing — this is what `Widget::on_resize`
|
||||
answers, per axis.** When a container's own layout shifts (a sibling grew or
|
||||
shrank, changing this widget's offered box), a widget that says `Scale` on the
|
||||
axes that changed is not redrawn: everything it drew is a fraction of its own
|
||||
slot's box, so writing that one box moves and stretches all of it. `Span`,
|
||||
`Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` say `Scale`;
|
||||
`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.
|
||||
This replaced `Widget::on_resize` and its `Scale`/`Redraw`/`Translate`
|
||||
answers, which said the same thing per widget type and could not say *how
|
||||
far*. There is no per-widget resize mode now: a widget that reads nothing is
|
||||
never redrawn for a resize, one that reads its width is redrawn when its
|
||||
width leaves the interval it declared, and the interval is the whole of the
|
||||
statement. Do not restore `Translate`; a retained subtree that only moves is
|
||||
remapped through the box chain of §2, exactly.
|
||||
|
||||
**Size invalidation travels upward before drawing; drawing itself travels only
|
||||
downward.** Every active widget retains the direct children whose size it read
|
||||
through `DrawResult::size()` or `Painter::known_len`. `redraw_updates` takes
|
||||
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.
|
||||
Lengths are whole counts of `1/1024` px, so "the box changed" is equality
|
||||
rather than a tolerance: a change too small to reach the next step is not a
|
||||
change, and one that reaches it is, however little of a pixel it is worth.
|
||||
|
||||
An exact `size_hint` stops propagation when both axes still equal the retained
|
||||
size. Otherwise propagation is deliberately conservative: the child may have
|
||||
changed size, and only its dependent ancestors can assign the final boxes.
|
||||
Unchanged descendants still take `draw_inner`'s retained skip-or-move path.
|
||||
An active widget also retains which offered-box and output axes flowed into
|
||||
the size it reported, directly or through a child size it read. A container
|
||||
may use that answer for the same prospective box when every observed input is
|
||||
still within 0.05 physical pixels; content dirtiness anywhere in its size
|
||||
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,
|
||||
so changing only height leaves its answer valid.
|
||||
**(b) Size invalidation travels upward before drawing; drawing itself travels
|
||||
only downward.** Every active widget retains the direct children whose size it
|
||||
read through `DrawResult::size()` or `Painter::known_len`. `redraw_updates`
|
||||
takes 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.
|
||||
|
||||
Dirty widgets settle deepest-first, and `dirty_size_under` stops a reader
|
||||
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"
|
||||
|
||||
|
||||
Reference in new issue
Block a user