diff --git a/IRIS.md b/IRIS.md new file mode 100644 index 0000000..7630e6c --- /dev/null +++ b/IRIS.md @@ -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._ diff --git a/LAYOUT.md b/LAYOUT.md index 86a4e8b..0f61a79 100644 --- a/LAYOUT.md +++ b/LAYOUT.md @@ -1,8 +1,13 @@ # iris: one `draw` that reports a size 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 -requirement with a design to be written below it. Nothing implemented.** +any design or code so that it survives a cleared session. **Status: design +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 @@ -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 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 -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 -rejected and why, and the pass conditions the implementation is checked -against (the existing examples under `iris/examples` still render the -same, and the per-frame work for an unchanged tree is measured, not -assumed). +### 1. The new `Widget` trait + +```rust +pub trait Widget: Any { + /// Draw within `painter.region()` (the space the parent offered) and + /// report how much of it was actually used, per axis. + 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`, the same arena shape + already used for `masks: TrackedArena` 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` (`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 { /* 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` 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.