Files
ai-app/docs/LAYOUT.md
T
iris-aiandClaude Fable 5.1 ef324aa88b Replace the step 3 plan with the one-ask protocol and record why it failed
The handoff now describes wip/one-ask at 3091fb8: a widget draws once in
the box it is asked in, its answer is placed by re-expressing the drawing,
and nothing is drawn again in a box an answer chose. The log records what
the step 3 plan got wrong -- it kept the second draw whose measurement
bit could not be defined, demanded a contract of the answer box, and
narrowed frames by region -- and what fuzzing the new protocol found: the
Part::Of composition dropping a pinned length, and a local redraw that
must be put back in the parent's answer box. The Pad frame rule is left
as a decision for Bryan with its cost stated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 18:23:49 -04:00

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

§1, §2 and §3 have landed in Iris (#16 and #18). §4 to §6 and the density section retain the rationale of the design but still name types that have since been replaced; they are not an API reference. docs/HANDOFF.md is the current transparent-frames work and its checks; docs/LAYOUT_LOG.md is what the sessions doing that work found, kept until it lands. The sections at the end of this file are durable design moved out of the handoff on 2026-09-18.

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) -> 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. dirty_size_under has been deleted; settling consumes descendant marks bottom-up, so no clean retained answer can hide an unsettled size dependency. 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.

Frames, decided boxes and padding

Containers that only divide room are transparent to fractions. A child frame is narrowed by a length its parent decided: a declared px or rel length, or the resolved slot of a leftover child. A box a widget reports for itself does not narrow its descendants' frame.

Pad is an outset: it forwards its frame less the padding, draws the child inside that area, and reports the child's used size plus padding. A rel(1.0) child inside padding inside a share is a fraction of the resolved share less that padding. The mixed "outset pixels, inset fractions and shares" interpretation is rejected.

A widget draws once, in the box it is asked in; its answer is placed inside that box by re-expressing the drawing, and nothing is drawn again in a box an answer chose. Holds is a contract about the ask box alone, read only to decide whether a re-ask can be skipped. A container that puts an answer somewhere other than where it asked says so with Painter::place_at, which never runs the body. A frame is narrowed by a length of the parent's frame, never by a region, and is put back into the part by the child's alignment on every placement. This is wip/one-ask in the experiment checkout; whether Pad narrows the frame by its padding is still open in docs/HANDOFF.md.

Layout decisions and invariants (2026-09-15 to 2026-09-17)

Moved here from the handoff on 2026-09-18. These are settled unless a subsection explicitly says it is pending.

Fixed point

Decided with Bryan on 2026-09-15. Layout decides on a grid rather than in floats.

  • Fixed<SHIFT> is an i32 counting 1 / 2^SHIFT. Adding and subtracting are exact; mul drops to the step below (Bryan, 2026-09-16: truncation is preferable); div, div_int and ratio round to nearest; to_scale takes the nearest step. Two routes to one place that land on one number are the same place, so everything downstream compares for equality.
  • Px is 1/1024 px, Rel is 1/2^24 of a box, Weight is 1/65536 of a share. PX_SHIFT and REL_SHIFT are the only statement of the first two; the shader's copy is prepended from them by render::module_source. Px was 1/64 first, where one rounding's residue was 0.016 px and enough to move a box. Range is +/-2.1M px and conversion to f32 is exact to 16,384 px.
  • A weight is not a fraction: a list divides its room by the total of its weights, and Rel::ratio turns two weights into a share on the finer grid.
  • Arithmetic wraps (4febabf, Bryan: a coordinate past the range will not draw reasonably anyway, so wrap and break clearly). Saturating cost a twelfth of layout's instructions. MIN and MAX stand in for an unbounded end and are only ever compared against; from_f32 is the one operation that clamps, and Holds keeps a saturating narrow.
  • A pointer, a wheel notch, a shaped glyph advance and a window size arrive as floats and go on the grid where they arrive. Vec2 is what the GPU and the platform speak; PxVec2 is what layout decides in.
  • Do not widen the grid to chase a residue. Every failure seen was one value reached by two expressions, sitting on a boundary defined by the same value coming back the other way. No precision shrinks a residue that is the whole distance.
  • A value that comes back as a box is rounded away from the measurement, not to the nearest step. Fixed::ceil_from_f32 exists for that and is the only rounding on the grid that is not to nearest. Rounding to nearest is right for a value being carried and wrong for a bound; a text reporting ceil of its longest line is what keeps the box it is handed back one its line fits in (4bd8607).
  • A structural decision may not be taken on a hair's breadth. A boundary that decides which children exist (a span's leftover split) is derived through the inverse of the expression that draws, never by a second expression for the same length: mul floors while div rounds, so a boundary derived with a division guards a drawing made with a multiply (53b00c6).

A box in pixels is one multiply from its parent's

