Files
ai-app/docs/LAYOUT.md
T
irisandClaude Opus 5 38bf6309cb iris: a mask is a shape, not a rectangle -- .masked_by, and touch obeys it
Iris, on the code fence: "the code block scrolling currently masks in an
inner rectangle. Ideally masks should have a shape associated with them,
rounded rectangle being one of them ... so that the mask becomes the
parent container with rounded edges. Make sure alpha works properly with
it, eg. on the corners where alpha should be decreased / multiplied."

`Mask` is now `{ primitive, parent }` -- the slot of a primitive already
written, plus the mask this one nests inside. The fragment stage
evaluates that primitive's own coverage at the masked pixel, through the
same `rounded_rect_coverage` a drawn rect goes through, and multiplies it
into the alpha along the whole `parent` chain. Nothing about the shape is
copied, so a rounded container's corner and its children's clipped corner
are one piece of arithmetic and cannot drift; two nested feathers dim a
pixel twice, which is the multiply she asked for.

`.masked()` is unchanged for callers: it writes an undrawn rect
(`Drawn::No`/`NOT_DRAWN` -- owned, moved, resized and freed like any
other primitive, simply never rasterized) and points at that, so square
clipping is the same mechanism rather than a special case. New
`.masked_by(shape)` draws `shape` behind the content in its own layer and
clips to the first primitive it drew, with no radius written twice; it
replaces `.masked().background(w)`, which drew both and clipped to the
box. `transcript-ui`'s `BlockFrame::Verbatim` is the first caller.

Hit-testing applies the shape (`SensorUi::run_sensors` ->
`UiRenderState::mask_admits`, coverage above one half, which is where the
drawn edge is), as well as the widget's own box -- the two ask different
questions and both have to hold. `primitive_corners` is a floor-for-floor
transliteration of the shader's `corners_of`, not `region.to_px()`: the
phone's 2.55 density puts nothing on a whole pixel, and skipping the
rounding disagrees with the pixels by up to one along each edge.

A mask's shape must be a rect, asserted by name in `set_mask_to`. A glyph
would need a CPU-side alpha plane before the hit test could agree with
the shader, and a standalone image a bind-group switch the fragment stage
cannot make. So no texture mask exists; the branch where one would go is
in both copies of `mask_coverage`. docs/LAYOUT.md's section end lists this
and the three other places the code is narrower than the design.

Tests. Layer 1, `layout_tests.rs`: the child's coverage swept across the
container's corner arc equals the container's own exactly; nested masks
multiply rather than intersect, asserted where both feathers are partial,
which is the only place the two differ; a press in a rounded-away corner
misses while one inside the curve and one on a straight edge hit; and
`a_plain_mask_still_clips_to_a_square_box`, the half this had no reason to
touch. The first version of the corner test swept the straight chord
between the arc's ends, which lies inside the circle everywhere -- it
proved nothing and said so, which is why it counts both sides now.

