`docs/LAYOUT_LOG.md` existed until the one-ask protocol landed. It has, as Iris PR #19 replacing #18, so what has to outlive the log moves into `docs/LAYOUT.md`: the hint rule and the one ask a span makes, the reported shares that are left and the two-answers design that would remove them, what the fuzzers reach and what they cannot, the hypotheses not to repeat, and the counter and dump commands with the numbers they print now. The handoff becomes what it is for -- where the work stands, which is waiting on a review.
664 lines
38 KiB
Markdown
664 lines
38 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 landed in Iris as #16. §2 and §3 were #18, which is closed: Iris PR #19
|
|
carries those commits whole plus the retained-layout repair built on them. §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 where the work
|
|
in flight stands. The sections from "Frames, decided boxes and padding"
|
|
onwards are the settled design, the findings that outlived the working log,
|
|
and the measurement method.
|
|
|
|
## 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. `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 inset (Bryan, 2026-09-18): it subtracts the padding from both
|
|
the child's frame and the child's box, and reports the child's size plus the
|
|
padding. A `rel(1.0)` child inside padding fills the pad without overflowing
|
|
it; a wrapping text inside padding wraps at the box the pad was given less
|
|
the padding. A span's frame never subtracts siblings, so a padded fixed
|
|
child measured after a 24 px sibling in a 900 px row wraps at 844 while a
|
|
`rel(1.0)` inside it is 868. There is no outset kind and no mixed kind.
|
|
|
|
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.
|
|
|
|
The answer box is never a question, so no contract can be demanded of a
|
|
widget there: a `Text` asked at 45 px whose longest word is 89.5 px cannot
|
|
promise its drawing holds for the box its own answer chose. An earlier plan
|
|
asserted exactly that and was unsatisfiable.
|
|
|
|
A container that can learn a child's length without drawing it does.
|
|
`Painter::size_hint` answers from the child's rule, or from
|
|
`Widget::size_hint` where it implements one, resolved against the asking
|
|
widget's frame; `Scroll`, `Masked` and a `Stack` without a sizing child hint
|
|
`LEFTOVER`, since each always reports the whole of its box. `Span` takes each
|
|
child's length from its hint where there is one and asks that child exactly
|
|
once, in the slot it decided; a child with no hint is drawn in the room left
|
|
from the cursor, because a text has to wrap at the width actually there, and
|
|
its drawing is moved to its slot with `place_at`. Reading a hint records a
|
|
size dependency and pins the frame where the hint declared a fraction, the
|
|
same pin a rule that is a fraction takes.
|
|
|
|
**What that leaves, and the design that would remove it.** A widget that
|
|
reports a share its children gave it -- a span whose children report
|
|
`leftover`, a stack sized by such a child, a wrapper round either -- can have
|
|
no hint, so its parent asks it in the room and again in its slot, and both
|
|
asks draw, since the room drawing divided the room. Under a resize that
|
|
multiplies down the tree: at seed 1, depth 8, the hottest widget in the
|
|
resize frame is a `Span` drawn 8 times. Removing it means two answers on one
|
|
record -- a room answer from an explicit measuring ask (`Painter::measure`,
|
|
so which ask is the measurement is stated rather than inferred) and a slot
|
|
answer from the placing ask, with the drawing belonging to the placing one --
|
|
plus a local redraw that re-asks both questions and marks the parent if
|
|
either answer moved, which also retires `re_asked`'s deferral. Do it only
|
|
once an app screen shows the cost, and measure that screen first.
|
|
|
|
**There is one coordinate unit, the window** (`1512d84`, `23523ee`). Every box in the
|
|
tree is a region in window units; a widget's frame is a *length* in the same
|
|
units and is only what fractions resolve against, so a widget's box need not
|
|
be its frame and padding can take from both. A region node's entry is a
|
|
translation -- a `rel 1` region anchored where its box starts -- so nothing
|
|
composes a frame back up a chain.
|
|
|
|
A frame decided for a child is therefore a length of the window too: a row's
|
|
slot, padding's frame less its pixels, or the box a stack's sizing child
|
|
decided (which arrives as `Part::Sized`). It is never a fraction of the
|
|
parent's frame, because a slot of a row is not a fraction of anything the row
|
|
can name -- a division of two lengths, which a `rel + px` length cannot hold.
|
|
A widget's own declaration *is* a fraction, of whichever of those reached it,
|
|
and it is the only one of them that also places the box inside the part.
|
|
|
|
Validity has a pin for each: `LayoutHolds::extent_len` for the box's symbolic
|
|
length and `frame_len` for the frame's. A range of window pixels cannot say
|
|
which frame an answer is a fraction of, since two frames are different
|
|
lengths at the same window size. Resolving any of these lengths in pixels is
|
|
a read of the window, and `Painter::to_px` is where that read is taken --
|
|
pinning the window where the length has a fraction in it, and nothing where
|
|
it is only pixels.
|
|
|
|
## 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 the window
|
|
|
|
Every length in the tree is a length of the window, so it becomes pixels in
|
|
one multiply wherever it is read: `Painter::px_size` and `px_len` for the
|
|
box, `frame_len` for the frame. There is no chain to walk and no coordinate
|
|
frame in the way, so a region node cannot break it and warm and cold reach
|
|
every length by the same expression. (Before `1512d84` a frame was a length
|
|
of its parent's frame and a local redraw walked back up the parent chain in
|
|
`asked_px`; both are gone.)
|
|
|
|
- **`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_toward`s 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. What is
|
|
stored is the symbolic frame, which a redraw resolves against the window
|
|
it has.
|
|
- **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()`.
|
|
- No measurement is a different state from a measured zero, so
|
|
`ActiveData::answer` is an `Option` rather than a zero size.
|
|
|
|
### What the fuzzers tolerate, and what they reach
|
|
|
|
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.
|
|
|
|
- The routine runs are ten seeds (fast oracle), 400 at depth 5 through the
|
|
shrinker, and 1000 at depth 6. The 2000-seed depth-4 scan over every case
|
|
is what found seeds that had failed on every commit for a week, so run it
|
|
after any layout change. `Rng::new` is `seed | 1`, so an even seed and the
|
|
odd one above it are one tree.
|
|
- The shrinker panics per thread at the first failing seed, so a run reports
|
|
at most one seed per chunk of about 58. Check a single seed against the
|
|
unpatched code before calling it new.
|
|
- Depth finds things and so does breadth; widen one axis at a time and
|
|
record which.
|
|
- If a seed fails once and never again, suspect the rig's determinism before
|
|
believing an edit fixed it: seed 30 at depth 5 did exactly that on
|
|
2026-09-19 and reverting each part of the change did not bring it back.
|
|
- The cases are what a change is *then* compared at. Until the `size-resize`
|
|
case was added, every one of them compared at the window the change was
|
|
made in, and a length kept as a fraction of the wrong box agrees there and
|
|
parts from cold at every other window. There is still no case that changes
|
|
a tree twice with a resize between, and none that resizes twice.
|
|
- The fuzzer grows no `rel` rules and never re-parents a widget, so neither
|
|
a fraction resolved against a frame nor a subtree changing hands is
|
|
covered by any generated tree. Both have cost a defect that only a
|
|
hand-written expectation or a render caught.
|
|
|
|
### Failed hypotheses worth not repeating
|
|
|
|
- **"Which draw is the measurement" cannot be defined.** Four bookkeeping
|
|
rules and two experiments each fixed some seeds and broke others. The bit
|
|
existed only because a widget was drawn twice; one draw leaves it nothing
|
|
to name. A plan that proposes bookkeeping for a distinction should first
|
|
ask whether the distinction has to exist.
|
|
- **A frame narrowed by a region does not move when its part moves.** A
|
|
narrowed frame must be a *length*, put back into the part on every
|
|
placement, exactly as a declared rule already is.
|
|
- **Choosing between a fixed and a relative child in pixels at the span's
|
|
current width** admits multiple self-sizing fixed points; seed 13 settles
|
|
differently warm and cold under it. The same circularity is what a cap
|
|
containing `leftover` would put into `SizeRule::Max`.
|
|
- **A tolerant endpoint on the span's leftover split** retains zero-height
|
|
children (seed 16). The boundary is derived through the inverse of the
|
|
expression that places children, and is pinned by
|
|
`unsettled::a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over`.
|
|
- **Not reading a span's own length where no slot depends on it** saves
|
|
nothing (`wip/inset`, measured 2026-09-19): counters identical at every
|
|
phase. A symbolic pin survives a resize because every ask box is
|
|
symbolically stable across one, so the pin only fails where an answer
|
|
changed, which is a real relayout.
|
|
- **Resolving a rule that is a fraction of the frame from the record instead
|
|
of redrawing** recovers nothing: `reuse outside: a frame length` is 3 of
|
|
264 cold draws at seed 1 and 1 of 758 at seed 13.
|
|
- **A `git bisect`** once named a commit that could not be the cause; read
|
|
the tree rather than the bisect when that happens.
|
|
|
|
### 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
|
|
|
|
- **Counters, at two seeds, before anything else.**
|
|
|
|
IRIS_SEED=1 IRIS_DEPTH=8 cargo test --release --features layout-diagnostics \
|
|
--test layout_diagnostics -- --ignored --nocapture layout_cost
|
|
|
|
reports each phase's `widget draws` and `distinct widgets`, and
|
|
`IRIS_PHASE=resize IRIS_FRAMES=2` names who is drawn how often. Where the
|
|
one-ask protocol stands, draws / distinct at depth 8, beside #18:
|
|
|
|
| seed 1 | #18 | now | | seed 13 | #18 | now |
|
|
| --- | --- | --- | --- | --- | --- | --- |
|
|
| cold | 369/261 | 264/232 | | cold | 1330/707 | 758/627 |
|
|
| many | 157/95 | 41/41 | | many | 524/159 | 16/16 |
|
|
| size | 16/12 | 3/3 | | resize | nothing | nothing |
|
|
| scroll | 2 | 1 | | | | |
|
|
| resize | 13/13 | 36/13 | | | | |
|
|
|
|
`many`, `size` and `scroll` are at their floor: every draw is a marked
|
|
widget, or the parent a marked widget deferred to. `resize` at seed 1 and
|
|
cold's draws over its distinct count are the reported-share cost above.
|
|
|
|
- **A change that could move cold layout gets the dump diff**, which the
|
|
warm/cold oracle cannot replace -- both of its sides move together:
|
|
|
|
IRIS_DUMP_SEEDS=400 IRIS_DUMP_DEPTH=5 cargo test --release \
|
|
--test layout_dump -- --ignored --nocapture | grep -E '^[0-9]+ [0-9]+ '
|
|
|
|
once at each commit, then `diff`. Zero differing lines of 34,488 is the
|
|
expectation for a cost change.
|
|
|
|
- **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.
|