A draw threads pixel lengths down: the box a parent gave a widget, then the part of that box its own answer placed its drawing in. Painter::px_size and px_len read that value, and a local redraw takes the same steps back up the parent chain (asked_px). Neither chain has a coordinate frame in it, so a region node cannot break either, and warm and cold reach every length by the same expression.

  • Holds::through is the exact preimage of px + floor(rel * box): floor(rel * B) >= lo - px is rel * B >= (lo - px) << REL and floor(rel * B) <= hi - px is rel * B < (hi - px + 1) << REL, two div_towards once the sign of rel has said which bound is which. The answer is an interval even for a single length, because a floor is not invertible. The range has to contain the box a drawing was made in (the Holds assertion in draw_at, debug only) and must not contain a box the drawing does not hold for (the oracle); being the preimage makes those one statement rather than a trade-off.
  • Symbolic regions are for the GPU, hit testing and remaps alone. Moves::resolve is the only walk left and it is the vertex shader's. Nothing layout decides is composed back up the move chain.
  • px is not stored on ActiveData, deliberately. A resize every widget's Holds admits redraws nothing, so a stored pixel length would be stale on every widget in the tree with nothing to say so. asked_px walks up only where a widget is already being redrawn; the mean chain is 2.8 levels.
  • The window is not a move entry (5b78002). A chain bottoms out in MoveIdx::NONE; the window is applied where a fraction becomes pixels, to_px(output_size) on the CPU and the uniform in the shader. A resize rewrites no retained entry and re-uploads nothing but the uniform; its cost is whatever Holds redraws.
  • A move that keeps a box's length is a translation, and exact. A box that changed length re-expresses each part as a fraction of the new one, which rounds. tests/cases/drift.rs pins that the grid does not drift either way. A length given in pixels is that many pixels wherever it ends up (Len::within adds a part's own pixels rather than scaling them); equal shares come out one or two steps apart because positions, not lengths, are what gets rounded, so the row fills and no two children leave a seam.

Retained-layout invariants

  • Holds is the interval of box lengths for which a widget's drawing and reported size remain valid. Reading Painter::px_len or px_size narrows it; Painter::holds widens it. The contract is trusted rather than checked defensively on every use.
  • A retained drawing is reusable only when its Holds contains the new box on both axes, its parent and region-node choice match, it is on the layer it was drawn on, and the widget is clean. A valid ordinary subtree moves by recursive remap; a region node moves by one entry. A container that draws a child to learn its size uses Painter::child_layer_at, the layer the child will actually occupy.
  • An answer's validity and its final drawing's validity are independent. A parent may reuse an answer while redrawing the placed output. Translate the drawing contract back through its placement; do not intersect it into the answer contract.
  • A fraction resolves once against its frame. A report returns raw and is composed only where a parent narrowed that frame. A part's own pixel length is added rather than scaled, so a pixel length remains that many pixels at every nesting depth.
  • An asked-but-undrawn size dependency belongs to the widget that asked. Keep it recorded so a later child change reaches the parent that decided not to draw it. Dirty size dependencies settle deepest-first.
  • A widget that creates a mask clips to and reports its box. Its own mask and its inherited mask are distinct retained state: the former says which mask a move rewrites, while a local redraw receives the latter.
  • A span's leftover/no-leftover boundary is a strict structural decision, not a tolerance. Derive the boundary through the inverse of the expression that places children. A cap may not contain leftover, because feeding the span's own room division back into a cap admits multiple fixed points.
  • Text shaping is retained separately from line breaking. A greedy break holds from its longest produced line through the width at which it was made, expressed with Painter::holds.
  • A region node stores one whole UiRegion in its parent node's coordinates; FULL is the identity. Changing node ownership redraws the subtree once, and a removed node's move entry remains alive until every descendant has migrated.
  • Alignment is one value per axis and defaults to the middle because neither edge is neutral without a direction. One widget has one length per axis; a second length requires a second widget through .wrapper().

What the fuzzers tolerate

Warm and cold pixel regions must compare exactly; there is no step allowance. When a row's grid-step count is not divisible by the number of children, individual share widths differ, but every rerun of that layout must still agree exactly.

Rendering the grid (pending)

snap_floor in prelude.wgsl adds half a layout step before flooring, which absorbs float error and not a layout step, so a third of 900 px (299.999 on the grid) lands at 299 on screen. Bryan approved on 2026-09-17 rounding to the nearest pixel in the shader together with round-to-nearest in Fixed::mul on the CPU, as one change with one verification; neither has landed. The reason for the CPU half: a Rel is off by at most 2^-25 of its box, so with round-to-nearest every product whose true value is a whole number of steps is exact for boxes under about 8,000 px, where truncation leaves half of them one step short and layout then decides "does not fit" on a container the user meant to fit exactly. Use the branchless round-half-up form, (a * b + (1 << (BY - 1))) >> BY; re-derive Holds::through for it; check with nm that UiSpan::within still inlines.

Measuring layout cost on this machine

  • Check the work counters before comparing two commits' times. tests/layout_diagnostics.rs prints drawn widgets, widget draws and primitive writes; a comparison is only worth reading when they match. random.rs's Branch picks a subtree by a measured pixel length, so the fixture's shape moves with the thing measured; Edits::fixed_branches pins it for timing and the oracle keeps measured branches on purpose. A 3x once reported was that artifact.
  • perf stat in this VM returns garbage readings for both instructions:u and cycles:u, roughly a quarter of the time, off by a factor of five to fifteen. Take medians of nine or more and report how many readings a filter kept. Instruction counts hold to 0.02% within a binary and move 0.5% across a rebuild, so build the baseline beside the thing measured and quote a delta.
  • What moves cycles is whether UiSpan::within inlines. It is the hottest line in layout; nm shows it as a symbol when it does not. Shrinking its body until the inliner takes it won; #[inline] on the body it had lost 1.5% cycles. Shrink it, do not annotate it.
  • Holds::through divides twice per call and accounts for essentially all of a run's i64 divisions: 2.8% of a 500-frame many.
  • Tried and rejected, with numbers: a float reciprocal for the remap division, +6% cycles; branchless shift_round, +6.7%; removing the per-child hash lookup in remap_subtree, 0.0%; short-circuiting apply_scalar where the fraction is nought or one, +17%. Short-circuits guarding a saturating multiply stopped paying once the multiply wrapped; re-price a short-circuit before keeping it. Rust does not contract a + b * c. Wrapping (4febabf) was -8.6% instructions; truncating (08c9d5a) costs a share a thousandth of a pixel of its row.
  • Threading the pixel box down the draw (2026-09-17) was free on cold layout and 9-13% of instructions off the retained paths, measured against 5b78002 at seed 1, depth 8, medians of 21.