LAYOUT.md: single-draw design with an O(1) move chain; IRIS.md for notable API changes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
d194d73439
commit
1c937e2f48
2 files changed
+623
-10
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
# iris: notable public API changes
|
||||||
|
|
||||||
|
For Iris to read on her own time. Each entry is a change to iris's public
|
||||||
|
surface that a widget author or app author would notice: a trait method
|
||||||
|
added, removed or re-shaped; a type that callers construct differently; a
|
||||||
|
capability that moved. Small and trivial changes do not go here.
|
||||||
|
|
||||||
|
An entry gives the date, what changed, why, and a short before/after where
|
||||||
|
it helps judge the change without the session that made it. Newest first.
|
||||||
|
|
||||||
|
_No entries yet._
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
# iris: one `draw` that reports a size
|
# iris: one `draw` that reports a size
|
||||||
|
|
||||||
Preference stated by Iris, 2026-09-04, on the `rustify` branch. Recorded before
|
Preference stated by Iris, 2026-09-04, on the `rustify` branch. Recorded before
|
||||||
any design or code so that it survives a cleared session. **Status: a
|
any design or code so that it survives a cleared session. **Status: design
|
||||||
requirement with a design to be written below it. Nothing implemented.**
|
written 2026-09-04, on top of TEXTURES.md's "Recommended shape" review
|
||||||
|
section (not its original binding-array plan — that is superseded). Nothing
|
||||||
|
implemented yet.** Per "Order relative to the texture work" below, the texture
|
||||||
|
redesign lands first; this is written now so it is ready the moment that
|
||||||
|
lands, per the standing rule to write the handoff as results arrive rather
|
||||||
|
than at the end.
|
||||||
|
|
||||||
## What Iris asked for
|
## What Iris asked for
|
||||||
|
|
||||||
@@ -85,12 +90,609 @@ shader, so they are done **in sequence, textures first**, and the layout
|
|||||||
design here is written (not implemented) while the texture work is in
|
design here is written (not implemented) while the texture work is in
|
||||||
progress, then implemented on top of it.
|
progress, then implemented on top of it.
|
||||||
|
|
||||||
## Design (to be written by the agent that takes this)
|
## Design
|
||||||
|
|
||||||
Not yet written. When it is, it replaces this heading and records: the
|
### 1. The new `Widget` trait
|
||||||
new `Widget` trait, how a move is expressed and what it costs, how a
|
|
||||||
resize is scoped, how the result is cached and invalidated, what was
|
```rust
|
||||||
rejected and why, and the pass conditions the implementation is checked
|
pub trait Widget: Any {
|
||||||
against (the existing examples under `iris/examples` still render the
|
/// Draw within `painter.region()` (the space the parent offered) and
|
||||||
same, and the per-frame work for an unchanged tree is measured, not
|
/// report how much of it was actually used, per axis.
|
||||||
assumed).
|
fn draw(&mut self, painter: &mut Painter) -> Size;
|
||||||
|
|
||||||
|
/// True if `draw`'s output (both the primitives it writes and the
|
||||||
|
/// `Size` it returns) is the same for any `painter.region()` of the
|
||||||
|
/// same *content* -- an icon, a fixed-size rect, an already-decoded
|
||||||
|
/// image at its natural size. Default `false` (redraw on any change to
|
||||||
|
/// the offered region) because assuming independence wrongly produces
|
||||||
|
/// a stale draw; a widget must opt in.
|
||||||
|
fn is_size_independent(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
|
||||||
|
**No single-draw alternative was found that does less work per frame.** The
|
||||||
|
two-method trait was checked against three properties a real screen needs —
|
||||||
|
a row placing children in sequence, a widget centering on its own content,
|
||||||
|
and wrapped text — and in every one, `draw` already has to visit the child
|
||||||
|
to get a size that is *this specific one's* answer, which today's
|
||||||
|
`desired_width`/`desired_height` re-derive by re-running (a shrunk copy of)
|
||||||
|
the same layout the draw pass will do again. So the two-method trait is not
|
||||||
|
"measure once, draw once" in the general case; it is "measure once per axis,
|
||||||
|
then draw once," i.e. up to three visits per widget per frame, against one
|
||||||
|
under the design here. The single-draw model is therefore adopted as
|
||||||
|
proposed, not merely accepted as a preference.
|
||||||
|
|
||||||
|
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
|
||||||
|
|
||||||
|
**What exists today, and why it is not O(1).** `UiRenderState::mov`
|
||||||
|
(`core/src/ui/render_state.rs:156-168`) fires when a widget's region keeps
|
||||||
|
its *size* but changes *position* (`draw_inner`, `:85-100`:
|
||||||
|
`active.region.size() == region.size()` after excluding the exact-match
|
||||||
|
case). It rewrites every primitive's `region` field via
|
||||||
|
`Primitives::region_mut` (`core/src/render/primitive.rs:176-179`) for the
|
||||||
|
widget's own primitives, then recurses into every child — O(primitives in
|
||||||
|
the subtree). Both call sites that trigger it today, `Scroll::draw`
|
||||||
|
(`iris/src/widget/position/scroll.rs:29-31`) and `Offset::draw`
|
||||||
|
(`iris/src/widget/position/offset.rs:9-11`), are "translate this subtree by
|
||||||
|
an abs pixel amount, `rel` framing unchanged" — a transcript scroll
|
||||||
|
re-touches every glyph in every visible row, every frame of the drag, and
|
||||||
|
I3's target is 800 rows on screen.
|
||||||
|
|
||||||
|
**Recommendation: a per-widget offset slot forming a parent-linked chain,
|
||||||
|
resolved in the vertex shader.**
|
||||||
|
|
||||||
|
- `UiData` (`core/src/ui/mod.rs:14-20`) gains
|
||||||
|
`pub move_offsets: TrackedArena<MoveOffset, u32>`, the same arena shape
|
||||||
|
already used for `masks: TrackedArena<Mask, u32>` on the line above it.
|
||||||
|
- `render/data.rs` gains `pub struct MoveOffset { pub delta: [f32; 2], pub
|
||||||
|
parent: u32 }` (`Pod`/`Zeroable`, `parent = u32::MAX` = "no ancestor,
|
||||||
|
add nothing more"). A pure abs-pixel translation, not a general
|
||||||
|
`UiRegion` remap — sufficient for every existing call site (above).
|
||||||
|
- `PrimitiveInstance` (`render/data.rs:11-18`) gains `pub move_idx: u32`,
|
||||||
|
a vertex attribute at `@location(7)` beside `mask_idx` at `6` — the same
|
||||||
|
kind of per-instance handle.
|
||||||
|
- `ActiveData` (`core/src/ui/active.rs`) gains `pub move_slot: MoveIdx`,
|
||||||
|
assigned **when the widget is first drawn** (`draw_inner`, beside
|
||||||
|
`active.insert`), with `parent` = the drawing widget's parent's slot.
|
||||||
|
`Painter` threads a `move_slot` field down exactly as it already threads
|
||||||
|
`mask` and `layer` (`painter.rs:9-20`), so a freshly-drawn descendant is
|
||||||
|
correct from its first frame — nothing is ever retrofitted onto an
|
||||||
|
already-active primitive. An unmoved widget's slot just stays `[0, 0]`.
|
||||||
|
- `Painter::primitive_at` (`painter.rs:23-38`) writes `move_idx:
|
||||||
|
self.move_slot`, matching how it already writes `mask_idx: self.mask`.
|
||||||
|
- `mov(id, delta)` becomes: look up `id`'s slot, write
|
||||||
|
`move_offsets[slot].delta += delta`. One write — no primitive touched, no
|
||||||
|
recursion, since descendants already reference this slot transitively.
|
||||||
|
- `shader.wgsl`'s vertex stage, after computing `top_left`/`bot_right` in
|
||||||
|
pixels (after `:106`, before the clip-space divide at `:113`), walks
|
||||||
|
`move_idx → move_offsets[i].parent` for a bounded number of steps (a
|
||||||
|
small constant, e.g. 16, with a CPU-side debug assertion that no chain
|
||||||
|
exceeds it), summing `delta` into both corners. Cost is O(chain depth),
|
||||||
|
paid every frame regardless of whether anything moved — negligible next
|
||||||
|
to the per-fragment texture sampling TEXTURES.md already measures this
|
||||||
|
GPU as not bound by.
|
||||||
|
|
||||||
|
**Why the chain, not the flatter thing first proposed.** Iris's own
|
||||||
|
phrasing — "every instance carries an index into a small per-widget offset
|
||||||
|
buffer" — describes a flat table: one slot per subtree *declared* movable,
|
||||||
|
no parent link. It breaks the moment two such subtrees nest — a row inside
|
||||||
|
a scrolling list, itself later given its own animated offset (a
|
||||||
|
swipe-to-delete mid-scroll) — because the row's primitives would have to
|
||||||
|
pick one slot and lose the other's contribution. The chain costs one extra
|
||||||
|
field and a bounded shader loop in exchange for no such gap, and since
|
||||||
|
every `ActiveData` gets a slot unconditionally rather than lazily, it costs
|
||||||
|
no more at the common depth of one than the flat version would.
|
||||||
|
|
||||||
|
**Against `region_mut` as the steady-state mechanism**: rejected for being
|
||||||
|
O(primitives in the subtree) — the cost this section removes — but kept
|
||||||
|
for a resize that changes a region's `rel` component (a genuine reflow,
|
||||||
|
§3) and for a size-independent widget's resize (§3), where the content's
|
||||||
|
shape doesn't change and one field write already suffices.
|
||||||
|
|
||||||
|
### 2b. Two more readers of "where is this widget," and masks
|
||||||
|
|
||||||
|
Moving the offset into the vertex shader means `ActiveData.region` is no
|
||||||
|
longer the on-screen truth once a widget has been moved — it is where the
|
||||||
|
widget was *drawn*, before any `move_offsets` delta. Two things read it as
|
||||||
|
if it still were, and both must move to a resolved query or they silently
|
||||||
|
answer with the pre-move position: a click landing on a scrolled row would
|
||||||
|
be routed to whatever used to be there, with nothing on screen to say so —
|
||||||
|
exactly the "wrong answer that looks like a right one" case the code rules
|
||||||
|
single out.
|
||||||
|
|
||||||
|
**Hit-testing.** `SensorUi::run_sensors` (`src/default/sense.rs:154-200`)
|
||||||
|
does the actual pointer routing, and line 170 is the read in question:
|
||||||
|
`let shape = self.active.get(id).unwrap().region;` (`self: &UiRenderState`),
|
||||||
|
immediately turned into pixels and tested against the cursor at `:171-172`.
|
||||||
|
Under this design that region must be resolved through the same chain the
|
||||||
|
GPU walks before it means anything. Add to `UiRenderState`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// `active[id].region`, corrected by every `move_offsets` delta between
|
||||||
|
/// `id` and the root — the CPU-side twin of the vertex shader's chain
|
||||||
|
/// walk, over the same arena, so the two cannot disagree about where a
|
||||||
|
/// widget is. O(chain depth), not O(primitives): a plain Rust loop over
|
||||||
|
/// `move_offsets`, bounded by the same constant the shader loop uses
|
||||||
|
/// (name it once, e.g. `render::MOVE_CHAIN_LIMIT`, and reference it from
|
||||||
|
/// the WGSL loop bound in a comment, since WGSL cannot `include!` a Rust
|
||||||
|
/// const across the language boundary).
|
||||||
|
pub fn resolved_region(&self, id: WidgetId) -> UiRegion;
|
||||||
|
```
|
||||||
|
|
||||||
|
`window_region` (`core/src/ui/render_state.rs:264-267`), the public
|
||||||
|
coordinate query already used outside hit-testing
|
||||||
|
(`src/default/attr.rs:15,17,70`, e.g. positioning one widget relative to
|
||||||
|
another's on-screen box), is reimplemented to call `resolved_region(id)`
|
||||||
|
before `.to_px(...)` instead of reading `.region` directly — one change
|
||||||
|
covers both call sites listed there. `sense.rs:170` changes to
|
||||||
|
`let shape = self.resolved_region(*id);`. Both are required the moment §2
|
||||||
|
lands, not an optional follow-up: an unmoved widget's chain is empty and
|
||||||
|
`resolved_region` costs one arena read to find that out, so there is no
|
||||||
|
version of this design where skipping the fix is a legitimate
|
||||||
|
optimization — it is a correctness gap, not a performance one.
|
||||||
|
|
||||||
|
**Masks.** `Painter::set_mask` (`core/src/ui/painter.rs:49-52`) bakes the
|
||||||
|
painter's *current* region into a `Mask` pushed onto
|
||||||
|
`masks: TrackedArena<Mask, u32>` (`core/src/ui/mod.rs:19`), and the
|
||||||
|
fragment shader clips every primitive against `masks[in.mask_idx]`'s raw
|
||||||
|
`rel`/`abs` fields, unaffected by any move (`shader.wgsl:147-157`). If the
|
||||||
|
widget that called `set_mask` — `Masked::draw`,
|
||||||
|
`iris/src/widget/mask.rs:7-11`, `painter.set_mask(painter.region()); ...` —
|
||||||
|
is itself later moved, its clip rectangle stays where it was drawn while
|
||||||
|
its content moves out from under it: a visibly wrong clip, immediately on
|
||||||
|
screen, not a latency question.
|
||||||
|
|
||||||
|
Fix: `Mask` (`core/src/render/data.rs:46-49`) gains `pub move_idx: u32`,
|
||||||
|
written from `Painter::set_mask` as `self.move_slot` — the identical slot
|
||||||
|
the mask-owning widget's own primitives already get (§2), not a second
|
||||||
|
mechanism. Resolution happens in the **fragment** shader, not the CPU, and
|
||||||
|
not the vertex shader either: `shader.wgsl`'s mask check (`:147-157`)
|
||||||
|
currently computes the mask's `top_left`/`bot_right` inline from
|
||||||
|
`masks[in.mask_idx]`; that computation is extended to walk the same
|
||||||
|
move-offset chain §2 added, via one shared function —
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
fn resolve_move(idx: u32) -> vec2<f32> { /* the bounded parent walk, used by both stages */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
— called from `vs_main` for a primitive's own corners and from `fs_main`
|
||||||
|
for its mask's corners, so the walk is written once and the two stages
|
||||||
|
cannot drift apart (the sibling-rule from the code rules: one loop, not a
|
||||||
|
hand-copied second one in the other shader stage).
|
||||||
|
|
||||||
|
**Why the fragment shader, not a CPU-side mask rewrite at move time.** A
|
||||||
|
primitive's mask is frequently owned by a *different* widget than the
|
||||||
|
primitive itself — often several levels up a subtree, with its own,
|
||||||
|
independent move slot — so a primitive's resolved offset and its mask's
|
||||||
|
resolved offset are two different chain sums, both needed, and only the
|
||||||
|
fragment shader has both `in.move_idx` (this fragment's own chain) and
|
||||||
|
`in.mask_idx` (indirecting to a second, possibly unrelated chain) already
|
||||||
|
in hand per-fragment. Resolving mask regions on the CPU at move time would
|
||||||
|
mean, for every `mov()` call, walking forward to every mask instance the
|
||||||
|
moved widget's slot could affect and rewriting its raw region — exactly
|
||||||
|
the O(subtree) cost §2 exists to remove, just moved from primitives to
|
||||||
|
masks. The fragment shader already re-reads `masks[in.mask_idx]` every
|
||||||
|
frame (`:148`); one more arena read to resolve its chain costs nothing
|
||||||
|
extra in kind.
|
||||||
|
|
||||||
|
**The scroll-container case, checked rather than assumed.** A masked,
|
||||||
|
scrollable region is built as a `Masked` wrapping a `Scroll`
|
||||||
|
(`iris/src/widget/position/scroll.rs`, `iris/src/widget/mask.rs`) — the
|
||||||
|
viewport border is drawn (and `set_mask` called) by `Masked`, which is
|
||||||
|
never itself the target of `mov()`; only `Scroll`'s inner content is,
|
||||||
|
every frame the user drags. Because each widget's move slot is its own
|
||||||
|
(§2: assigned per `ActiveData`, not shared), `Masked`'s mask references
|
||||||
|
its own, stationary slot, while the scrolled content underneath references
|
||||||
|
a separate, deeper slot whose `parent` chain passes through — but does not
|
||||||
|
write to — the viewport's slot. Moving the content therefore never touches
|
||||||
|
the mask's resolved position, and the mask staying still while its content
|
||||||
|
slides past it is what this design already produces with no special case,
|
||||||
|
not an extra rule that had to be added for it.
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
|
||||||
|
**(a) A window resize does not, by itself, require touching most widgets.**
|
||||||
|
`shader.wgsl:105-106` 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 (no
|
||||||
|
call to `px_size()`, `output_size()`, or anything else that reads a
|
||||||
|
concrete pixel count) is therefore already correct after a resize with zero
|
||||||
|
CPU work — the shader did it. `UiRenderState::needs_redraw_all`
|
||||||
|
(`render_state.rs:229-231`) currently ignores this and redraws the entire
|
||||||
|
tree on every `resized`, which was the safe default while sizing and
|
||||||
|
drawing were two passes; it should be narrowed to only the widgets that
|
||||||
|
*do* read a concrete pixel value. Track this the same way `needs_redraw`
|
||||||
|
already tracks per-widget dirtiness (`Widgets::needs_redraw`,
|
||||||
|
`core/src/widget/widgets.rs:9`): a widget's `draw` call marks itself
|
||||||
|
pixel-dependent by calling through `Painter` methods that read
|
||||||
|
`output_size`/`px_size` (both already funnel through `Painter`, so the
|
||||||
|
marking is one line at each), and `resize()` (`render_state.rs:32-35`)
|
||||||
|
walks only that set instead of unconditionally setting `resized = true`
|
||||||
|
for a full `redraw_all`. This turns "every resize redraws everything" into
|
||||||
|
"every resize redraws what depends on pixels" — a real behavior change
|
||||||
|
beyond what was asked, so verify it against the I0b `pre_present_notify`
|
||||||
|
resize regression (that fix depended on `redraw_all`'s completeness)
|
||||||
|
before narrowing this.
|
||||||
|
|
||||||
|
**(b) A widget's `available` (its parent's offered region) can change
|
||||||
|
without the widget's *content* changing — this is what
|
||||||
|
`is_size_independent` (§1) answers.** When a container's own layout shifts
|
||||||
|
(a sibling grew or shrank, changing this widget's offered box), a widget
|
||||||
|
that returns `true` from `is_size_independent` is not redrawn: its
|
||||||
|
primitives are unaffected by size, only by placement, so the parent
|
||||||
|
either (i) issues a move (§2) if only position changed, or (ii) rewrites
|
||||||
|
the primitive's `region` fields directly via `region_mut` if the box
|
||||||
|
changed shape too (still O(primitives owned directly by this widget, not
|
||||||
|
its subtree, since a size-independent widget by definition has no
|
||||||
|
size-dependent descendants worth distinguishing — in practice this is
|
||||||
|
always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns
|
||||||
|
`false` (the default) is redrawn in full whenever `available` changes,
|
||||||
|
which is correct always, just not free.
|
||||||
|
|
||||||
|
**Ancestor propagation** (a resized child changing its own reported size,
|
||||||
|
requiring its parent to re-lay-out) is unchanged in spirit from today's
|
||||||
|
`redraw` (`render_state.rs:270-305`), which already walks up exactly the
|
||||||
|
ancestors whose cached size differs from the new one and stops as soon as
|
||||||
|
a size is unchanged (`:274-286`). That loop moves from consulting
|
||||||
|
`Cache.size` to consulting `ActiveData.size` (§5) but keeps its shape.
|
||||||
|
|
||||||
|
### 4. Wrapped text, and "needs child height before choosing width"
|
||||||
|
|
||||||
|
**Wrapped text is not a special case any more; it already reads as one
|
||||||
|
draw.** `TextView::render` (`iris/src/widget/text/mod.rs:57-76`) already
|
||||||
|
does exactly what single-draw asks for: it reads `ctx.px_size().x` as the
|
||||||
|
wrap width, shapes once, and memoizes the shaped layout keyed on that width
|
||||||
|
plus a changed-flag on the buffer and attrs (`:63-69`) — a second call with
|
||||||
|
the same width is a hash-map-style cache hit, not a re-shape. Under the new
|
||||||
|
trait this collapses `Text::draw`/`desired_width`/`desired_height`
|
||||||
|
(`text/mod.rs:133-147`, three functions) into one `Text::draw` that calls
|
||||||
|
`self.view.draw(painter)` once, which internally still calls `render`
|
||||||
|
once, hits its own cache, and returns the size it already computed. No
|
||||||
|
new caching is needed here; the two now-redundant call sites
|
||||||
|
(`desired_width`/`desired_height` each separately calling `render`) simply
|
||||||
|
disappear, which is a second `render` avoided per frame per text widget
|
||||||
|
that is being measured by a parent.
|
||||||
|
|
||||||
|
**"Parent wants the child's height before deciding the width it will
|
||||||
|
offer"** — the genuinely circular case named in the brief, e.g. a column
|
||||||
|
that sizes its own width to its widest child, where that child is wrapped
|
||||||
|
text whose height (which the column's *own* height depends on) depends on
|
||||||
|
the width the column has not yet decided. This is not solvable in one pass
|
||||||
|
for the same reason it is not solvable in CSS shrink-to-fit with wrapped
|
||||||
|
content: the two axes' answers are mutually dependent. `Span::desired_ortho`
|
||||||
|
(`span.rs:98-136`) already hits exactly this today and already resolves it
|
||||||
|
by an explicit second, throwaway pass (its own comment: "this literally
|
||||||
|
copies draw ... which makes this slow and not cool"). The design keeps that
|
||||||
|
resolution, made explicit rather than accidental: `Painter` gets
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Draw `child` at a provisional region to learn its size under one
|
||||||
|
/// axis's worth of assumption, discard everything it wrote, then draw it
|
||||||
|
/// again at the region that assumption produced. For the rare parent that
|
||||||
|
/// cannot pick an offered size without already knowing the answer.
|
||||||
|
/// Twice the cost of one `draw`; every other case in this file avoids it.
|
||||||
|
pub fn draw_twice(&mut self, child: &StrongWidget, first: UiRegion, second: impl FnOnce(Size) -> UiRegion) -> Size;
|
||||||
|
```
|
||||||
|
|
||||||
|
implemented as: draw at `first`, record `Size`, remove the widget and its
|
||||||
|
subtree the same way a resize-triggered redraw already does (`draw_inner`'s
|
||||||
|
"if not \[same region\], maintain resize and track old children," `:97-100`,
|
||||||
|
which already frees the old primitives before redrawing) — reusing that
|
||||||
|
path rather than adding a second one — draw again at `second(size)`, return
|
||||||
|
the final `Size`. It is opt-in and named for its cost, so a widget only
|
||||||
|
pays it if it is the one that needs it; `Span`'s cross-axis case is the one
|
||||||
|
call site converted to it, replacing the hand-rolled duplicate loop.
|
||||||
|
|
||||||
|
### 5. Caching and invalidation
|
||||||
|
|
||||||
|
`Cache.size` (`core/src/ui/cache.rs`) is **deleted, not replaced with an
|
||||||
|
equivalent** — the thing it memoized (a `desired_width`/`desired_height`
|
||||||
|
answer, independent of drawing) no longer exists as a separate query, so
|
||||||
|
there is nothing left to cache at that layer. What already provides "an
|
||||||
|
unchanged subtree costs nothing" is the check `draw_inner` performs before
|
||||||
|
touching a widget at all (`render_state.rs:85-90`): if the widget is active,
|
||||||
|
its region is unchanged, and it is not marked dirty, `draw_inner` returns
|
||||||
|
immediately — no `Painter` constructed, no primitive touched, no shader
|
||||||
|
work beyond what the GPU already redraws from the unchanged instance
|
||||||
|
buffer. That check is kept exactly as it is; it is the caching mechanism,
|
||||||
|
and it already operates at (id, region) granularity, which subsumes "(id,
|
||||||
|
available size)" once size *is* what a region change means.
|
||||||
|
|
||||||
|
What is added: `ActiveData` gains `pub size: Size` — the value `draw`
|
||||||
|
returned, stored the moment it is (`draw_inner`, alongside building the
|
||||||
|
`ActiveData` struct at `:134-143`). This is what a parent placing this
|
||||||
|
widget for a second frame without redrawing it (because nothing changed)
|
||||||
|
reads instead of recomputing — it replaces `Cache.size`'s role of "answer a
|
||||||
|
size question without a full draw" with "read the size of the last actual
|
||||||
|
draw," which is always available because `draw_inner`'s skip path is only
|
||||||
|
reachable once the widget has been drawn at least once. `Cache::remove`/
|
||||||
|
`Cache::clear` (`cache.rs:9-17`) are deleted with the type; `ActiveData`
|
||||||
|
already has an equivalent lifecycle (removed in `remove`/`remove_rec`,
|
||||||
|
`render_state.rs:171-198`, freed with the widget).
|
||||||
|
|
||||||
|
### 6. Before / after
|
||||||
|
|
||||||
|
**A leaf, `iris/src/widget/rect.rs`** — the size-independent case:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before
|
||||||
|
impl Widget for Rect {
|
||||||
|
fn draw(&mut self, painter: &mut Painter) {
|
||||||
|
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
||||||
|
thickness: self.thickness, inner_radius: self.inner_radius });
|
||||||
|
}
|
||||||
|
fn desired_width(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
||||||
|
fn desired_height(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// after
|
||||||
|
impl Widget for Rect {
|
||||||
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
|
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
||||||
|
thickness: self.thickness, inner_radius: self.inner_radius });
|
||||||
|
Size::REST // fills whatever it was given -- used == available
|
||||||
|
}
|
||||||
|
fn is_size_independent(&self) -> bool { true } // content never depends on region size
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**A container that needs the child's size before placing it,
|
||||||
|
`iris/src/widget/position/align.rs`**:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before
|
||||||
|
impl Widget for Aligned {
|
||||||
|
fn draw(&mut self, painter: &mut Painter) {
|
||||||
|
let region = match self.align.tuple() {
|
||||||
|
(Some(x), Some(y)) => painter.size(&self.inner).to_uivec2().align(RegionAlign { x, y }),
|
||||||
|
(Some(x), None) => { let x = painter.size_ctx().width(&self.inner).apply_rest().align(x);
|
||||||
|
UiRegion::new(x, UiSpan::FULL) }
|
||||||
|
(None, Some(y)) => { let y = painter.size_ctx().height(&self.inner).apply_rest().align(y);
|
||||||
|
UiRegion::new(UiSpan::FULL, y) }
|
||||||
|
(None, None) => UiRegion::FULL,
|
||||||
|
};
|
||||||
|
painter.widget_within(&self.inner, region);
|
||||||
|
}
|
||||||
|
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { ctx.width(&self.inner) }
|
||||||
|
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { ctx.height(&self.inner) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// after
|
||||||
|
impl Widget for Aligned {
|
||||||
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
|
let full = painter.region();
|
||||||
|
// Draw once at the full region to learn the child's real size --
|
||||||
|
// this placement is provisional and corrected below without a
|
||||||
|
// second draw.
|
||||||
|
let used = painter.widget_within(&self.inner, full);
|
||||||
|
let region = match self.align.tuple() {
|
||||||
|
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }).within(&full),
|
||||||
|
(Some(x), None) => used.x.apply_rest().align(x).within(&full),
|
||||||
|
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
|
||||||
|
(None, None) => full,
|
||||||
|
};
|
||||||
|
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
|
||||||
|
used
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
|
||||||
|
return type from `()` to `Size`, carrying the child's `draw` result back —
|
||||||
|
the only signature change needed to let a parent see what its child used.
|
||||||
|
`Painter::reposition` is new, computing the delta between where a child
|
||||||
|
was actually drawn and where it belongs and calling the O(1) `mov` from
|
||||||
|
§2. `SizeCtx` and `Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
|
||||||
|
180-182`) are deleted — nothing calls `desired_len` any more, so there is
|
||||||
|
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
|
||||||
|
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
|
||||||
|
today (compare `size.rs:71-90` against `painter.rs:152-174`) and this
|
||||||
|
deletes the `SizeCtx` copies, keeping the `Painter` ones.
|
||||||
|
|
||||||
|
### 7. Migration — every file and widget that changes
|
||||||
|
|
||||||
|
One change, in dependency order (rename-and-move-together, per the code
|
||||||
|
rules — no intermediate state with both trait shapes):
|
||||||
|
|
||||||
|
- `core/src/widget/mod.rs` — the `Widget` trait (§1), delete
|
||||||
|
`WidgetAxisFns`, update `impl Widget for ()`.
|
||||||
|
- `core/src/ui/size.rs` — delete `SizeCtx` (the type and all its methods).
|
||||||
|
- `core/src/ui/cache.rs` — delete `Cache` (§5).
|
||||||
|
- `core/src/ui/painter.rs` — `widget`/`widget_within`/`widget_at` return
|
||||||
|
`Size`; add `reposition`, `draw_twice`; delete `size_ctx`, `size`,
|
||||||
|
`len_axis`; `primitive_at` writes `move_idx`.
|
||||||
|
- `core/src/ui/render_state.rs` — `draw_inner` captures and stores
|
||||||
|
`ActiveData.size`; `mov` becomes the O(1) offset write (§2); resize
|
||||||
|
narrowing (§3a); `redraw`'s per-axis loop reads `ActiveData.size`
|
||||||
|
instead of `Cache.size`.
|
||||||
|
- `core/src/ui/active.rs` — `ActiveData` gains `size: Size`,
|
||||||
|
`move_slot: MoveIdx`.
|
||||||
|
- `core/src/ui/mod.rs` — `UiData` gains `move_offsets`.
|
||||||
|
- `core/src/render/data.rs` — `PrimitiveInstance` gains `move_idx`;
|
||||||
|
new `MoveOffset` struct.
|
||||||
|
- `core/src/render/primitive.rs` — thread `move_idx` through `PrimitiveInst`
|
||||||
|
and `Primitives::write`, matching `mask_idx`.
|
||||||
|
- `core/src/render/mod.rs` — bind the new `move_offsets` storage buffer
|
||||||
|
(group 2, beside `masks`) and its update path.
|
||||||
|
- `core/src/render/shader.wgsl` — `InstanceInput` gains `move_idx`;
|
||||||
|
`MoveOffset`/`UiScalar`-shaped storage binding; a shared `resolve_move`
|
||||||
|
function (§2b) called from both `vs_main` (a primitive's own corners)
|
||||||
|
and `fs_main` (its mask's corners, once `Mask` carries `move_idx`).
|
||||||
|
- `core/src/ui/render_state.rs` — additionally, `resolved_region` (§2b)
|
||||||
|
and `window_region` (`:264-267`) reimplemented on top of it.
|
||||||
|
- `src/default/sense.rs` — `run_sensors`'s hit-test read (`:170`) switches
|
||||||
|
from `self.active.get(id).unwrap().region` to `self.resolved_region(*id)`
|
||||||
|
(§2b) — the pointer-routing fix this design requires, not an optional
|
||||||
|
follow-up.
|
||||||
|
- `core/src/render/data.rs` — additionally, `Mask` (`:46-49`) gains
|
||||||
|
`move_idx: u32` (§2b).
|
||||||
|
- `core/src/ui/painter.rs` — additionally, `set_mask` (`:49-52`) writes
|
||||||
|
`move_idx: self.move_slot` into the `Mask` it pushes (§2b).
|
||||||
|
- Every widget with a two-method `impl Widget`, collapsed to one `draw`
|
||||||
|
(§1, §6), `is_size_independent` added where true: `core/src/widget/mod.rs`
|
||||||
|
(`impl Widget for ()`), `iris/src/widget/rect.rs` (`Rect`, → true),
|
||||||
|
`iris/src/widget/image.rs` (`Image`, → true — a decoded image's primitive
|
||||||
|
never depends on the region it is offered, same as `Rect`),
|
||||||
|
`iris/src/widget/mask.rs` (`Masked`), `iris/src/widget/ptr.rs`
|
||||||
|
(`WidgetPtr`), `iris/src/widget/text/mod.rs` (`Text`, §4),
|
||||||
|
`iris/src/widget/text/edit.rs` (`TextEdit`),
|
||||||
|
`iris/src/widget/position/scroll.rs` (`Scroll`, keeps its `mov`-shaped
|
||||||
|
offset, now O(1) automatically via §2), `iris/src/widget/position/align.rs`
|
||||||
|
(`Aligned`, §6), `iris/src/widget/position/max_size.rs` (`MaxSize`),
|
||||||
|
`iris/src/widget/position/layer.rs` (`LayerOffset`),
|
||||||
|
`iris/src/widget/position/pad.rs` (`Pad`),
|
||||||
|
`iris/src/widget/position/stack.rs` (`Stack`),
|
||||||
|
`iris/src/widget/position/offset.rs` (`Offset`),
|
||||||
|
`iris/src/widget/position/span.rs` (`Span`, §4's `draw_twice` for the
|
||||||
|
cross-axis case, deleting `desired_ortho`'s duplicate loop),
|
||||||
|
`iris/src/widget/position/sized.rs` (`Sized`).
|
||||||
|
This list was produced by `grep -rn "impl Widget for\|fn desired_width\|fn desired_height"`
|
||||||
|
across `core/` and `src/`; re-run it before starting, since it is the
|
||||||
|
authoritative check that nothing was missed, not this paragraph.
|
||||||
|
- `iris/examples/{minimal.rs,task.rs,view.rs,tabs/main.rs}` — no direct
|
||||||
|
`impl Widget` found in any example (verified by the same grep); they use
|
||||||
|
the builder DSL in `core/src/widget/trait_fns.rs` and should need no
|
||||||
|
source change, which is itself part of the pass condition below.
|
||||||
|
|
||||||
|
### 8. Pass conditions
|
||||||
|
|
||||||
|
1. **Every example under `iris/examples` renders identically.** Run
|
||||||
|
`iris/run-headless.sh EXAMPLE --shot PNG` for each of `minimal`, `task`,
|
||||||
|
`view`, `tabs` before and after, and diff the PNGs pixel-for-pixel — not
|
||||||
|
"looks right," since a subtle wrap or alignment regression is exactly
|
||||||
|
what a diff catches and a glance does not.
|
||||||
|
2. **Unchanged-frame cost, measured, not assumed.** Add a counter beside
|
||||||
|
the existing `debug_layers`/`active_widgets` instrumentation
|
||||||
|
(`render_state.rs:241-262`) for (a) `Widget::draw` invocations and (b)
|
||||||
|
`Primitives::write`/`region_mut` calls, both per `update()` call. Drive
|
||||||
|
one example (`tabs`, since it already has multiple widgets and an
|
||||||
|
interactive element) through one frame with nothing changed and report
|
||||||
|
both counts — the pass condition is **0 draws and 0 primitive rewrites**
|
||||||
|
for a frame in which nothing was marked dirty, resized, or moved.
|
||||||
|
3. **Single-moved-child cost, measured.** Same counters, one frame in
|
||||||
|
which exactly one widget is moved (not resized) with N primitives in its
|
||||||
|
subtree — the pass condition is **1 write to `move_offsets`, 0 calls to
|
||||||
|
`Widget::draw`, 0 calls to `region_mut`**, independent of N. Construct
|
||||||
|
the case with a `tabs`-style example holding a deliberately large text
|
||||||
|
block (hundreds of glyphs) inside a `Scroll`, so N is large enough that
|
||||||
|
an O(N) regression would show up as a non-trivial write count rather
|
||||||
|
than being lost in noise.
|
||||||
|
4. **Hit-testing follows the move, not just the render.** In the same
|
||||||
|
scrolled-`tabs` construction as condition 3, scroll the content, then
|
||||||
|
send a synthetic cursor position over a widget that moved and assert
|
||||||
|
`run_sensors` (`src/default/sense.rs:154-200`) routes to that widget's
|
||||||
|
id, not to whatever is now at its pre-scroll coordinates or to nothing.
|
||||||
|
This is a correctness check, not a timing one — §2b's fix is required
|
||||||
|
before §2 can ship at all, and this is what would fail silently
|
||||||
|
(nothing on screen indicates a missed or misrouted hit) if it were
|
||||||
|
skipped.
|
||||||
|
5. **A mask moves with its subtree.** Render a `Masked`-wrapped `Scroll`
|
||||||
|
both before and after scrolling it (`iris/run-headless.sh` against a
|
||||||
|
small purpose-built example, or an addition to `tabs`), and diff the
|
||||||
|
two frames: the clipped edge of the content must have moved with the
|
||||||
|
scroll while the viewport's own border (drawn by `Masked`, not moved)
|
||||||
|
stays put — the specific case worked through in §2b. A mask rectangle
|
||||||
|
that stayed at its pre-scroll position while its content slid past it
|
||||||
|
is the regression this checks for, and it is visible in a single
|
||||||
|
screenshot, not just in a counter.
|
||||||
|
6. **`cargo test --workspace`, `cargo clippy --all-targets`, `cargo fmt`**
|
||||||
|
stay clean at the defaults (iris has no tests today per I0b, so this is
|
||||||
|
presently only clippy/fmt; add the first real widget-layer tests here if
|
||||||
|
the move-offset chain or `draw_twice` are non-trivial enough to want
|
||||||
|
one, per "match the codebase's testing posture" — judge that once the
|
||||||
|
code exists rather than pre-committing to a number of tests here).
|
||||||
|
|
||||||
|
### 9. Rejected, and why
|
||||||
|
|
||||||
|
- **A flat (non-chained) per-subtree offset table**, Iris's literal
|
||||||
|
phrasing — rejected in §2 for breaking under nested independent moves
|
||||||
|
(a swiped row inside a scrolling list). Costs nothing extra to avoid: the
|
||||||
|
chain is the same mechanism with one more field.
|
||||||
|
- **Keeping `region_mut` recursion as the only move mechanism** — rejected
|
||||||
|
as the steady-state path (O(primitives in subtree), exactly what a
|
||||||
|
transcript scroll must not pay every frame) but kept for resize-shaped
|
||||||
|
changes (§3) where the content's own region field, not an ancestor
|
||||||
|
chain, is what has to change.
|
||||||
|
- **A second, size-only trait method kept alongside `draw`** (e.g.
|
||||||
|
`fn size_hint(&self) -> Option<Size>` as a fast path some widgets could
|
||||||
|
implement to skip a draw when a cheap answer exists) — considered and
|
||||||
|
rejected: it reintroduces exactly the "two names for one concept" split
|
||||||
|
this change removes, for a saving `is_size_independent` (§1, §3b)
|
||||||
|
already covers for the cases where it would actually help (fixed-size
|
||||||
|
leaves). A widget whose size is cheap to compute but whose *drawing* is
|
||||||
|
not (unlikely in this codebase's widget set, but conceivable) is better
|
||||||
|
served by that widget caching its own draw output internally — exactly
|
||||||
|
the pattern `TextView::render` already uses (§4) — than by a second
|
||||||
|
trait method every implementor has to reason about.
|
||||||
|
- **Passing `available` as an explicit parameter to `draw`** (mirroring
|
||||||
|
Masonry's `layout(&mut self, ctx, bc: &BoxConstraints) -> Size`, the
|
||||||
|
yardstick per AGENTS.md) — rejected as redundant with `Painter::region()`,
|
||||||
|
which already carries the same information into every widget that needs
|
||||||
|
it; adding a parameter would just be a second route to a value already
|
||||||
|
reachable, and would invite the two drifting apart.
|
||||||
|
- **Eagerly propagating a moved widget's delta into every descendant's own
|
||||||
|
offset value** (rather than chaining and resolving in the shader) —
|
||||||
|
rejected as O(descendant widgets), which is smaller than O(primitives)
|
||||||
|
but still not O(1), and the shader-side chain costs nothing extra to get
|
||||||
|
the better bound.
|
||||||
|
|
||||||
|
## For IRIS.md
|
||||||
|
|
||||||
|
When this lands, copy this entry into `IRIS.md` (newest first):
|
||||||
|
|
||||||
|
> **2026-09-04 — `Widget::draw` reports the size it used; `desired_width`/
|
||||||
|
> `desired_height` are gone.** A widget used to implement three methods
|
||||||
|
> (`draw`, `desired_width`, `desired_height`); it now implements one,
|
||||||
|
> `fn draw(&mut self, painter: &mut Painter) -> Size`, which draws into
|
||||||
|
> `painter.region()` and returns how much of it was used. Why: the two
|
||||||
|
> extra methods routinely re-simulated what `draw` was about to do anyway
|
||||||
|
> (`Span::desired_ortho` copied its own draw loop to get cross-axis sizing
|
||||||
|
> right) — one visit per widget per frame instead of up to three. A
|
||||||
|
> container that needs a child's size before placing it (alignment,
|
||||||
|
> centering) draws the child once at a provisional region, reads the
|
||||||
|
> returned `Size`, and calls the new `Painter::reposition` to move it into
|
||||||
|
> its final spot — an O(1) offset write, not a second draw. A widget whose
|
||||||
|
> drawn output never depends on the size it's given (a fixed-size `Rect`,
|
||||||
|
> a decoded `Image`) overrides the new `fn is_size_independent(&self) ->
|
||||||
|
> bool { false }` to `true`, which skips redrawing it when only its
|
||||||
|
> offered region changes shape.
|
||||||
|
>
|
||||||
|
> ```rust
|
||||||
|
> // before
|
||||||
|
> fn draw(&mut self, painter: &mut Painter) { /* ... */ }
|
||||||
|
> fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||||||
|
> fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||||||
|
>
|
||||||
|
> // after
|
||||||
|
> fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
||||||
|
> design, the move-offset mechanism this shipped alongside, and the file
|
||||||
|
> list.
|
||||||
Reference in new issue
Block a user