Files
ai-app/docs/LAYOUT.md
T

327 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.
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.