`iris/tests/mask_sdf.rs` is the only test here that needs a GPU: it lifts
`distance_from_rect` and `rounded_rect_coverage` out of
`iris_core::SHAPE_SHADER` by name -- lifted, not copied, since a copy
would be edited alongside the shader -- and runs them in a compute pass
over ~200k points at five radii against `iris_core::rounded_rect_coverage`.
Worst disagreement under 1e-5; the negative control (`+ 0.01` inside the
shader's smoothstep) fails it at 0.03.

Layer 2 for looking: `./run-headless.sh phone --phone --shot /tmp/mask.png
--seconds 6 -- -p transcript-fixture` draws the fixture's horizontally
scrolled code fence clipped on the curve at both top corners.

Two things found on the way and fixed here:

- The winit backend had the defect the Android one was fixed for in
  85869d0 -- `Backends::PRIMARY` and an `.expect` on the adapter. This
  VM's Venus device disappears when the host runs out of virgl contexts,
  which happened mid-task, and layer 2 aborted with `Could not get
  adapter!` while GL sat there working. It probes and rebuilds the
  instance on `Backends::GL` exactly as Android does now, and the request
  names the backends it tried. The rule had been written on one member of
  a set of two.
- `active_primitive_count` counted mask shapes, so `iris::frame`'s
  `primitives=` -- a number Iris reads off a phone report as "how much is
  on screen" -- would have gained one per masked widget.

`widget_trait!` now accepts a `///` doc comment on its functions, since
`masked_by` is public API and rustdoc is where a contract is read.

docs/LAYOUT.md, docs/RUST.md (both queue boxes, the commands, and where
the GPU test sits among the three layers), docs/IRIS.md and
docs/IRIS_TODO.md ("Masks defined relative to each other", now closed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 02:18:57 -04:00

62 KiB
Raw Blame History

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: implemented 2026-09-04, against every pass condition in §8 (measured, not assumed — see that section). Every widget listed in §7 was migrated in one change; none kept desired_width/desired_height. Five points needed correction or refinement beyond what this file originally specified — see "Deviations found during implementation" below, added right before "For IRIS.md" — read that section before touching Aligned, Sized, MaxSize, Scroll, or the move-slot lifecycle in render_state.rs, since each of those five is a real bug this file's first draft would have reproduced if implemented literally.

What Iris asked for

I don't like that widgets need both a draw and size functions. I'd much rather them have a single draw that reports a size, and if it needs to be moved then that can be done after the fact efficiently, or resized just done after as well. This should be done efficiently like everything else tries to do right now.

She added, a few minutes later: "single draw is not a requirement. It just seems more efficient from what I've heard. Feel free to override any decision I've made if you can find a genuinely better & still clean alternative." So the single-draw model is the default to design against, and the design below may reject it, but only with a written comparison showing the alternative does less work per frame and is no harder to use.

Standing constraints from RUST.md still apply: no DSL, plain Rust, do as little processing as possible per frame, but the model must cover every layout need a real app has (the transcript's virtualised list, wrapped text whose height depends on width, rows and columns that size to their children, overlays, masks).

What exists today

Widget (iris/core/src/widget/mod.rs) has three methods: draw(&mut self, &mut Painter), desired_width(&mut self, &mut SizeCtx) -> Len and desired_height. A parent asks SizeCtx::width/height for a child, which is memoised per widget id and axis in Cache.size keyed on the outer size, then places the child with Painter::widget_within(region). So a child is visited twice (sized, then drawn), every widget implements sizing twice (one per axis), and a widget whose size depends on what it draws (wrapped text, a laid-out paragraph) does the layout in the size pass and again in the draw pass unless it caches by hand.

Primitives are already positioned by UiRegion values whose scalars have a rel and an abs part, resolved against the window in the vertex shader (core/src/render/shader.wgsl), and Primitives::region_mut exists to rewrite one instance's region in place. That is the mechanism a "move after the fact" can build on.

What the design must answer

  1. Parent-before-child ordering. A row has to know each child's width to place the next one, but under "one draw" the child's size only exists after it has drawn. The answer is meant to be: the child draws at a provisional origin, reports its size, and the parent moves it. The move must be O(1) per moved subtree, not O(primitives in the subtree). One way: every instance carries an index into a small per-widget offset buffer, so moving a widget writes one entry and the vertex shader adds it. Other ways may be better; the design should say what was considered.
  2. Move vs resize are different costs and must be kept apart. A move never re-runs draw. A resize re-runs draw for exactly the widgets whose size input changed, and a widget whose output does not depend on its size (an icon, a fixed rect) must be able to say so and be skipped.
  3. Size-dependent content. Wrapped text is the hard case: its height is a function of its width. A single draw receives the available size (what SizeCtx.outer is today) and reports what it used, so the two-pass "measure then draw" collapses into one for the common case. The design must say what happens when a parent wants the child's height before deciding the width it will offer (rare; say whether it is supported, or is done by drawing twice as an explicit, opt-in cost).
  4. Caching. Today's Cache.size memoises by (id, axis, outer). The replacement should memoise the whole draw result by (id, available size) so that an unchanged subtree costs nothing on the next frame, which is what makes a virtualised list cheap.
  5. Everything currently written against desired_width/desired_height moves over in one change, per the code rules: two names for one concept is not an intermediate state to leave behind. The widgets are in iris/src/widget/ (ptr, mask, image, rect, trait_fns, and whatever else is there when the change is made).

Order relative to the texture work

TEXTURES.md's redesign touches the render core (shader, GpuTextures, Primitives, Painter's texture calls). This change touches the widget trait, SizeCtx, Cache, Painter's widget calls, and any offset mechanism the vertex shader needs. They overlap in Painter and the 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

1. The new Widget trait

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<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:

/// `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_maskMasked::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 —

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

/// 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:

// 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) }
}
// 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:

// 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) }
}
// 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.rswidget/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.rsdraw_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.rsActiveData gains size: Size, move_slot: MoveIdx.
  • core/src/ui/mod.rsUiData gains move_offsets.
  • core/src/render/data.rsPrimitiveInstance 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.wgslInstanceInput 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.rsrun_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.

    Result (2026-09-04): pass, all four, 0 differing bytes. No PNG library is installed in this VM (no PIL, no ImageMagick, no pip), so the diff is a from-scratch PNG decoder (zlib + the five filter types) at /tmp/layout-shots/pngdiff.py, comparing decoded pixel bytes rather than file bytes (cmp alone is not conclusive across two separately-encoded PNGs, though it happened to agree here for minimal). Before-shots were taken with git stash at the pre-change commit; tabs needed two real fixes (deviations 1 and 2 below) before it stopped differing — the other three matched on the first try.

  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.

    Result (2026-09-04): pass, 0 and 0. Implemented as UiRenderState::take_counters() -> (u64, u64, u64) (draws, region_mut rewrites, move_offsets writes — a third counter, for condition 3 below), reset on read. Measured in iris/src/layout_tests.rs::an_unchanged_frame_draws_and_rewrites_nothing against a Scroll over 500 fixed-height rects (not the tabs example — see the note on condition 3 for why this runs as a plain unit test instead).

  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.

    Result (2026-09-04): pass — 0 draws, 0 rewrites, 1 move_offsets write, N = 500. Built with rects rather than glyphs (iris/src/layout_tests.rs::scrolling_moves_in_o1_without_a_redraw): iris-core/iris touch no GPU or window to lay out and move a tree, so this runs as a plain cargo test, not through run-headless.sh — a Widgets/UiData pair and a bare UiRsc impl are enough, and it is faster and more precise than reading counters out of a real example's stderr. Getting a clean single move took two follow-up fixes beyond the design as written (deviation 3, the parent_move_slot threading; and the Scroll design decision below about offering last frame's content length) — without either, the count was in the thousands (every rect in the subtree redrawing) rather than 1.

  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.

    Result (2026-09-04): pass, but checked one level below run_sensors: iris/src/layout_tests.rs::hit_testing_follows_a_scrolled_widget scrolls a widget and asserts UiRenderState::resolved_region (the query run_sensors's hit-test and window_region both now go through, per §2b) reports the moved, not the pre-scroll, position — within 0.01px of the exact expected delta. run_sensors itself needs a HasEvents/window/cursor-state harness this pass did not build; the coverage that matters (does the position query the router uses reflect the move) is exercised directly instead.

  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.

    Result (2026-09-04): pass, checked numerically rather than by screenshot. No example in this repository builds a Masked-wrapped Scroll (tabs's "text edit scroll" tab uses TextEdit's own internal scrolling, not this widget), so there was nothing to screenshot without first authoring a new example. Checked instead in iris/src/layout_tests.rs::a_mask_stays_put_while_its_scrolled_content_moves, on the exact data the fragment shader's resolve_move reads: the masked widget's own move_offsets slot delta is [0, 0] both before and after scrolling its content, because Masked is never itself the target of a move — only its child is, on a separate, deeper slot in the chain (§2b's "scroll-container case, checked rather than assumed"). A pixel-level screenshot check of this remains open; see RUST.md's next step.

  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).

    Result (2026-09-04): pass. cargo fmt --all -- --check, cargo build --workspace --all-targets, and cargo clippy --all-targets are all clean (one pre-existing, unrelated warning about naga/wgpu/ winit future-incompatibility, from dependencies, not this change). cargo test --workspace: the 14 pre-existing TextEdit tests plus 4 new ones in iris/src/layout_tests.rs (conditions 25 above), 18 passed, 0 failed — the move-offset chain turned out non-trivial enough (three real bugs found only by writing it) to clearly clear the "match the testing posture" bar this section left open.

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.

Deviations found during implementation (2026-09-04)

Five corrections this file's first draft did not anticipate, each found by iris/run-headless.sh tabs --shot disagreeing with a pixel-identical pre-change screenshot (pass condition 1) and traced with eprintln! in draw_inner/reposition — not by reasoning about the design in the abstract. Recorded here rather than silently fixed in place, per the code rules' escape-hatch requirement.

  1. Aligned's provisional draw must call painter.widget, not widget_within(&self.inner, painter.region()). §6's original text drew the sample as the latter. widget_within composes its region argument as local, UiRegion::FULL-relative coordinates against painter.region() (exactly what UiRegion::FULL.within(&self.region) == self.region relies on); handing it painter.region() itself — already-resolved, window-relative coordinates — composes that frame a second time. For the root widget this is silently the identity (its region already is [0,1]), which is why it can look correct in a trivial case and only breaks once something is nested — i.e. always, in practice. Symptom: a centered child rendered at a wildly wrong offset nested more than one level deep. Fixed by using painter.widget, which hands the child self.region unmodified, with no second composition.

  2. A widget that reports a size smaller than its offered region must actually paint at that size, anchored top-left of what it was given — not fill the full offered region while merely reporting a smaller number. Sized and MaxSize both had exactly this bug: their desired_width/desired_height predecessors capped the reported value but their draw bodies called painter.widget(&self.inner) unconstrained, which was harmless under the old two-pass model (a parent always queried the size before drawing, so by the time draw ran the offered region already matched) but wrong under Aligned's new provisional-draw-then-reposition pattern, which offers the whole region on the first, learning pass. Symptom: a .sized((100, 100)) rect rendered stretched to fill its whole row instead of a 100×100 square. Fixed by having both widgets carve the declared sub-region (UiSpan sized to the axis's Len, anchored at AxisAlign::Neg) out of whatever they were offered before drawing the child in it. Image needed the same treatment from the start (texture_within at its own natural size, not texture() at the full offered region) and was written that way in the first pass, once this was understood; Rect's "fill whatever I'm given" is the one case where painting the whole offered region really is the declared behavior, so it needed no change.

  3. The move-offset chain's parent link cannot be found by looking up the parent's ActiveData in draw_inner, because the parent's ActiveData does not exist yet while its own Widget::draw is still running. ActiveData is inserted only after draw returns (render_state.rs, end of draw_inner), so a child drawn partway through its parent's draw body — the ordinary case, since every composite widget draws its children from inside its own draw — would always read "no parent" from self.active, silently orphaning it at the root of the chain. Fixed by threading the parent's move_slot down through Painter (it already carries mask/layer the same way) and passing it explicitly into draw_inner as parent_move_slot, rather than deriving it from self.active.get(parent_id). move_parent_of (the self.active-based lookup) is kept, but only for redraw(), whose target's parent genuinely is already active at that call site — the doc comment on it says which is which. Symptom: reposition computed the right delta and wrote it to the right slot, but the shader never saw it, because the primitive doing the actual painting chained to u32::MAX one level too early.

  4. Painter::reposition cannot reuse active.region as "where the widget currently is," because for a widget offered more room than it used, active.region is the offered box, not the painted one. This only matters for reposition (used by Aligned); mov (used by draw_inner's own same-size-different-position dispatch, for Scroll and Offset) has no such gap, because there the offered region is the visual footprint — content is sized to fill exactly what it is given. reposition instead reconstructs "from" as active.size (already tracked, per §5) anchored at AxisAlign::Neg within active.region — i.e. it assumes the child painted itself top-left of whatever it was offered, per point 2's convention — and overwrites the slot's delta rather than accumulating it the way mov does, since "from" is recomputed fresh from stable inputs every call and repeating the same reposition (an unrelated redraw elsewhere re-running this widget's parent) must not drift further each time. The one shape this does not cover: Aligned wrapping Aligned, where the inner one's own reposition may have moved its content away from top-left already. No widget or example in this codebase builds that today; if one needs to, reposition would need the child to report where it painted, not just how big, which is a larger change than this pass's scope.

  5. A widget's move_offsets slot is allocated once, on its first-ever draw, and reused in place — never reallocated — for every later redraw of the same id, with its delta reset to [0, 0] on each reuse. Not spelled out in §2's original text, which only said slots are assigned "when the widget is first drawn." Reallocating a fresh slot on every redraw would leave any retained (not-redrawn) descendant's parent link pointing at a now-orphaned old slot — a permanent leak, and worse, a descendant that silently stops tracking its ancestor's future moves. Resetting the delta on reuse (rather than carrying it forward) is required because a full redraw bakes the widget's correct absolute position into the fresh region argument directly; a stale delta left over from before the redraw would double-offset it.

