§2 described `Painter::place`, `move_offsets`, `resolved_region` and `set_instance`; three of those four names no longer exist. What shipped is the opt-in region node, a box rather than a translation, and remapping for everything that did not opt in. §3 described `Widget::on_resize` and its `Scale`/`Redraw`/`Translate` answers, which are gone: the `Holds` interval says the same thing per drawing rather than per widget type, and says how far. The 0.05 px comparison it quoted is equality on the grid now. The trait in §1 has two methods rather than three, and the line numbers it cited have all moved; they are dropped rather than corrected, since the names are enough to find. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
326 lines
18 KiB
Markdown
326 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.
|
|
|
|
§1, §2 and §3 have landed in Iris (#16 and #18) and the notes below have been
|
|
brought to what shipped rather than what was proposed; §4 to §6 describe the
|
|
same design as it stands, and name types that have since been replaced where
|
|
they were written before it. `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 }
|
|
}
|
|
```
|
|
|
|
Two methods, not three: `on_resize` was proposed here and shipped, and §3
|
|
below replaced it with the `Holds` interval a widget declares while drawing.
|
|
|
|
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()`) and already exposes the pixel-resolved form
|
|
(`px_size()`) and the output surface size (`output_size()`). Passing it again
|
|
would be the same value under a second name. `desired_width`/`desired_height`
|
|
and `WidgetAxisFns::desired_len` 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`
|
|
(as it was then) 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
|
|
|
|
A widget opts into one independently movable region with `.region_node()`, or
|
|
`Widgets::set_region_node` at runtime; `.scrollable()` sets it once as its
|
|
convenient default. A node holds a whole **box** -- a `UiRegion` in its parent
|
|
node's coordinates, `UiRegion::FULL` being the identity -- and each primitive
|
|
instance names the node it was drawn under. Moving a subtree through a node
|
|
writes one entry. A widget without the property shares the nearest ancestor's
|
|
node, and moving it remaps its retained primitive, mask and active regions
|
|
instead, stopping at any descendant node after rewriting that one entry.
|
|
|
|
A box rather than a translation, because a pixel-space offset would scale a
|
|
child that has to keep its pixel length; the fraction and the offset in a
|
|
`UiScalar` are what tell the two apart. The parent chain is what makes nested
|
|
movable subtrees work -- a swipeable row inside a scrolling list -- and a flat
|
|
table would rewrite the row whenever an ancestor moved, which is the
|
|
`O(subtree)` work this removes. `CHAIN_LIMIT` bounds the walk at 64 in both
|
|
Rust (`core/src/ui/mod.rs`) and WGSL, so a malformed cycle resolves the same
|
|
way on each side.
|
|
|
|
`Moves::resolve` performs the same walk on the CPU for hit testing,
|
|
accessibility and window-coordinate queries, and the shader's `resolve_move`
|
|
mirrors it. Coordinates cross as whole counts of `1/1024` px and `1/2^24` of
|
|
a box, which the shader decodes from constants the Rust side prepends: the
|
|
grid is stated once. Masks carry their own node and resolve it independently,
|
|
so a stationary viewport clips content that moves inside it.
|
|
|
|
Nodes follow `ActiveData`'s lifecycle. Removing one retires its entry only
|
|
after every descendant has migrated, since reusing the index sooner would
|
|
make an old parent look current. Changing the property redraws the subtree
|
|
once, to rebuild the coordinate boundary; it belongs to widget identity,
|
|
which is safe because a widget has one parent.
|
|
|
|
### 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. Two independent narrowings apply, and both are
|
|
measured properties of the code rather than new machinery:
|
|
|
|
**(a) A window resize does not, by itself, require touching most widgets.**
|
|
The shader recomputes every primitive's position from `window.dim` and the
|
|
primitive's stored fraction and offset every frame, already, on the GPU. A
|
|
widget laid out purely in those terms is therefore correct after a resize
|
|
with no CPU work at all.
|
|
|
|
What decides the rest is `Holds`, one interval of box lengths per axis:
|
|
*give this widget any box in here and it draws the same thing and reports the
|
|
same size*. A widget that never reads its box in pixels holds for every
|
|
length. Reading `Painter::px_len(axis)` or `px_size()` narrows the interval
|
|
to the length read, and `Painter::holds` is how a widget widens it again by
|
|
saying what its drawing actually depends on -- a greedy line break holds from
|
|
its longest line up to the width it was made at. A parent holds for whatever
|
|
keeps every child it asked about or drew inside its own range, each child's
|
|
interval translated into lengths of the parent's box.
|
|
|
|
This replaced `Widget::on_resize` and its `Scale`/`Redraw`/`Translate`
|
|
answers, which said the same thing per widget type and could not say *how
|
|
far*. There is no per-widget resize mode now: a widget that reads nothing is
|
|
never redrawn for a resize, one that reads its width is redrawn when its
|
|
width leaves the interval it declared, and the interval is the whole of the
|
|
statement. Do not restore `Translate`; a retained subtree that only moves is
|
|
remapped through the box chain of §2, exactly.
|
|
|
|
Lengths are whole counts of `1/1024` px, so "the box changed" is equality
|
|
rather than a tolerance: a change too small to reach the next step is not a
|
|
change, and one that reaches it is, however little of a pixel it is worth.
|
|
|
|
**(b) 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.
|
|
|
|
Dirty widgets settle deepest-first, and `dirty_size_under` stops a reader
|
|
taking a retained answer while something below that answer is still dirty --
|
|
an optimisation against laying out twice rather than a second validity
|
|
mechanism. An exact `size_hint` stops propagation when both axes still equal
|
|
the retained size; otherwise propagation is deliberately conservative, since
|
|
only a dependent ancestor can assign the final boxes. 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.
|