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>
525 lines
30 KiB
Markdown
525 lines
30 KiB
Markdown
# iris: one `draw` that records a size
|
|
|
|
A widget draws once and records its size on the `Painter`. Reading a child
|
|
`DrawResult::size()` records a retained size dependency; drawing the child
|
|
without reading that result does not make the parent's size depend on it.
|
|
|
|
§1, §2 and §3 have landed in Iris (#16 and #18). §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
|
|
|
|
```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 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_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. `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.
|