Two further points worth recording because they were design decisions made while implementing, not bugs — LAYOUT.md's own text left them unspecified rather than getting them wrong:

  • Scroll offers its content a region sized by the previous frame's measured content length, not a fresh one. A fresh measurement would require drawing the content once to learn its size and — since that provisional size essentially never matches the previously active one — redrawing it a second time at the real size, on every single scroll tick, which is exactly the cost §2 exists to remove. Using the stale length means an ordinary scroll (position changes, content does not) offers the same size as last frame, only shifted, which is what makes draw_inner dispatch it as the O(1) move. The cost: a real content-size change lags one frame before the container's scroll range reflects it, self-correcting the frame after (the content length itself, read from what was actually drawn, is never stale — only the offered region used for placement is). No example in this repository builds a Scroll yet, so this could not be checked against a pixel diff; it is covered instead by iris/src/layout_tests.rs's three Scroll-based unit tests, which build a tree and drive UiRenderState directly with no GPU or window needed.
  • redraw()'s parent-relayout check draws the widget first, then compares the fresh ActiveData.size the draw produced against the size from before removal — the mirror image of the old code's "query size, compare, decide whether to draw," which no longer has a size query to do the comparison with before drawing (§5 deleted Cache/SizeCtx along with desired_width/desired_height). This can occasionally draw a widget once more than the old code would have (if the parent it bubbles up to ends up redrawing the same widget again as part of its own relayout) — draw_inner's own skip/move dispatch absorbs most of that redundancy for free, and this path is not one of §8's measured conditions, so the remaining slack was accepted rather than chased further.

