676 lines
38 KiB
Markdown
676 lines
38 KiB
Markdown
# iris: one `draw` that reports a size
|
|
|
|
Iris, 2026-09-04:
|
|
|
|
> I don't like that widgets need both a draw and size functions. I'd much
|
|
> rather them have a single draw that reports a size, and if it needs to be
|
|
> moved then that can be done after the fact efficiently, or resized just
|
|
> done after as well. This should be done efficiently like everything else
|
|
> tries to do right now.
|
|
|
|
**Implemented 2026-09-04.** Every widget was migrated in one change; none
|
|
kept `desired_width`/`desired_height`. What is kept below is the design as
|
|
it stands, the five corrections implementation forced (read those before
|
|
touching `Aligned`, `Sized`, `MaxSize`, `Scroll` or the move-slot lifecycle
|
|
in `render_state.rs` -- each is a real bug the first draft would have
|
|
reproduced), and the two later additions that build on it. The
|
|
pre-implementation framing -- what the old trait looked like, the checklist
|
|
the design had to answer, the migration list, the pass conditions and the
|
|
"copy this into the design log" note -- was deleted on 2026-09-08,
|
|
having been carried out.
|
|
|
|
## Design
|
|
|
|
### 1. The new `Widget` trait
|
|
|
|
```rust
|
|
pub trait Widget: Any {
|
|
fn draw(&mut self, painter: &mut Painter) -> Size;
|
|
|
|
fn size_hint(&self, axis: Axis) -> Option<Len> { None }
|
|
|
|
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.
|
|
|
|
`size_hint` is not a second layout pass. It is an optional exact answer for
|
|
an axis the widget declares without painter context or child access. A
|
|
lying hint fails a debug assertion when the widget is drawn.
|
|
|
|
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
|
|
|
|
**What exists today, and why it is not O(1).** `UiRenderState::mov`
|
|
(`core/src/ui/render_state.rs:156-168`) fires when a widget's region keeps
|
|
its *size* but changes *position* (`draw_inner`, `:85-100`:
|
|
`active.region.size() == region.size()` after excluding the exact-match
|
|
case). It rewrites every primitive's `region` field via
|
|
`Primitives::region_mut` (`core/src/render/primitive.rs:176-179`) for the
|
|
widget's own primitives, then recurses into every child — O(primitives in
|
|
the subtree). Both call sites that trigger it today, `Scroll::draw`
|
|
(`iris/src/widget/position/scroll.rs:29-31`) and `Offset::draw`
|
|
(`iris/src/widget/position/offset.rs:9-11`), are "translate this subtree by
|
|
an abs pixel amount, `rel` framing unchanged" — a transcript scroll
|
|
re-touches every glyph in every visible row, every frame of the drag, and
|
|
I3's target is 800 rows on screen.
|
|
|
|
**Recommendation: a per-widget offset slot forming a parent-linked chain,
|
|
resolved in the vertex shader.**
|
|
|
|
- `UiData` (`core/src/ui/mod.rs:14-20`) 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.
|
|
- A container may also retain one optional **child-coordinate slot** between
|
|
its own slot and every direct child's slot. `Painter::set_child_offset`
|
|
creates that boundary before the first child is drawn and can update it
|
|
after measuring a child on later redraws. The container's own primitives,
|
|
hit region and mask stay fixed; its whole child subtree moves through one
|
|
write and every existing GPU, hit-test and accessibility chain sees the
|
|
same result. `LazySpan` uses this while still walking visible rows for
|
|
virtualisation: row boxes stay in stable local coordinates and the shared
|
|
boundary carries the changing screen translation.
|
|
- `shader.wgsl`'s vertex stage, after 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.
|
|
|
|
Provisional layout can still write an instance at an intermediate position
|
|
and restore it before upload. `Primitives::set_instance` remembers the value
|
|
at the first write in a frame and clears the dirty bit when the final bytes
|
|
match it. The GPU therefore observes final layout state, not CPU-only
|
|
measurement work.
|
|
|
|
### 2b. Two more readers of "where is this widget," and masks
|
|
|
|
Moving the offset into the vertex shader means `ActiveData.region` is no
|
|
longer the on-screen truth once a widget has been moved — it is where the
|
|
widget was *drawn*, before any `move_offsets` delta. Two things read it as
|
|
if it still were, and both must move to a resolved query or they silently
|
|
answer with the pre-move position: a click landing on a scrolled row would
|
|
be routed to whatever used to be there, with nothing on screen to say so —
|
|
exactly the "wrong answer that looks like a right one" case the code rules
|
|
single out.
|
|
|
|
**Hit-testing.** `SensorUi::run_sensors` (`src/default/sense.rs:154-200`)
|
|
does the actual pointer routing, and line 170 is the read in question:
|
|
`let shape = self.active.get(id).unwrap().region;` (`self: &UiRenderState`),
|
|
immediately turned into pixels and tested against the cursor at `:171-172`.
|
|
Under this design that region must be resolved through the same chain the
|
|
GPU walks before it means anything. Add to `UiRenderState`:
|
|
|
|
```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.
|
|
|
|
`Span` has no size-only pass. It first reads exact, context-free
|
|
`Widget::size_hint(axis)` values. It then draws unknown fixed children
|
|
forward from the current cursor, retaining what they paint. Once every
|
|
length is known, flexible space is allocated and `Painter::place` moves
|
|
each retained child into its final box. A child is redrawn only when that
|
|
box changes the size it was drawn for.
|
|
|
|
Hints are optional and affect cost, never correctness. `Sized` can report
|
|
its declared axis without inspecting its child, which covers the important
|
|
`.height(rest())` case. A debug assertion compares every hint with the
|
|
eventual `draw` result. Widgets whose answer depends on shaping or on a
|
|
child return `None`.
|
|
|
|
### 5. Caching and invalidation
|
|
|
|
`Cache.size` (`core/src/ui/cache.rs`) is **deleted, not replaced with an
|
|
equivalent** — the thing it memoized (a `desired_width`/`desired_height`
|
|
answer, independent of drawing) no longer exists as a separate query, so
|
|
there is nothing left to cache at that layer. What already provides "an
|
|
unchanged subtree costs nothing" is the check `draw_inner` performs before
|
|
touching a widget at all (`render_state.rs:85-90`): if the widget is active,
|
|
its region is unchanged, and it is not marked dirty, `draw_inner` returns
|
|
immediately — no `Painter` constructed, no primitive touched, no shader
|
|
work beyond what the GPU already redraws from the unchanged instance
|
|
buffer. That check is kept exactly as it is; it is the caching mechanism,
|
|
and it already operates at (id, region) granularity, which subsumes "(id,
|
|
available size)" once size *is* what a region change means.
|
|
|
|
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.place(&self.inner, region);
|
|
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::place` moves an already-drawn child when its used area fits the
|
|
target box, and redraws it when the target changes its size. `SizeCtx` and
|
|
`Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
|
|
180-182`) are deleted — nothing calls `desired_len` any more, so there is
|
|
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
|
|
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
|
|
today (compare `size.rs:71-90` against `painter.rs:152-174`) and this
|
|
deletes the `SizeCtx` copies, keeping the `Painter` ones.
|
|
|
|
### 7. Rejected, and why
|
|
|
|
- **A flat (non-chained) per-subtree offset table**, Iris's literal
|
|
phrasing — rejected in §2 for breaking under nested independent moves
|
|
(a swiped row inside a scrolling list). Costs nothing extra to avoid: the
|
|
chain is the same mechanism with one more field.
|
|
- **Keeping `region_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 general measurement API** — rejected because it walks the same nested
|
|
tree again. The narrow `size_hint(axis)` contract is exact,
|
|
context-free, and optional; it exists only for sizes a widget already
|
|
declares itself.
|
|
- **Passing `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.
|
|
|
|
## Density: `Len::dp`, resolved at `apply_rest` time (2026-09-06)
|
|
|
|
Iris asked for a third length kind beside `abs` (physical pixels) and
|
|
`rel`/`rest` (a fraction of the parent) — IRIS_TODO.md's "density-
|
|
independent length unit" — after the P0 phone pass found 16px text
|
|
drawing at roughly a third size on a real phone. The fix that shipped
|
|
first (RUST.md's P0 box) was a global stopgap: divide the whole window
|
|
into a "logical" coordinate space (physical ÷ `content_scale`) and let
|
|
the shader's NDC mapping stretch it back up onto the real framebuffer.
|
|
That fixed the *size* but not the *sharpness* — a glyph rasterised at the
|
|
small, pre-stretch size and then stretched onto more physical pixels than
|
|
it has texels for is blurry, which is exactly what Iris's next report
|
|
said.
|
|
|
|
**The fix**: `Len` gained a `dp` field, resolved against a `density: f32`
|
|
(physical pixels per dp) at the one place a `Len` becomes a `UiScalar`
|
|
(`Len::apply_rest`) — `abs + dp * density`. `density` lives on
|
|
`UiRenderState` (`set_density`/`density()`) and `Painter` (`density()`),
|
|
set once from `DisplayMetrics.density` in `android::view::new_peer`; the
|
|
desktop backend has no per-monitor density wired up yet and stays at
|
|
`1.0`. Every layout call site that used to call `.apply_rest()`/
|
|
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
|
|
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `LazySpan::place`, and
|
|
`UiRenderState::place` itself). This also meant the Android
|
|
boundary's global logical-space stopgap could come out entirely: window
|
|
size, touch coordinates and insets are physical pixels again, matching
|
|
`AndroidRenderer`'s own swapchain resolution, with `dp` doing the
|
|
per-length work the global divide used to do for everything at once.
|
|
|
|
**Text is the case that needed more than the `Len` plumbing.** A widget's
|
|
`font_size`/`line_height` are plain `f32`, not routed through `Len` at
|
|
all (there is no sensible `rel`/`rest` for a font size). `TextBuffer::
|
|
shape` now takes `density` directly and multiplies `font_size`/
|
|
`line_height` (and any span override) by it before handing them to
|
|
parley — so the size that reaches both the line-breaker and the
|
|
rasteriser (`TextData::place`, which reads back whatever `shape` set) is
|
|
the display's *physical* size, and the glyph atlas holds a bitmap at the
|
|
resolution it is actually shown at. `GlyphKey.size` already keys on the
|
|
resolved size, so a cache entry is naturally per-physical-size with no
|
|
further change. The one caller with no `Painter` to read density from
|
|
(`TextEditCtx::layout`, cursor movement and hit-testing) reads a second
|
|
copy kept directly on `TextData` (`TextData::density`) instead — an
|
|
accepted duplication rather than threading a `Painter` into every input
|
|
handler for one field, the same tradeoff `AndroidRenderer::content_scale`
|
|
already makes for the Diagnostics page.
|
|
|
|
**What did not change**: `rel`/`rest` are unaffected (already
|
|
resolution-independent, a fraction of the parent). `Span::gap` and
|
|
`Padding`'s four sides moved from bare `f32` to `Len` so `dp(...)` works
|
|
on them the same as any other size; a bare number is still `abs`,
|
|
physical pixels, unchanged.
|
|
|
|
## Masks with a shape (decided 2026-09-07, built 2026-09-08)
|
|
|
|
Iris, on the code block's scrolling: "the code block scrolling currently
|
|
masks in an inner rectangle. Ideally masks should have a shape
|
|
associated with them, rounded rectangle being one of them, and/or
|
|
another widget you can select, so that the mask becomes the parent
|
|
container with rounded edges. Make sure alpha works properly with it,
|
|
eg. on the corners where alpha should be decreased / multiplied."
|
|
|
|
**What exists.** `Mask` in `shader.wgsl`/`data.rs` is two `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 move alone cannot fix a changed size; `Painter::place` redraws in that
|
|
case.
|
|
|
|
The cost is bounded and worth stating, because it is what makes the rule
|
|
safe to apply everywhere: the 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.
|