39 KiB
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: design
written 2026-09-04, on top of TEXTURES.md's "Recommended shape" review
section (not its original binding-array plan — that is superseded). Nothing
implemented yet. Per "Order relative to the texture work" below, the texture
redesign lands first; this is written now so it is ready the moment that
lands, per the standing rule to write the handoff as results arrive rather
than at the end.
What Iris asked for
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
- 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.
- Move vs resize are different costs and must be kept apart. A move
never re-runs
draw. A resize re-runsdrawfor 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. - Size-dependent content. Wrapped text is the hard case: its height
is a function of its width. A single
drawreceives the available size (whatSizeCtx.outeris 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). - Caching. Today's
Cache.sizememoises 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. - Everything currently written against
desired_width/desired_heightmoves over in one change, per the code rules: two names for one concept is not an intermediate state to leave behind. The widgets are iniris/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) 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.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.
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.
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— theWidgettrait (§1), deleteWidgetAxisFns, updateimpl Widget for ().core/src/ui/size.rs— deleteSizeCtx(the type and all its methods).core/src/ui/cache.rs— deleteCache(§5).core/src/ui/painter.rs—widget/widget_within/widget_atreturnSize; addreposition,draw_twice; deletesize_ctx,size,len_axis;primitive_atwritesmove_idx.core/src/ui/render_state.rs—draw_innercaptures and storesActiveData.size;movbecomes the O(1) offset write (§2); resize narrowing (§3a);redraw's per-axis loop readsActiveData.sizeinstead ofCache.size.core/src/ui/active.rs—ActiveDatagainssize: Size,move_slot: MoveIdx.core/src/ui/mod.rs—UiDatagainsmove_offsets.core/src/render/data.rs—PrimitiveInstancegainsmove_idx; newMoveOffsetstruct.core/src/render/primitive.rs— threadmove_idxthroughPrimitiveInstandPrimitives::write, matchingmask_idx.core/src/render/mod.rs— bind the newmove_offsetsstorage buffer (group 2, besidemasks) and its update path.core/src/render/shader.wgsl—InstanceInputgainsmove_idx;MoveOffset/UiScalar-shaped storage binding; a sharedresolve_movefunction (§2b) called from bothvs_main(a primitive's own corners) andfs_main(its mask's corners, onceMaskcarriesmove_idx).core/src/ui/render_state.rs— additionally,resolved_region(§2b) andwindow_region(:264-267) reimplemented on top of it.src/default/sense.rs—run_sensors's hit-test read (:170) switches fromself.active.get(id).unwrap().regiontoself.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) gainsmove_idx: u32(§2b).core/src/ui/painter.rs— additionally,set_mask(:49-52) writesmove_idx: self.move_slotinto theMaskit pushes (§2b).- Every widget with a two-method
impl Widget, collapsed to onedraw(§1, §6),is_size_independentadded 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 asRect),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 itsmov-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'sdraw_twicefor the cross-axis case, deletingdesired_ortho's duplicate loop),iris/src/widget/position/sized.rs(Sized). This list was produced bygrep -rn "impl Widget for\|fn desired_width\|fn desired_height"acrosscore/andsrc/; 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 directimpl Widgetfound in any example (verified by the same grep); they use the builder DSL incore/src/widget/trait_fns.rsand should need no source change, which is itself part of the pass condition below.
8. Pass conditions
- Every example under
iris/examplesrenders identically. Runiris/run-headless.sh EXAMPLE --shot PNGfor each ofminimal,task,view,tabsbefore 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. - Unchanged-frame cost, measured, not assumed. Add a counter beside
the existing
debug_layers/active_widgetsinstrumentation (render_state.rs:241-262) for (a)Widget::drawinvocations and (b)Primitives::write/region_mutcalls, both perupdate()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. - 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 toWidget::draw, 0 calls toregion_mut, independent of N. Construct the case with atabs-style example holding a deliberately large text block (hundreds of glyphs) inside aScroll, 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. - Hit-testing follows the move, not just the render. In the same
scrolled-
tabsconstruction as condition 3, scroll the content, then send a synthetic cursor position over a widget that moved and assertrun_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. - A mask moves with its subtree. Render a
Masked-wrappedScrollboth before and after scrolling it (iris/run-headless.shagainst a small purpose-built example, or an addition totabs), and diff the two frames: the clipped edge of the content must have moved with the scroll while the viewport's own border (drawn byMasked, 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. cargo test --workspace,cargo clippy --all-targets,cargo fmtstay 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 ordraw_twiceare non-trivial enough to want one, per "match the codebase's testing posture" — judge that once the code exists rather than pre-committing to a number of tests here).
9. Rejected, and why
- A flat (non-chained) per-subtree offset table, Iris's literal phrasing — rejected in §2 for breaking under nested independent moves (a swiped row inside a scrolling list). Costs nothing extra to avoid: the chain is the same mechanism with one more field.
- Keeping
region_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 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 savingis_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 patternTextView::renderalready uses (§4) — than by a second trait method every implementor has to reason about. - 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.
For IRIS.md
When this lands, copy this entry into IRIS.md (newest first):
2026-09-04 —
Widget::drawreports the size it used;desired_width/desired_heightare 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 intopainter.region()and returns how much of it was used. Why: the two extra methods routinely re-simulated whatdrawwas about to do anyway (Span::desired_orthocopied 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 returnedSize, and calls the newPainter::repositionto 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-sizeRect, a decodedImage) overrides the newfn is_size_independent(&self) -> bool { false }totrue, 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 { /* ... */ }
SizeCtxandCacheare gone with it — seeLAYOUT.mdfor the full design, the move-offset mechanism this shipped alongside, and the file list.