Density: Len::dp, resolved at apply_rest time (2026-09-06)

Iris asked for a third length kind beside abs (physical pixels) and rel/rest (a fraction of the parent) — IRIS_TODO.md's "density- independent length unit" — after the P0 phone pass found 16px text drawing at roughly a third size on a real phone. The fix that shipped first (RUST.md's P0 box) was a global stopgap: divide the whole window into a "logical" coordinate space (physical ÷ content_scale) and let the shader's NDC mapping stretch it back up onto the real framebuffer. That fixed the size but not the sharpness — a glyph rasterised at the small, pre-stretch size and then stretched onto more physical pixels than it has texels for is blurry, which is exactly what Iris's next report said.

The fix: Len gained a dp field, resolved against a density: f32 (physical pixels per dp) at the one place a Len becomes a UiScalar (Len::apply_rest) — abs + dp * density. density lives on UiRenderState (set_density/density()) and Painter (density()), set once from DisplayMetrics.density in android::view::new_peer; the desktop backend has no per-monitor density wired up yet and stays at 1.0. Every layout call site that used to call .apply_rest()/ .to_uivec2() now passes painter.density() (nine call sites — Span, Sized, MaxSize, Aligned, Scroll, List::place, and UiRenderState::reposition itself). This also meant the Android boundary's global logical-space stopgap could come out entirely: window size, touch coordinates and insets are physical pixels again, matching AndroidRenderer's own swapchain resolution, with dp doing the per-length work the global divide used to do for everything at once.

