Files
ai-app/docs/LAYOUT.md
T
2026-09-11 13:06:52 -04:00

18 KiB

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

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

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.