39 KiB
iris: one draw that records a size
Iris, 2026-09-04:
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.
Implemented 2026-09-04; size dependencies made explicit 2026-09-09.
Every widget was migrated in one change; none kept
desired_width/desired_height. draw no longer returns its size directly:
it records it once on its Painter, and a parent that reads a child draw's
DrawResult::size() records the retained dependency between them. What is
kept below is the design as it stands, the corrections implementation forced
(read those before
touching Aligned, Sized, MaxSize, Scroll or the move-slot lifecycle
in render_state.rs -- each is a real bug the first draft would have
reproduced), and the two later additions that build on it. The
pre-implementation framing -- what the old trait looked like, the checklist
the design had to answer, the migration list, the pass conditions and the
"copy this into the design log" note -- was deleted on 2026-09-08,
having been carried out.
Design
1. The new Widget trait
pub trait Widget: Any {
fn draw(&mut self, painter: &mut Painter);
fn size_hint(&self, axis: Axis) -> Option<Len> { None }
fn is_size_independent(&self) -> bool {
false
}
}
Every implementation calls painter.set_size(size) exactly once. A child
draw returns a DrawResult that keeps the painter borrowed; calling .size()
on that result reads the child's retained size and records that the current
widget depends on it. Dropping the result without reading it draws the child
without making the parent's own size depend on the child's.
No available parameter: Painter already carries the region the parent
handed down (Painter::region(), core/src/ui/painter.rs:137) and already
exposes the pixel-resolved form (px_size(), :156) and the output surface
size (output_size(), :152). Passing it again would be the same value
under a second name. desired_width/desired_height (core/src/widget/mod.rs:20-21)
and WidgetAxisFns::desired_len (:24-35) are deleted outright — not
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.
size_hint is not a second layout pass. It is an optional exact answer for
an axis the widget declares without painter context or child access. A
lying hint fails a debug assertion when the widget is drawn.
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) gainspub move_offsets: TrackedArena<MoveOffset, u32>, the same arena shape already used formasks: TrackedArena<Mask, u32>on the line above it.render/data.rsgainspub 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 generalUiRegionremap — sufficient for every existing call site (above).PrimitiveInstance(render/data.rs:11-18) gainspub move_idx: u32, a vertex attribute at@location(7)besidemask_idxat6— the same kind of per-instance handle.ActiveData(core/src/ui/active.rs) gainspub move_slot: MoveIdx, assigned when the widget is first drawn (draw_inner, besideactive.insert), withparent= the drawing widget's parent's slot.Painterthreads amove_slotfield down exactly as it already threadsmaskandlayer(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) writesmove_idx: self.move_slot, matching how it already writesmask_idx: self.mask.mov(id, delta)becomes: look upid's slot, writemove_offsets[slot].delta += delta. One write — no primitive touched, no recursion, since descendants already reference this slot transitively.- A container may also retain one optional child-coordinate slot between
its own slot and every direct child's slot.
Painter::set_child_offsetcreates that boundary before the first child is drawn and can update it after measuring a child on later redraws. The container's own primitives, hit region and mask stay fixed; its whole child subtree moves through one write and every existing GPU, hit-test and accessibility chain sees the same result.LazySpanuses this while still walking visible rows for virtualisation: row boxes stay in stable local coordinates and the shared boundary carries the changing screen translation. shader.wgsl's vertex stage, after computingtop_left/bot_rightin pixels (after:106, before the clip-space divide at:113), walksmove_idx → move_offsets[i].parentfor a bounded number of steps (a small constant, e.g. 16, with a CPU-side debug assertion that no chain exceeds it), summingdeltainto 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.
Provisional layout can still write an instance at an intermediate position
and restore it before upload. Primitives::set_instance remembers the value
at the first write in a frame and clears the dirty bit when the final bytes
match it. The GPU therefore observes final layout state, not CPU-only
measurement work.
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_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 —
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.
Size invalidation travels upward before drawing; drawing itself travels only
downward. Every active widget retains the direct children whose size it read
through DrawResult::size() or Painter::known_len. Before a frame draws,
redraw_updates follows only those dependency edges from each dirty child and
marks the affected ancestors dirty. It then selects the highest dirty roots and
draws them top-down. Drawing never synchronously invalidates or invokes a
parent, so there is no layout recursion and no provisional child draw on a
different layer.
An exact size_hint stops propagation when both axes still equal the retained
size. Otherwise propagation is deliberately conservative: the child may have
changed size, and only its dependent ancestors can assign the final boxes.
Unchanged descendants still take draw_inner's retained skip-or-move path.
For a stacking container, retained child lengths are cached per axis: a child's
width remains reusable while the parent changes width, and its height remains
reusable while the parent changes height. A change on the orthogonal axis does
invalidate it in both directions. This is a generic constraint rule, not a
text exception; wrapped text is merely the common example of height depending
on width.
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.
Span has no size-only pass. It first reads exact, context-free
Widget::size_hint(axis) values. It then draws unknown fixed children
forward from the current cursor, retaining what they paint. Once every
length is known, flexible space is allocated and Painter::place moves
each retained child into its final box. A child is redrawn only when that
box changes the size it was drawn for.
Hints are optional and affect cost, never correctness. Sized can report
its declared axis without inspecting its child, which covers the important
.height(rest()) case. A debug assertion compares every hint with the
eventual draw result. Widgets whose answer depends on shaping or on a
child return None.
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.
ActiveData::size stores the value the widget recorded with
Painter::set_size. This is what a parent placing the widget for a second
frame without redrawing it 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." ActiveData::size_dependencies
stores the direct children whose DrawResult::size() or known length the
widget observed during that same draw; the next draw replaces the list, so a
dependency disappears as soon as the widget stops reading it. Both fields
have ActiveData's existing lifecycle through remove/remove_rec.
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) {
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
thickness: self.thickness, inner_radius: self.inner_radius });
painter.set_size(Size::REST); // fills whatever it was given
}
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) {
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).size();
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.place(&self.inner, region);
painter.set_size(used);
}
}
Painter::widget_within/widget/widget_at (painter.rs:55-76) change
return type from () to DrawResult. Calling .size() reads the size the
child recorded on its painter and records the parent's dependency on that
answer; leaving it unread records no dependency.
Painter::place moves an already-drawn child when its used area fits the
target box, and redraws it when the target changes its size. 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. 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_mutrecursion 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 general measurement API — rejected because it walks the same nested
tree again. The narrow
size_hint(axis)contract is exact, context-free, and optional; it exists only for sizes a widget already declares itself. - Passing
availableas an explicit parameter todraw(mirroring Masonry'slayout(&mut self, ctx, bc: &BoxConstraints) -> Size, the yardstick per AGENTS.md) — rejected as redundant withPainter::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.
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, LazySpan::place, and
UiRenderState::place 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.
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).
- 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 aRect, the samedraw_rounded_rectcoverage from the same SDF; for a texture or glyph, the sampled alpha -- and doescolor.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 andmove_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 (seemasks_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. - Nested masks chain and multiply, like moves.
parentwalks up the chain, bounded likeresolve_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. .masked()points the mask at the current widget's own primitives.Maskedstops 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 roundedRectwidget'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.- 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
Rectthat 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 (headlessrenders 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
kindand noflagsonMask. It is{ primitive, parent }. The referenced instance already carries its ownbinding, 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_toasserts it, by name, rather than leaving the shader to read arectsentry 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'smask_coveragealready 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 undrawnRectPrimitiveat 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)drawsshapebehind the content — in its own layer, the wayStackputs 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 liftsdistance_from_rectandrounded_rect_coverageout ofiris_core::SHAPE_SHADERby 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.
What a widget's offered box may and may not be (2026-09-08)
Two rules that were each true in one place and missing from a sibling, found together by Iris's 2026-09-08 phone report.
Padding works in whatever container it is placed in, and is an inset or
an outset depending on how tight that container's region is. Iris's
own words, 2026-09-08: "padding should work no matter what container a
widget is placed in, and acts as both inset and outset depending on how
tight the parent region is." Pad offers its child the region it was
handed, inset on each side, and reports used + padding — so given a
generous box it insets the child inside it, and given a box already the
size of the content it reports a larger size and the parent grows. What
this rules out is any container that offers a padded child a box and then
ignores what it reported, and any caller that reshapes its tree to avoid
a Pad (which transcript-ui/src/tool.rs did until 2026-09-08, at the
cost of a tool group's 4dp inset).
A widget offered a box it does not fit is drawn again at the box its
own reported size implies, in the same frame. Not next frame. The
temptation to defer is real — LazySpan::place offers a row its cached
height precisely so that an unchanged row hits draw_inner's cheap
skip-or-move path, and Scroll sizes its child region from last frame's
content length for the same reason. But a Rect fills whatever region it
is given (Size::REST, and rect.rs's is_size_independent doc says
why it must), and .background(rect(..)) is the ordinary way to style
anything — so a one-frame-stale box is a background drawn at the wrong
size while the text inside it is already right. On screen that is a tool
card that looks closed while its text is there and open while it is not.
A move alone cannot fix a changed size; Painter::place redraws in that
case.
The cost is bounded and worth stating, because it is what makes the rule
safe to apply everywhere: the settling draw happens only on the frame a
widget's own size actually changes, which is a frame that was already
redrawing it. Sized also requires its final region before retaining its
children: its own reported size may be known exactly while a descendant was
drawn in the provisional box, so moving only the wrapper is insufficient. A
widget whose reported size is a function of the box it was offered would
disagree every frame and redraw every frame — which is why LazySpan requires
content-sized rows, and has since long before this.