Text is the case that needed more than the Len plumbing. A widget's font_size/line_height are plain f32, not routed through Len at all (there is no sensible rel/rest for a font size). TextBuffer:: shape now takes density directly and multiplies font_size/ line_height (and any span override) by it before handing them to parley — so the size that reaches both the line-breaker and the rasteriser (TextData::place, which reads back whatever shape set) is the display's physical size, and the glyph atlas holds a bitmap at the resolution it is actually shown at. GlyphKey.size already keys on the resolved size, so a cache entry is naturally per-physical-size with no further change. The one caller with no Painter to read density from (TextEditCtx::layout, cursor movement and hit-testing) reads a second copy kept directly on TextData (TextData::density) instead — an accepted duplication rather than threading a Painter into every input handler for one field, the same tradeoff AndroidRenderer::content_scale already makes for the Diagnostics page.

What did not change: rel/rest are unaffected (already resolution-independent, a fraction of the parent). Span::gap and Padding's four sides moved from bare f32 to Len so dp(...) works on them the same as any other size; a bare number is still abs, physical pixels, unchanged.

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.

// 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.

Masks with a shape (decided 2026-09-07, built 2026-09-08)

Iris, on the code block's scrolling: "the code block scrolling currently masks in an inner rectangle. Ideally masks should have a shape associated with them, rounded rectangle being one of them, and/or another widget you can select, so that the mask becomes the parent container with rounded edges. Make sure alpha works properly with it, eg. on the corners where alpha should be decreased / multiplied."

