A fresh session had no way to learn the handoff existed: AGENTS.md named only docs/PLAN.md, and the filename tied the document to one piece of work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
333 lines
19 KiB
Markdown
333 lines
19 KiB
Markdown
# iris: one `draw` that records a size
|
|
|
|
A widget draws once and records its size on the `Painter`. Reading a child
|
|
`DrawResult::size()` records a retained size dependency; drawing the child
|
|
without reading that result does not make the parent's size depend on it.
|
|
|
|
§1 and §2 have landed in Iris (#16 and #18) and the notes below have been
|
|
brought to what shipped rather than what was proposed; §3 to §6 describe the
|
|
same design as it stands. `docs/HANDOFF.md` has the invariants
|
|
the code now rests on and what is still to do.
|
|
|
|
## Design
|
|
|
|
### UI ownership and frame access
|
|
|
|
`Ui` owns both the mutable widget-side `UiData` and the retained
|
|
`UiRenderState`. It dereferences to `UiData`, so resources expose one `Ui`
|
|
without adding a second layer to ordinary widget, text, and texture access.
|
|
The render state itself remains private. `Ui::render_state()` returns an owned
|
|
`RenderHandle`, whose only public operation is a shared `get()` guard over the
|
|
last completed frame. Owning the handle, rather than borrowing `Ui`, lets a
|
|
controller inspect retained ancestry while it mutates other resources.
|
|
|
|
`UiRsc::draw` is the mutation boundary: it clones the private handle, takes
|
|
the exclusive guard, and updates the render state with the `Rsc`. Event
|
|
dispatch holds a shared guard for the whole callback, so events and controller
|
|
methods can reuse the completed tree but cannot start a draw or observe a
|
|
partially updated one. Controller ancestry is walked directly through that
|
|
tree; the event manager still maintains its per-event active-widget index in
|
|
draw hooks so dispatch never has to scan every active widget.
|
|
|
|
### 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 on_resize(&self, axis: Axis) -> OnResize { OnResize::Redraw }
|
|
}
|
|
```
|
|
|
|
A widget returns what it used of the box it was given. A child
|
|
draw returns a `DrawResult` that keeps the painter borrowed; calling `.size()`
|
|
on that result reads the child's retained size and records that the current
|
|
widget depends on it. Dropping the result without reading it draws the child
|
|
without making the parent's own size depend on the child's.
|
|
|
|
No `available` parameter: `Painter` already carries the region the parent
|
|
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already
|
|
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface
|
|
size (`output_size()`, `:152`). Passing it again would be the same value
|
|
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`)
|
|
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
|
|
deprecated, not kept as a fallback — because a widget that implements both
|
|
`draw` and `desired_*` for the same thing is exactly the "two names for one
|
|
concept" the code rules call out, and it is what today's `Span::desired_ortho`
|
|
(`iris/src/widget/position/span.rs:98-152`) already complains about in its
|
|
own comment: "this literally copies draw so that the lengths are correctly
|
|
set in the context, which makes this slow and not cool." Folding sizing into
|
|
`draw` deletes that duplicate simulation, not just moves it.
|
|
|
|
`size_hint` is not a second layout pass. It is an optional exact answer for
|
|
an axis the widget declares without painter context or child access. A
|
|
lying hint fails a debug assertion when the widget is drawn.
|
|
|
|
### 2. O(1) subtree movement
|
|
|
|
Every active widget owns a slot in `UiData::move_offsets`. A slot stores an
|
|
absolute-pixel delta and its parent slot; each primitive instance stores the
|
|
slot of the widget that drew it. The vertex shader walks this bounded chain
|
|
and adds the accumulated translation. Moving a subtree therefore writes one
|
|
slot instead of rewriting every descendant primitive.
|
|
|
|
The parent chain is required for independently movable nested subtrees, such
|
|
as a swipeable row inside a scrolling list. A flat offset table would require
|
|
rewriting the row whenever an ancestor moved and would restore the very
|
|
O(subtree) work this design removes. Chain depth is bounded in both Rust and
|
|
WGSL.
|
|
|
|
`Painter::place` draws a child whose box its parent decides and may decide
|
|
again, and gives that child a slot of its own; `widget` and `widget_within` do
|
|
not, and share the nearest ancestor's. A slot carries a whole **box**, not a
|
|
translation: a pixel-space scale and offset would scale a child that has to
|
|
keep its pixel length, and the `rel`/`abs` pair is what distinguishes the two.
|
|
(That slots carry translation only was an agent's choice on 2026-09-04, never
|
|
asked for, and #18 replaced it.)
|
|
|
|
`UiRenderState::resolved_region` performs the same chain walk on the CPU for
|
|
hit-testing, accessibility, and public window-coordinate queries. Masks store
|
|
the move slot of their owning widget and resolve it independently in the
|
|
fragment shader, so a stationary viewport can clip moving content.
|
|
|
|
Slots follow `ActiveData`'s lifecycle. Removing a widget recursively retires
|
|
its slot only after descendants are gone, and a reused arena slot is reset
|
|
before new primitives can reference it. `Primitives::set_instance` also
|
|
cancels a dirty mark when provisional layout restores the original bytes, so
|
|
CPU-only measurement positions are never uploaded.
|
|
|
|
### 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` 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 is therefore already correct
|
|
after a resize with zero CPU work. Calls to `Painter::px_size` and
|
|
`Painter::output_size` mark both concrete-pixel axes; `px_len(axis)` and
|
|
`output_len(axis)` mark only the axis actually read. Only widgets whose read
|
|
axes changed by more than 0.05 physical pixels become dirty. The comparison
|
|
is against each widget's last actual draw, so smaller changes accumulate
|
|
rather than disappearing event by event.
|
|
|
|
All pixel-dependent leaves are marked before layout begins, along with every
|
|
chain of parents that read their sizes. Resize then settles the shallowest
|
|
shared readers first, under the new output, so overlapping dependency paths
|
|
are drawn once. Ordinary content changes use the opposite order: deepest
|
|
dirty widgets first, with a changed returned size propagated one reader edge
|
|
at a time. Re-reporting the current output size is a no-op.
|
|
|
|
**(b) A widget's `available` (its parent's offered region) can change
|
|
without the widget's *content* changing — this is what `Widget::on_resize`
|
|
answers, per axis.** When a container's own layout shifts (a sibling grew or
|
|
shrank, changing this widget's offered box), a widget that says `Scale` on the
|
|
axes that changed is not redrawn: everything it drew is a fraction of its own
|
|
slot's box, so writing that one box moves and stretches all of it. `Span`,
|
|
`Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` say `Scale`;
|
|
`Scroll` and `MaxSize` read their box in pixels and cannot. `Redraw`, the
|
|
default, is correct always and free never. `Translate` — an unchanged drawing
|
|
placed somewhere else in a bigger box — is reserved: nothing reads it until a
|
|
widget can say where in that box its drawing belongs, which is the alignment
|
|
work.
|
|
|
|
**Size invalidation travels upward before drawing; drawing itself travels only
|
|
downward.** Every active widget retains the direct children whose size it read
|
|
through `DrawResult::size()` or `Painter::known_len`. `redraw_updates` takes
|
|
one id from the dirty set, follows only those dependency edges upward and marks
|
|
that path dirty, then redraws its highest already-dirty ancestor. Drawing that
|
|
ancestor consumes the marks of every dirty descendant it reaches; the loop
|
|
then takes whatever remains. Drawing never synchronously invalidates or invokes
|
|
a parent, so there is no layout recursion and no provisional child draw on a
|
|
different layer.
|
|
|
|
An exact `size_hint` stops propagation when both axes still equal the retained
|
|
size. Otherwise propagation is deliberately conservative: the child may have
|
|
changed size, and only its dependent ancestors can assign the final boxes.
|
|
Unchanged descendants still take `draw_inner`'s retained skip-or-move path.
|
|
An active widget also retains which offered-box and output axes flowed into
|
|
the size it reported, directly or through a child size it read. A container
|
|
may use that answer for the same prospective box when every observed input is
|
|
still within 0.05 physical pixels; content dirtiness anywhere in its size
|
|
dependency subtree rejects the answer. This is a generic constraint rule, not
|
|
a text exception. Wrapped text is merely the common example: it reads width,
|
|
so changing only height leaves its answer valid.
|
|
|
|
### 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_len(Axis::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. `SetSize` can report
|
|
its declared axis without inspecting its child, which covers the important
|
|
`.height(rest())` case. A debug assertion compares every hint with the
|
|
eventual `draw` result. Widgets whose answer depends on shaping or on a
|
|
child return `None`.
|
|
|
|
### 5. Caching and invalidation
|
|
|
|
`Cache.size` (`core/src/ui/cache.rs`) is **deleted, not replaced with an
|
|
equivalent** — the thing it memoized (a `desired_width`/`desired_height`
|
|
answer, independent of drawing) no longer exists as a separate query, so
|
|
there is nothing left to cache at that layer. What already provides "an
|
|
unchanged subtree costs nothing" is the check `draw_inner` performs before
|
|
touching a widget at all (`render_state.rs:85-90`): if the widget is active,
|
|
its region is unchanged, and it is not marked dirty, `draw_inner` returns
|
|
immediately — no `Painter` constructed, no primitive touched, no shader
|
|
work beyond what the GPU already redraws from the unchanged instance
|
|
buffer. That check is kept exactly as it is; it is the caching mechanism,
|
|
and it already operates at (id, region) granularity, which subsumes "(id,
|
|
available size)" once size *is* what a region change means.
|
|
|
|
`ActiveData::size` stores the value the widget's `draw` returned.
|
|
This is what a parent placing the widget for a second
|
|
frame without redrawing it reads instead of recomputing — it replaces
|
|
`Cache.size`'s role of "answer a size question without a full draw" with
|
|
"read the size of the last actual draw." `ActiveData::size_deps`
|
|
stores the direct children whose `DrawResult::size()` or known length the
|
|
widget observed during that same draw; the next draw replaces the list, so a
|
|
dependency disappears as soon as the widget stops reading it. Both fields
|
|
have `ActiveData`'s existing lifecycle through `remove`/`remove_rec`.
|
|
|
|
Retained draw output uses two buffers per collection. A redraw clears and
|
|
fills the spare child, primitive, texture, and paint buffers while consuming
|
|
the current buffers for reuse, then swaps their roles. Stable redraws therefore
|
|
reuse vector capacity and move matching resource handles instead of allocating
|
|
new collections or changing resource reference counts each frame.
|
|
|
|
### 6. Rejected alternatives
|
|
|
|
- **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
|
|
|
|
Iris asked for a third length kind beside `abs` (physical pixels) and
|
|
`rel`/`rest` (a fraction of the parent) 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 callers with no `Painter` to read density from (cursor
|
|
movement and hit-testing through `TextHandle::layout`) read 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.
|
|
|
|
Glyph masks are cached at four horizontal quarter-pixel phases. Their final
|
|
quad edges snap to physical pixels after retained move offsets are applied;
|
|
the CPU mask geometry uses the same calculation as the shader. In particular,
|
|
a fractional scroll offset therefore moves text and other primitives in whole
|
|
physical-pixel steps instead of resampling the atlas vertically with the
|
|
nearest sampler.
|
|
|
|
**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
|
|
|
|
A `Mask` references a rectangle primitive and its parent mask. Nested masks
|
|
multiply coverage. Plain `.masked()` creates an undrawn rectangle at the
|
|
widget's region; `.masked_by(shape)` draws the shape behind the content and
|
|
clips to its first primitive. Keeping the shape in one primitive prevents a
|
|
rounded background and its clip from drifting apart.
|
|
|
|
Masks are rect-only. Glyph masks would require a CPU-readable alpha plane for
|
|
hit-test agreement, and standalone image masks require a bind-group switch the
|
|
fragment stage cannot make. Rendering and hit-testing both traverse the full
|
|
mask chain and use the same rounded-rectangle coverage; `iris/tests/mask_sdf.rs`
|
|
checks the WGSL implementation against the CPU SDF.
|
|
|
|
## Offered boxes
|
|
|
|
`Pad` must work in every container: it offers an inset region to its child and
|
|
reports the child's used size plus padding. In a generous parent it behaves as
|
|
an inset; in a tight parent it grows the result outward.
|
|
|
|
When a widget does not fit its offered box, it is redrawn at the box implied by
|
|
its reported size in the same frame. Deferring would leave ordinary
|
|
`.background(rect(..))` surfaces one frame behind their content. The settling
|
|
draw occurs only when the widget's own size changes. Widgets whose size varies
|
|
with every offered box are therefore unsuitable as `LazySpan` rows.
|