320 lines
18 KiB
Markdown
320 lines
18 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.
|
|
|
|
## 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);
|
|
|
|
fn size_hint(&self, axis: Axis) -> Option<Len> { None }
|
|
|
|
fn is_size_independent(&self) -> bool {
|
|
false
|
|
}
|
|
}
|
|
```
|
|
|
|
Every implementation calls `painter.set_size(size)` exactly once. A child
|
|
draw returns a `DrawResult` that keeps the painter borrowed; calling `.size()`
|
|
on that result reads the child's retained size and records that the current
|
|
widget depends on it. Dropping the result without reading it draws the child
|
|
without making the parent's own size depend on the child's.
|
|
|
|
No `available` parameter: `Painter` already carries the region the parent
|
|
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already
|
|
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface
|
|
size (`output_size()`, `:152`). Passing it again would be the same value
|
|
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`)
|
|
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
|
|
deprecated, not kept as a fallback — because a widget that implements both
|
|
`draw` and `desired_*` for the same thing is exactly the "two names for one
|
|
concept" the code rules call out, and it is what today's `Span::desired_ortho`
|
|
(`iris/src/widget/position/span.rs:98-152`) already complains about in its
|
|
own comment: "this literally copies draw so that the lengths are correctly
|
|
set in the context, which makes this slow and not cool." Folding sizing into
|
|
`draw` deletes that duplicate simulation, not just moves it.
|
|
|
|
`size_hint` is not a second layout pass. It is an optional exact answer for
|
|
an axis the widget declares without painter context or child access. A
|
|
lying hint fails a debug assertion when the widget is drawn.
|
|
|
|
### 2. 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::set_child_offset` inserts a retained coordinate slot between a
|
|
container and its direct children. `LazySpan` uses one so visible row boxes
|
|
remain stable while scrolling changes a single shared translation. Ordinary
|
|
window-relative positions remain `rel + abs`; move slots carry translation
|
|
only, not general remapping.
|
|
|
|
`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:105-106` recomputes every primitive's pixel position from
|
|
`window.dim` and the primitive's stored `rel`/`abs` pair *every frame,
|
|
already, on the GPU*. A widget laid out purely in `rel`/`abs` terms (no
|
|
call to `px_size()`, `output_size()`, or anything else that reads a
|
|
concrete pixel count) is therefore already correct after a resize with zero
|
|
CPU work — the shader did it. `UiRenderState::needs_redraw_all`
|
|
(`render_state.rs:229-231`) currently ignores this and redraws the entire
|
|
tree on every `resized`, which was the safe default while sizing and
|
|
drawing were two passes; it should be narrowed to only the widgets that
|
|
*do* read a concrete pixel value. Track this the same way `needs_redraw`
|
|
already tracks per-widget dirtiness (`Widgets::needs_redraw`,
|
|
`core/src/widget/widgets.rs:9`): a widget's `draw` call marks itself
|
|
pixel-dependent by calling through `Painter` methods that read
|
|
`output_size`/`px_size` (both already funnel through `Painter`, so the
|
|
marking is one line at each), and `resize()` (`render_state.rs:32-35`)
|
|
walks only that set instead of unconditionally setting `resized = true`
|
|
for a full `redraw_all`. This turns "every resize redraws everything" into
|
|
"every resize redraws what depends on pixels" — a real behavior change
|
|
beyond what was asked, so verify it against the I0b `pre_present_notify`
|
|
resize regression (that fix depended on `redraw_all`'s completeness)
|
|
before narrowing this.
|
|
|
|
**(b) A widget's `available` (its parent's offered region) can change
|
|
without the widget's *content* changing — this is what
|
|
`is_size_independent` (§1) answers.** When a container's own layout shifts
|
|
(a sibling grew or shrank, changing this widget's offered box), a widget
|
|
that returns `true` from `is_size_independent` is not redrawn: its
|
|
primitives are unaffected by size, only by placement, so the parent
|
|
either (i) issues a move (§2) if only position changed, or (ii) rewrites
|
|
the primitive's `region` fields directly via `region_mut` if the box
|
|
changed shape too (still O(primitives owned directly by this widget, not
|
|
its subtree, since a size-independent widget by definition has no
|
|
size-dependent descendants worth distinguishing — in practice this is
|
|
always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns
|
|
`false` (the default) is redrawn in full whenever `available` changes,
|
|
which is correct always, just not free.
|
|
|
|
**Size invalidation travels upward before drawing; drawing itself travels only
|
|
downward.** Every active widget retains the direct children whose size it read
|
|
through `DrawResult::size()` or `Painter::known_len`. Before a frame draws,
|
|
`redraw_updates` follows only those dependency edges from each dirty child and
|
|
marks the affected ancestors dirty. It then selects the highest dirty roots and
|
|
draws them top-down. Drawing never synchronously invalidates or invokes a
|
|
parent, so there is no layout recursion and no provisional child draw on a
|
|
different layer.
|
|
|
|
An exact `size_hint` stops propagation when both axes still equal the retained
|
|
size. Otherwise propagation is deliberately conservative: the child may have
|
|
changed size, and only its dependent ancestors can assign the final boxes.
|
|
Unchanged descendants still take `draw_inner`'s retained skip-or-move path.
|
|
For a stacking container, retained child lengths are cached per axis: a child's
|
|
width remains reusable while the parent changes width, and its height remains
|
|
reusable while the parent changes height. A change on the orthogonal axis does
|
|
invalidate it in both directions. This is a generic constraint rule, not a
|
|
text exception; wrapped text is merely the common example of height depending
|
|
on width.
|
|
|
|
### 4. Wrapped text, and "needs child height before choosing width"
|
|
|
|
**Wrapped text is not a special case any more; it already reads as one
|
|
draw.** `TextView::render` (`iris/src/widget/text/mod.rs:57-76`) already
|
|
does exactly what single-draw asks for: it reads `ctx.px_size().x` as the
|
|
wrap width, shapes once, and memoizes the shaped layout keyed on that width
|
|
plus a changed-flag on the buffer and attrs (`:63-69`) — a second call with
|
|
the same width is a hash-map-style cache hit, not a re-shape. Under the new
|
|
trait this collapses `Text::draw`/`desired_width`/`desired_height`
|
|
(`text/mod.rs:133-147`, three functions) into one `Text::draw` that calls
|
|
`self.view.draw(painter)` once, which internally still calls `render`
|
|
once, hits its own cache, and returns the size it already computed. No
|
|
new caching is needed here; the two now-redundant call sites
|
|
(`desired_width`/`desired_height` each separately calling `render`) simply
|
|
disappear, which is a second `render` avoided per frame per text widget
|
|
that is being measured by a parent.
|
|
|
|
`Span` has no size-only pass. It first reads exact, context-free
|
|
`Widget::size_hint(axis)` values. It then draws unknown fixed children
|
|
forward from the current cursor, retaining what they paint. Once every
|
|
length is known, flexible space is allocated and `Painter::place` moves
|
|
each retained child into its final box. A child is redrawn only when that
|
|
box changes the size it was drawn for.
|
|
|
|
Hints are optional and affect cost, never correctness. `Sized` can report
|
|
its declared axis without inspecting its child, which covers the important
|
|
`.height(rest())` case. A debug assertion compares every hint with the
|
|
eventual `draw` result. Widgets whose answer depends on shaping or on a
|
|
child return `None`.
|
|
|
|
### 5. Caching and invalidation
|
|
|
|
`Cache.size` (`core/src/ui/cache.rs`) is **deleted, not replaced with an
|
|
equivalent** — the thing it memoized (a `desired_width`/`desired_height`
|
|
answer, independent of drawing) no longer exists as a separate query, so
|
|
there is nothing left to cache at that layer. What already provides "an
|
|
unchanged subtree costs nothing" is the check `draw_inner` performs before
|
|
touching a widget at all (`render_state.rs:85-90`): if the widget is active,
|
|
its region is unchanged, and it is not marked dirty, `draw_inner` returns
|
|
immediately — no `Painter` constructed, no primitive touched, no shader
|
|
work beyond what the GPU already redraws from the unchanged instance
|
|
buffer. That check is kept exactly as it is; it is the caching mechanism,
|
|
and it already operates at (id, region) granularity, which subsumes "(id,
|
|
available size)" once size *is* what a region change means.
|
|
|
|
`ActiveData::size` stores the value the widget recorded with
|
|
`Painter::set_size`. This is what a parent placing the widget for a second
|
|
frame without redrawing it reads instead of recomputing — it replaces
|
|
`Cache.size`'s role of "answer a size question without a full draw" with
|
|
"read the size of the last actual draw." `ActiveData::size_dependencies`
|
|
stores the direct children whose `DrawResult::size()` or known length the
|
|
widget observed during that same draw; the next draw replaces the list, so a
|
|
dependency disappears as soon as the widget stops reading it. Both fields
|
|
have `ActiveData`'s existing lifecycle through `remove`/`remove_rec`.
|
|
|
|
### 6. 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.
|
|
|
|
**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.
|