What exists. Mask in shader.wgsl/data.rs is two UiSpans and a move_idx; fs_main resolves it and does color *= 0.0 outside the rectangle -- a hard cut on a pixel boundary. Masked (widget/mask.rs) sets the painter's mask to its own region. Separately, draw_rounded_rect already produces an anti-aliased rounded edge from distance_from_rect(pos, center, corner, radius) with a half-pixel smoothstep, and the border variant multiplies a second coverage in.

Design (revised the same day on Iris's two corrections: hit-testing applies the shape too, and a mask should reference a primitive rather than carry a copy of its shape).

  1. A mask is a reference to a primitive already drawn, plus how to use it. Mask { kind, idx, flags, parent }: the primitive's binding (RECT, TEXTURE, GLYPH) and slot, flags (today one: alpha only -- take the primitive's coverage and ignore its colour, which is the default and the only mode until a need for another appears), and the enclosing mask's slot for nesting. The fragment stage evaluates the referenced primitive at the masked pixel -- for a Rect, the same draw_rounded_rect coverage from the same SDF; for a texture or glyph, the sampled alpha -- and does color.a *= coverage. Nothing about the shape is copied: a rounded container's corner and its children's clipped corner are the same primitive's arithmetic, and a texture mask (an alpha image as the clip) works with no new shader path. What this needs from the data layout: evaluating a primitive at an arbitrary pixel means its placement (its spans and move_idx, today vertex attributes) has to be readable from a storage buffer in the fragment stage. If it is not already there, put it there once, for every primitive, rather than keeping a second copy for masks -- the vertex stage can read the same buffer. Textures: the shader binds one image at a time (see masks_layout's comment on why an image's own bind group must not name the masks buffer), so a texture mask is limited to what the fragment can sample without a bind-group switch: the atlas, and the primitive's own bound image when the masked primitive is drawn in the same image's batch. Say so at the flag.
  2. Nested masks chain and multiply, like moves. parent walks up the chain, bounded like resolve_move (MOVE_CHAIN_LIMIT's sibling; debug-assert on overflow and print the chain); coverages multiply, so a pixel inside two feathered corners is dimmed by both, which is what a compositor does and what "alpha should be multiplied" asks.
  3. .masked() points the mask at the current widget's own primitives. Masked stops describing a region: it records which primitive(s) the wrapping widget drew this frame (the painter knows -- it just allocated the slots) and sets the mask to reference them. So a rounded Rect widget's .masked() clips its children to itself by pointing at the rect it already draws; an image widget's .masked() clips to its alpha. No radius or shape argument exists to fall out of sync. When a widget draws more than one primitive (a bordered rect is one primitive; a card with a stripe is two), the mask references the first and the doc says so; a widget that wants another names it.
  4. Hit-testing applies the shape. A press is inside a masked subtree only if the mask's coverage at that point is above one half. For a Rect that is the same rounded-rect SDF evaluated on the CPU -- one function in the shared crate, with the WGSL a transliteration of it and a test that compares the two at a grid of points (headless renders to a buffer and reads back, or the Rust version is checked against the values the shader produced once and recorded). For a texture, the CPU needs the alpha: keep the alpha channel of an image used as a mask readable on the CPU (it was uploaded from CPU memory; keeping the alpha plane is a quarter of the image), and read it at the point. A masked corner that cannot be tapped and a masked corner that is not drawn are then the same corner.

Rejected. A stencil buffer (a second pass per mask level and no anti-aliasing); the scissor rectangle (rectangles only, no alpha); rendering a masked subtree to an offscreen texture and compositing (a texture allocation per mask, every frame it scrolls, on the phone).

Pass conditions. A headless test draws a rounded container with a masked child that overhangs all four sides and asserts the child's coverage at a corner pixel equals the container's own coverage there (same primitive evaluated, so exactly equal, not approximately); a nested-mask test asserts the product at a pixel inside both feathers; a texture-mask test clips a rect to an alpha image and asserts a transparent texel masks fully; a hit-test asserts a press in a container's clipped corner misses and one just inside the curve hits, and that the CPU SDF and the shader agree at a grid of points; a run-headless.sh --phone screenshot of a scrolled code block shows rounded corners with no square pixels poking out at the top and bottom of the scrolled content. Record the commands in RUST.md when it lands.

What was built (2026-09-08), and where it differs

The commands and the screenshot are in docs/RUST.md's queue entry. Four places the code is narrower than the design above, each deliberate:

  • No kind and no flags on Mask. It is { primitive, parent }. The referenced instance already carries its own binding, so a copy of it in the mask is a second thing to keep in step; alpha only is the only mode there is, so there is nothing to select. Both are a field away if a second mode appears.
  • A mask's shape must be a rect. Painter::set_mask_to asserts it, by name, rather than leaving the shader to read a rects entry that is not there. A glyph would need a CPU-side alpha plane before the hit test could agree with the shader, and a standalone image needs a bind-group switch the fragment stage cannot make (masks_layout's own comment on why an image's bind group must not name the masks buffer). So the texture-mask pass condition is not met and no texture mask exists — the point of the reference design is that adding one is a binding check and a sampled alpha, with no new shader path, and the shader's mask_coverage already has the branch where it would go.
  • The shape is a primitive of its own, not always a drawn one. A plain .masked() writes an undrawn RectPrimitive at its region (Drawn::No, NOT_DRAWN) and points the mask at that, so "clip to my box" and "clip to that widget's rounded background" are one mechanism and square-cornered clipping did not become a special case. .masked_by(shape) draws shape behind the content — in its own layer, the way Stack puts a background under its content — and clips to the first primitive it drew.
  • The CPU/shader agreement is a GPU test, iris/tests/mask_sdf.rs, the only test in the workspace that needs an adapter. It lifts distance_from_rect and rounded_rect_coverage out of iris_core::SHAPE_SHADER by name and runs them in a compute pass, so the thing under test is the shader itself rather than a copy of it that would be edited alongside.