First of three steps agreed with Iris for getting scrolling out of the list and into `Scroll`, so that `.scrollable()` is the one way anything in iris scrolls. docs/IRIS_TODO.md's "In progress" block carries the whole plan and the decisions behind it; this step is the rename and the direction. `List` -> `LazySpan`, and it moves in beside `Span` under `widget/position/`. It is what `Span` is -- a sequence of children along an axis -- laid out lazily from an anchor instead of eagerly from the start, and the name says the one thing that matters about it. It also stops colliding with `BlockKind::List` in the markdown code. `ListRow` -> `LazyItem`; `RowKey` keeps its name, since rows are the vocabulary in transcript-ui. `Axis` -> `Dir`, with the sign meaning what it means in `Span`: which end of the box item 0 sits at. **That is a different question from which end the view is pinned to**, and conflating them would stand a transcript on its head -- its oldest message is item 0 and sits at the top (`Dir::DOWN`) while the view clings to the bottom. So the pin is its own constructor argument, `LazySpan::new(dir, at_end)`, spelled the same way as `Scroll::new`'s. Making `Dir::UP` real rather than nominal is most of the diff. The walk now works entirely in direction-relative pixels from the leading edge -- `Edge::Top`/`Bottom` are `Leading`/`Trailing`, `Placement` likewise, and `RowExtent`'s fields and every local are `lead`/`trail` -- with two places converting: `abs_region`, which flips the box for `Sign::Neg`, and `flip_pos`, which converts the screen-space positions the public helpers speak in (`note_tap`, `key_at`, `extent`, all fed by pointer events) into the walk's space. Without the second, a reversed span would hit-test at the mirror of where it drew. `a_dir_up_span_grows_upward_from_item_zero` asserts on where each row was **actually drawn** (`UiRenderState::active`), not on `extents`: the first version of it read `extent()` and passed with `abs_region`'s flip deleted -- checking the bookkeeping against itself while every row painted at the mirror of where it belonged. It now fails with the flip removed (row 2 at 80..100 instead of 0..20), which is the check that matters. Verified: cargo fmt --check, clippy --workspace --all-targets clean, cargo test --workspace green (21 suites), including the phone-shaped fixture tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1117 lines
65 KiB
Markdown
1117 lines
65 KiB
Markdown
# 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
|
||
|
||
```rust
|
||
pub trait Widget: Any {
|
||
/// Draw within `painter.region()` (the space the parent offered) and
|
||
/// report how much of it was actually used, per axis.
|
||
fn draw(&mut self, painter: &mut Painter) -> Size;
|
||
|
||
/// True if `draw`'s output (both the primitives it writes and the
|
||
/// `Size` it returns) is the same for any `painter.region()` of the
|
||
/// same *content* -- an icon, a fixed-size rect, an already-decoded
|
||
/// image at its natural size. Default `false` (redraw on any change to
|
||
/// the offered region) because assuming independence wrongly produces
|
||
/// a stale draw; a widget must opt in.
|
||
fn is_size_independent(&self) -> bool {
|
||
false
|
||
}
|
||
}
|
||
```
|
||
|
||
No `available` parameter: `Painter` already carries the region the parent
|
||
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already
|
||
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface
|
||
size (`output_size()`, `:152`). Passing it again would be the same value
|
||
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`)
|
||
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
|
||
deprecated, not kept as a fallback — because a widget that implements both
|
||
`draw` and `desired_*` for the same thing is exactly the "two names for one
|
||
concept" the code rules call out, and it is what today's `Span::desired_ortho`
|
||
(`iris/src/widget/position/span.rs:98-152`) already complains about in its
|
||
own comment: "this literally copies draw so that the lengths are correctly
|
||
set in the context, which makes this slow and not cool." Folding sizing into
|
||
`draw` deletes that duplicate simulation, not just moves it.
|
||
|
||
**No single-draw alternative was found that does less work per frame.** The
|
||
two-method trait was checked against three properties a real screen needs —
|
||
a row placing children in sequence, a widget centering on its own content,
|
||
and wrapped text — and in every one, `draw` already has to visit the child
|
||
to get a size that is *this specific one's* answer, which today's
|
||
`desired_width`/`desired_height` re-derive by re-running (a shrunk copy of)
|
||
the same layout the draw pass will do again. So the two-method trait is not
|
||
"measure once, draw once" in the general case; it is "measure once per axis,
|
||
then draw once," i.e. up to three visits per widget per frame, against one
|
||
under the design here. The single-draw model is therefore adopted as
|
||
proposed, not merely accepted as a preference.
|
||
|
||
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
|
||
|
||
**What exists today, and why it is not O(1).** `UiRenderState::mov`
|
||
(`core/src/ui/render_state.rs:156-168`) fires when a widget's region keeps
|
||
its *size* but changes *position* (`draw_inner`, `:85-100`:
|
||
`active.region.size() == region.size()` after excluding the exact-match
|
||
case). It rewrites every primitive's `region` field via
|
||
`Primitives::region_mut` (`core/src/render/primitive.rs:176-179`) for the
|
||
widget's own primitives, then recurses into every child — O(primitives in
|
||
the subtree). Both call sites that trigger it today, `Scroll::draw`
|
||
(`iris/src/widget/position/scroll.rs:29-31`) and `Offset::draw`
|
||
(`iris/src/widget/position/offset.rs:9-11`), are "translate this subtree by
|
||
an abs pixel amount, `rel` framing unchanged" — a transcript scroll
|
||
re-touches every glyph in every visible row, every frame of the drag, and
|
||
I3's target is 800 rows on screen.
|
||
|
||
**Recommendation: a per-widget offset slot forming a parent-linked chain,
|
||
resolved in the vertex shader.**
|
||
|
||
- `UiData` (`core/src/ui/mod.rs:14-20`) gains
|
||
`pub move_offsets: TrackedArena<MoveOffset, u32>`, the same arena shape
|
||
already used for `masks: TrackedArena<Mask, u32>` on the line above it.
|
||
- `render/data.rs` gains `pub struct MoveOffset { pub delta: [f32; 2], pub
|
||
parent: u32 }` (`Pod`/`Zeroable`, `parent = u32::MAX` = "no ancestor,
|
||
add nothing more"). A pure abs-pixel translation, not a general
|
||
`UiRegion` remap — sufficient for every existing call site (above).
|
||
- `PrimitiveInstance` (`render/data.rs:11-18`) gains `pub move_idx: u32`,
|
||
a vertex attribute at `@location(7)` beside `mask_idx` at `6` — the same
|
||
kind of per-instance handle.
|
||
- `ActiveData` (`core/src/ui/active.rs`) gains `pub move_slot: MoveIdx`,
|
||
assigned **when the widget is first drawn** (`draw_inner`, beside
|
||
`active.insert`), with `parent` = the drawing widget's parent's slot.
|
||
`Painter` threads a `move_slot` field down exactly as it already threads
|
||
`mask` and `layer` (`painter.rs:9-20`), so a freshly-drawn descendant is
|
||
correct from its first frame — nothing is ever retrofitted onto an
|
||
already-active primitive. An unmoved widget's slot just stays `[0, 0]`.
|
||
- `Painter::primitive_at` (`painter.rs:23-38`) writes `move_idx:
|
||
self.move_slot`, matching how it already writes `mask_idx: self.mask`.
|
||
- `mov(id, delta)` becomes: look up `id`'s slot, write
|
||
`move_offsets[slot].delta += delta`. One write — no primitive touched, no
|
||
recursion, since descendants already reference this slot transitively.
|
||
- `shader.wgsl`'s vertex stage, after computing `top_left`/`bot_right` in
|
||
pixels (after `:106`, before the clip-space divide at `:113`), walks
|
||
`move_idx → move_offsets[i].parent` for a bounded number of steps (a
|
||
small constant, e.g. 16, with a CPU-side debug assertion that no chain
|
||
exceeds it), summing `delta` into both corners. Cost is O(chain depth),
|
||
paid every frame regardless of whether anything moved — negligible next
|
||
to the per-fragment texture sampling TEXTURES.md already measures this
|
||
GPU as not bound by.
|
||
|
||
**Why the chain, not the flatter thing first proposed.** Iris's own
|
||
phrasing — "every instance carries an index into a small per-widget offset
|
||
buffer" — describes a flat table: one slot per subtree *declared* movable,
|
||
no parent link. It breaks the moment two such subtrees nest — a row inside
|
||
a scrolling list, itself later given its own animated offset (a
|
||
swipe-to-delete mid-scroll) — because the row's primitives would have to
|
||
pick one slot and lose the other's contribution. The chain costs one extra
|
||
field and a bounded shader loop in exchange for no such gap, and since
|
||
every `ActiveData` gets a slot unconditionally rather than lazily, it costs
|
||
no more at the common depth of one than the flat version would.
|
||
|
||
**Against `region_mut` as the steady-state mechanism**: rejected for being
|
||
O(primitives in the subtree) — the cost this section removes — but kept
|
||
for a resize that changes a region's `rel` component (a genuine reflow,
|
||
§3) and for a size-independent widget's resize (§3), where the content's
|
||
shape doesn't change and one field write already suffices.
|
||
|
||
### 2b. Two more readers of "where is this widget," and masks
|
||
|
||
Moving the offset into the vertex shader means `ActiveData.region` is no
|
||
longer the on-screen truth once a widget has been moved — it is where the
|
||
widget was *drawn*, before any `move_offsets` delta. Two things read it as
|
||
if it still were, and both must move to a resolved query or they silently
|
||
answer with the pre-move position: a click landing on a scrolled row would
|
||
be routed to whatever used to be there, with nothing on screen to say so —
|
||
exactly the "wrong answer that looks like a right one" case the code rules
|
||
single out.
|
||
|
||
**Hit-testing.** `SensorUi::run_sensors` (`src/default/sense.rs:154-200`)
|
||
does the actual pointer routing, and line 170 is the read in question:
|
||
`let shape = self.active.get(id).unwrap().region;` (`self: &UiRenderState`),
|
||
immediately turned into pixels and tested against the cursor at `:171-172`.
|
||
Under this design that region must be resolved through the same chain the
|
||
GPU walks before it means anything. Add to `UiRenderState`:
|
||
|
||
```rust
|
||
/// `active[id].region`, corrected by every `move_offsets` delta between
|
||
/// `id` and the root — the CPU-side twin of the vertex shader's chain
|
||
/// walk, over the same arena, so the two cannot disagree about where a
|
||
/// widget is. O(chain depth), not O(primitives): a plain Rust loop over
|
||
/// `move_offsets`, bounded by the same constant the shader loop uses
|
||
/// (name it once, e.g. `render::MOVE_CHAIN_LIMIT`, and reference it from
|
||
/// the WGSL loop bound in a comment, since WGSL cannot `include!` a Rust
|
||
/// const across the language boundary).
|
||
pub fn resolved_region(&self, id: WidgetId) -> UiRegion;
|
||
```
|
||
|
||
`window_region` (`core/src/ui/render_state.rs:264-267`), the public
|
||
coordinate query already used outside hit-testing
|
||
(`src/default/attr.rs:15,17,70`, e.g. positioning one widget relative to
|
||
another's on-screen box), is reimplemented to call `resolved_region(id)`
|
||
before `.to_px(...)` instead of reading `.region` directly — one change
|
||
covers both call sites listed there. `sense.rs:170` changes to
|
||
`let shape = self.resolved_region(*id);`. Both are required the moment §2
|
||
lands, not an optional follow-up: an unmoved widget's chain is empty and
|
||
`resolved_region` costs one arena read to find that out, so there is no
|
||
version of this design where skipping the fix is a legitimate
|
||
optimization — it is a correctness gap, not a performance one.
|
||
|
||
**Masks.** `Painter::set_mask` (`core/src/ui/painter.rs:49-52`) bakes the
|
||
painter's *current* region into a `Mask` pushed onto
|
||
`masks: TrackedArena<Mask, u32>` (`core/src/ui/mod.rs:19`), and the
|
||
fragment shader clips every primitive against `masks[in.mask_idx]`'s raw
|
||
`rel`/`abs` fields, unaffected by any move (`shader.wgsl:147-157`). If the
|
||
widget that called `set_mask` — `Masked::draw`,
|
||
`iris/src/widget/mask.rs:7-11`, `painter.set_mask(painter.region()); ...` —
|
||
is itself later moved, its clip rectangle stays where it was drawn while
|
||
its content moves out from under it: a visibly wrong clip, immediately on
|
||
screen, not a latency question.
|
||
|
||
Fix: `Mask` (`core/src/render/data.rs:46-49`) gains `pub move_idx: u32`,
|
||
written from `Painter::set_mask` as `self.move_slot` — the identical slot
|
||
the mask-owning widget's own primitives already get (§2), not a second
|
||
mechanism. Resolution happens in the **fragment** shader, not the CPU, and
|
||
not the vertex shader either: `shader.wgsl`'s mask check (`:147-157`)
|
||
currently computes the mask's `top_left`/`bot_right` inline from
|
||
`masks[in.mask_idx]`; that computation is extended to walk the same
|
||
move-offset chain §2 added, via one shared function —
|
||
|
||
```wgsl
|
||
fn resolve_move(idx: u32) -> vec2<f32> { /* the bounded parent walk, used by both stages */ }
|
||
```
|
||
|
||
— called from `vs_main` for a primitive's own corners and from `fs_main`
|
||
for its mask's corners, so the walk is written once and the two stages
|
||
cannot drift apart (the sibling-rule from the code rules: one loop, not a
|
||
hand-copied second one in the other shader stage).
|
||
|
||
**Why the fragment shader, not a CPU-side mask rewrite at move time.** A
|
||
primitive's mask is frequently owned by a *different* widget than the
|
||
primitive itself — often several levels up a subtree, with its own,
|
||
independent move slot — so a primitive's resolved offset and its mask's
|
||
resolved offset are two different chain sums, both needed, and only the
|
||
fragment shader has both `in.move_idx` (this fragment's own chain) and
|
||
`in.mask_idx` (indirecting to a second, possibly unrelated chain) already
|
||
in hand per-fragment. Resolving mask regions on the CPU at move time would
|
||
mean, for every `mov()` call, walking forward to every mask instance the
|
||
moved widget's slot could affect and rewriting its raw region — exactly
|
||
the O(subtree) cost §2 exists to remove, just moved from primitives to
|
||
masks. The fragment shader already re-reads `masks[in.mask_idx]` every
|
||
frame (`:148`); one more arena read to resolve its chain costs nothing
|
||
extra in kind.
|
||
|
||
**The scroll-container case, checked rather than assumed.** A masked,
|
||
scrollable region is built as a `Masked` wrapping a `Scroll`
|
||
(`iris/src/widget/position/scroll.rs`, `iris/src/widget/mask.rs`) — the
|
||
viewport border is drawn (and `set_mask` called) by `Masked`, which is
|
||
never itself the target of `mov()`; only `Scroll`'s inner content is,
|
||
every frame the user drags. Because each widget's move slot is its own
|
||
(§2: assigned per `ActiveData`, not shared), `Masked`'s mask references
|
||
its own, stationary slot, while the scrolled content underneath references
|
||
a separate, deeper slot whose `parent` chain passes through — but does not
|
||
write to — the viewport's slot. Moving the content therefore never touches
|
||
the mask's resolved position, and the mask staying still while its content
|
||
slides past it is what this design already produces with no special case,
|
||
not an extra rule that had to be added for it.
|
||
|
||
### 3. Resize scope
|
||
|
||
A resize is "the region a widget's parent offers it changes such that the
|
||
widget's draw might produce different output" — as opposed to a move, which
|
||
by construction cannot (§2 is scoped to pure translation). Two independent
|
||
narrowings apply, and both are real, measured properties of the code as it
|
||
stands rather than new machinery:
|
||
|
||
**(a) A window resize does not, by itself, require touching most widgets.**
|
||
`shader.wgsl:105-106` recomputes every primitive's pixel position from
|
||
`window.dim` and the primitive's stored `rel`/`abs` pair *every frame,
|
||
already, on the GPU*. A widget laid out purely in `rel`/`abs` terms (no
|
||
call to `px_size()`, `output_size()`, or anything else that reads a
|
||
concrete pixel count) is therefore already correct after a resize with zero
|
||
CPU work — the shader did it. `UiRenderState::needs_redraw_all`
|
||
(`render_state.rs:229-231`) currently ignores this and redraws the entire
|
||
tree on every `resized`, which was the safe default while sizing and
|
||
drawing were two passes; it should be narrowed to only the widgets that
|
||
*do* read a concrete pixel value. Track this the same way `needs_redraw`
|
||
already tracks per-widget dirtiness (`Widgets::needs_redraw`,
|
||
`core/src/widget/widgets.rs:9`): a widget's `draw` call marks itself
|
||
pixel-dependent by calling through `Painter` methods that read
|
||
`output_size`/`px_size` (both already funnel through `Painter`, so the
|
||
marking is one line at each), and `resize()` (`render_state.rs:32-35`)
|
||
walks only that set instead of unconditionally setting `resized = true`
|
||
for a full `redraw_all`. This turns "every resize redraws everything" into
|
||
"every resize redraws what depends on pixels" — a real behavior change
|
||
beyond what was asked, so verify it against the I0b `pre_present_notify`
|
||
resize regression (that fix depended on `redraw_all`'s completeness)
|
||
before narrowing this.
|
||
|
||
**(b) A widget's `available` (its parent's offered region) can change
|
||
without the widget's *content* changing — this is what
|
||
`is_size_independent` (§1) answers.** When a container's own layout shifts
|
||
(a sibling grew or shrank, changing this widget's offered box), a widget
|
||
that returns `true` from `is_size_independent` is not redrawn: its
|
||
primitives are unaffected by size, only by placement, so the parent
|
||
either (i) issues a move (§2) if only position changed, or (ii) rewrites
|
||
the primitive's `region` fields directly via `region_mut` if the box
|
||
changed shape too (still O(primitives owned directly by this widget, not
|
||
its subtree, since a size-independent widget by definition has no
|
||
size-dependent descendants worth distinguishing — in practice this is
|
||
always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns
|
||
`false` (the default) is redrawn in full whenever `available` changes,
|
||
which is correct always, just not free.
|
||
|
||
**Ancestor propagation** (a resized child changing its own reported size,
|
||
requiring its parent to re-lay-out) is unchanged in spirit from today's
|
||
`redraw` (`render_state.rs:270-305`), which already walks up exactly the
|
||
ancestors whose cached size differs from the new one and stops as soon as
|
||
a size is unchanged (`:274-286`). That loop moves from consulting
|
||
`Cache.size` to consulting `ActiveData.size` (§5) but keeps its shape.
|
||
|
||
### 4. Wrapped text, and "needs child height before choosing width"
|
||
|
||
**Wrapped text is not a special case any more; it already reads as one
|
||
draw.** `TextView::render` (`iris/src/widget/text/mod.rs:57-76`) already
|
||
does exactly what single-draw asks for: it reads `ctx.px_size().x` as the
|
||
wrap width, shapes once, and memoizes the shaped layout keyed on that width
|
||
plus a changed-flag on the buffer and attrs (`:63-69`) — a second call with
|
||
the same width is a hash-map-style cache hit, not a re-shape. Under the new
|
||
trait this collapses `Text::draw`/`desired_width`/`desired_height`
|
||
(`text/mod.rs:133-147`, three functions) into one `Text::draw` that calls
|
||
`self.view.draw(painter)` once, which internally still calls `render`
|
||
once, hits its own cache, and returns the size it already computed. No
|
||
new caching is needed here; the two now-redundant call sites
|
||
(`desired_width`/`desired_height` each separately calling `render`) simply
|
||
disappear, which is a second `render` avoided per frame per text widget
|
||
that is being measured by a parent.
|
||
|
||
**"Parent wants the child's height before deciding the width it will
|
||
offer"** — the genuinely circular case named in the brief, e.g. a column
|
||
that sizes its own width to its widest child, where that child is wrapped
|
||
text whose height (which the column's *own* height depends on) depends on
|
||
the width the column has not yet decided. This is not solvable in one pass
|
||
for the same reason it is not solvable in CSS shrink-to-fit with wrapped
|
||
content: the two axes' answers are mutually dependent. `Span::desired_ortho`
|
||
(`span.rs:98-136`) already hits exactly this today and already resolves it
|
||
by an explicit second, throwaway pass (its own comment: "this literally
|
||
copies draw ... which makes this slow and not cool"). The design keeps that
|
||
resolution, made explicit rather than accidental: `Painter` gets
|
||
|
||
```rust
|
||
/// Draw `child` at a provisional region to learn its size under one
|
||
/// axis's worth of assumption, discard everything it wrote, then draw it
|
||
/// again at the region that assumption produced. For the rare parent that
|
||
/// cannot pick an offered size without already knowing the answer.
|
||
/// Twice the cost of one `draw`; every other case in this file avoids it.
|
||
pub fn draw_twice(&mut self, child: &StrongWidget, first: UiRegion, second: impl FnOnce(Size) -> UiRegion) -> Size;
|
||
```
|
||
|
||
implemented as: draw at `first`, record `Size`, remove the widget and its
|
||
subtree the same way a resize-triggered redraw already does (`draw_inner`'s
|
||
"if not \[same region\], maintain resize and track old children," `:97-100`,
|
||
which already frees the old primitives before redrawing) — reusing that
|
||
path rather than adding a second one — draw again at `second(size)`, return
|
||
the final `Size`. It is opt-in and named for its cost, so a widget only
|
||
pays it if it is the one that needs it; `Span`'s cross-axis case is the one
|
||
call site converted to it, replacing the hand-rolled duplicate loop.
|
||
|
||
### 5. Caching and invalidation
|
||
|
||
`Cache.size` (`core/src/ui/cache.rs`) is **deleted, not replaced with an
|
||
equivalent** — the thing it memoized (a `desired_width`/`desired_height`
|
||
answer, independent of drawing) no longer exists as a separate query, so
|
||
there is nothing left to cache at that layer. What already provides "an
|
||
unchanged subtree costs nothing" is the check `draw_inner` performs before
|
||
touching a widget at all (`render_state.rs:85-90`): if the widget is active,
|
||
its region is unchanged, and it is not marked dirty, `draw_inner` returns
|
||
immediately — no `Painter` constructed, no primitive touched, no shader
|
||
work beyond what the GPU already redraws from the unchanged instance
|
||
buffer. That check is kept exactly as it is; it is the caching mechanism,
|
||
and it already operates at (id, region) granularity, which subsumes "(id,
|
||
available size)" once size *is* what a region change means.
|
||
|
||
What is added: `ActiveData` gains `pub size: Size` — the value `draw`
|
||
returned, stored the moment it is (`draw_inner`, alongside building the
|
||
`ActiveData` struct at `:134-143`). This is what a parent placing this
|
||
widget for a second frame without redrawing it (because nothing changed)
|
||
reads instead of recomputing — it replaces `Cache.size`'s role of "answer a
|
||
size question without a full draw" with "read the size of the last actual
|
||
draw," which is always available because `draw_inner`'s skip path is only
|
||
reachable once the widget has been drawn at least once. `Cache::remove`/
|
||
`Cache::clear` (`cache.rs:9-17`) are deleted with the type; `ActiveData`
|
||
already has an equivalent lifecycle (removed in `remove`/`remove_rec`,
|
||
`render_state.rs:171-198`, freed with the widget).
|
||
|
||
### 6. Before / after
|
||
|
||
**A leaf, `iris/src/widget/rect.rs`** — the size-independent case:
|
||
|
||
```rust
|
||
// before
|
||
impl Widget for Rect {
|
||
fn draw(&mut self, painter: &mut Painter) {
|
||
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
||
thickness: self.thickness, inner_radius: self.inner_radius });
|
||
}
|
||
fn desired_width(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
||
fn desired_height(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
||
}
|
||
```
|
||
|
||
```rust
|
||
// after
|
||
impl Widget for Rect {
|
||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
||
thickness: self.thickness, inner_radius: self.inner_radius });
|
||
Size::REST // fills whatever it was given -- used == available
|
||
}
|
||
fn is_size_independent(&self) -> bool { true } // content never depends on region size
|
||
}
|
||
```
|
||
|
||
**A container that needs the child's size before placing it,
|
||
`iris/src/widget/position/align.rs`**:
|
||
|
||
```rust
|
||
// before
|
||
impl Widget for Aligned {
|
||
fn draw(&mut self, painter: &mut Painter) {
|
||
let region = match self.align.tuple() {
|
||
(Some(x), Some(y)) => painter.size(&self.inner).to_uivec2().align(RegionAlign { x, y }),
|
||
(Some(x), None) => { let x = painter.size_ctx().width(&self.inner).apply_rest().align(x);
|
||
UiRegion::new(x, UiSpan::FULL) }
|
||
(None, Some(y)) => { let y = painter.size_ctx().height(&self.inner).apply_rest().align(y);
|
||
UiRegion::new(UiSpan::FULL, y) }
|
||
(None, None) => UiRegion::FULL,
|
||
};
|
||
painter.widget_within(&self.inner, region);
|
||
}
|
||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { ctx.width(&self.inner) }
|
||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { ctx.height(&self.inner) }
|
||
}
|
||
```
|
||
|
||
```rust
|
||
// after
|
||
impl Widget for Aligned {
|
||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||
let full = painter.region();
|
||
// Draw once at the full region to learn the child's real size --
|
||
// this placement is provisional and corrected below without a
|
||
// second draw.
|
||
let used = painter.widget_within(&self.inner, full);
|
||
let region = match self.align.tuple() {
|
||
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }).within(&full),
|
||
(Some(x), None) => used.x.apply_rest().align(x).within(&full),
|
||
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
|
||
(None, None) => full,
|
||
};
|
||
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
|
||
used
|
||
}
|
||
}
|
||
```
|
||
|
||
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
|
||
return type from `()` to `Size`, carrying the child's `draw` result back —
|
||
the only signature change needed to let a parent see what its child used.
|
||
`Painter::reposition` is new, computing the delta between where a child
|
||
was actually drawn and where it belongs and calling the O(1) `mov` from
|
||
§2. `SizeCtx` and `Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
|
||
180-182`) are deleted — nothing calls `desired_len` any more, so there is
|
||
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
|
||
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
|
||
today (compare `size.rs:71-90` against `painter.rs:152-174`) and this
|
||
deletes the `SizeCtx` copies, keeping the `Painter` ones.
|
||
|
||
### 7. Migration — every file and widget that changes
|
||
|
||
One change, in dependency order (rename-and-move-together, per the code
|
||
rules — no intermediate state with both trait shapes):
|
||
|
||
- `core/src/widget/mod.rs` — the `Widget` trait (§1), delete
|
||
`WidgetAxisFns`, update `impl Widget for ()`.
|
||
- `core/src/ui/size.rs` — delete `SizeCtx` (the type and all its methods).
|
||
- `core/src/ui/cache.rs` — delete `Cache` (§5).
|
||
- `core/src/ui/painter.rs` — `widget`/`widget_within`/`widget_at` return
|
||
`Size`; add `reposition`, `draw_twice`; delete `size_ctx`, `size`,
|
||
`len_axis`; `primitive_at` writes `move_idx`.
|
||
- `core/src/ui/render_state.rs` — `draw_inner` captures and stores
|
||
`ActiveData.size`; `mov` becomes the O(1) offset write (§2); resize
|
||
narrowing (§3a); `redraw`'s per-axis loop reads `ActiveData.size`
|
||
instead of `Cache.size`.
|
||
- `core/src/ui/active.rs` — `ActiveData` gains `size: Size`,
|
||
`move_slot: MoveIdx`.
|
||
- `core/src/ui/mod.rs` — `UiData` gains `move_offsets`.
|
||
- `core/src/render/data.rs` — `PrimitiveInstance` gains `move_idx`;
|
||
new `MoveOffset` struct.
|
||
- `core/src/render/primitive.rs` — thread `move_idx` through `PrimitiveInst`
|
||
and `Primitives::write`, matching `mask_idx`.
|
||
- `core/src/render/mod.rs` — bind the new `move_offsets` storage buffer
|
||
(group 2, beside `masks`) and its update path.
|
||
- `core/src/render/shader.wgsl` — `InstanceInput` gains `move_idx`;
|
||
`MoveOffset`/`UiScalar`-shaped storage binding; a shared `resolve_move`
|
||
function (§2b) called from both `vs_main` (a primitive's own corners)
|
||
and `fs_main` (its mask's corners, once `Mask` carries `move_idx`).
|
||
- `core/src/ui/render_state.rs` — additionally, `resolved_region` (§2b)
|
||
and `window_region` (`:264-267`) reimplemented on top of it.
|
||
- `src/default/sense.rs` — `run_sensors`'s hit-test read (`:170`) switches
|
||
from `self.active.get(id).unwrap().region` to `self.resolved_region(*id)`
|
||
(§2b) — the pointer-routing fix this design requires, not an optional
|
||
follow-up.
|
||
- `core/src/render/data.rs` — additionally, `Mask` (`:46-49`) gains
|
||
`move_idx: u32` (§2b).
|
||
- `core/src/ui/painter.rs` — additionally, `set_mask` (`:49-52`) writes
|
||
`move_idx: self.move_slot` into the `Mask` it pushes (§2b).
|
||
- Every widget with a two-method `impl Widget`, collapsed to one `draw`
|
||
(§1, §6), `is_size_independent` added where true: `core/src/widget/mod.rs`
|
||
(`impl Widget for ()`), `iris/src/widget/rect.rs` (`Rect`, → true),
|
||
`iris/src/widget/image.rs` (`Image`, → true — a decoded image's primitive
|
||
never depends on the region it is offered, same as `Rect`),
|
||
`iris/src/widget/mask.rs` (`Masked`), `iris/src/widget/ptr.rs`
|
||
(`WidgetPtr`), `iris/src/widget/text/mod.rs` (`Text`, §4),
|
||
`iris/src/widget/text/edit.rs` (`TextEdit`),
|
||
`iris/src/widget/position/scroll.rs` (`Scroll`, keeps its `mov`-shaped
|
||
offset, now O(1) automatically via §2), `iris/src/widget/position/align.rs`
|
||
(`Aligned`, §6), `iris/src/widget/position/max_size.rs` (`MaxSize`),
|
||
`iris/src/widget/position/layer.rs` (`LayerOffset`),
|
||
`iris/src/widget/position/pad.rs` (`Pad`),
|
||
`iris/src/widget/position/stack.rs` (`Stack`),
|
||
`iris/src/widget/position/offset.rs` (`Offset`),
|
||
`iris/src/widget/position/span.rs` (`Span`, §4's `draw_twice` for the
|
||
cross-axis case, deleting `desired_ortho`'s duplicate loop),
|
||
`iris/src/widget/position/sized.rs` (`Sized`).
|
||
This list was produced by `grep -rn "impl Widget for\|fn desired_width\|fn desired_height"`
|
||
across `core/` and `src/`; re-run it before starting, since it is the
|
||
authoritative check that nothing was missed, not this paragraph.
|
||
- `iris/examples/{minimal.rs,task.rs,view.rs,tabs/main.rs}` — no direct
|
||
`impl Widget` found in any example (verified by the same grep); they use
|
||
the builder DSL in `core/src/widget/trait_fns.rs` and should need no
|
||
source change, which is itself part of the pass condition below.
|
||
|
||
### 8. Pass conditions
|
||
|
||
1. **Every example under `iris/examples` renders identically.** Run
|
||
`iris/run-headless.sh EXAMPLE --shot PNG` for each of `minimal`, `task`,
|
||
`view`, `tabs` before and after, and diff the PNGs pixel-for-pixel — not
|
||
"looks right," since a subtle wrap or alignment regression is exactly
|
||
what a diff catches and a glance does not.
|
||
|
||
**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 2–5 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`, `LazySpan::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.
|
||
>
|
||
> ```rust
|
||
> // before
|
||
> fn draw(&mut self, painter: &mut Painter) { /* ... */ }
|
||
> fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||
> fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||
>
|
||
> // after
|
||
> fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
|
||
> ```
|
||
>
|
||
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
||
> design, the move-offset mechanism this shipped alongside, and the file
|
||
> list.
|
||
|
||
## 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 `UiSpan`s 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.
|
||
|
||
## 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 `reposition` is not the fix and cannot be: it writes an offset, never a
|
||
size.
|
||
|
||
The cost is bounded and worth stating, because it is what makes the rule
|
||
safe to apply everywhere: the second draw happens only on the frame a
|
||
widget's own size actually changes, which is a frame that was already
|
||
redrawing it. 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.
|