Finish the transparent-frames handoff
This commit is contained in:
1 parent
bdddb610c0
commit
280fad7472
3 files changed
+781
-1617
No files matched your search
+217
-22
@@ -4,11 +4,12 @@ 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) and the notes below have been
|
||||
brought to what shipped rather than what was proposed; §4 to §6 describe the
|
||||
same design as it stands, and name types that have since been replaced where
|
||||
they were written before it. `docs/HANDOFF.md` has the invariants
|
||||
the code now rests on and what is still to do.
|
||||
§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
|
||||
|
||||
@@ -143,14 +144,14 @@ 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, and `dirty_size_under` stops a reader
|
||||
taking a retained answer while something below that answer is still dirty --
|
||||
an optimisation against laying out twice rather than a second validity
|
||||
mechanism. 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.
|
||||
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"
|
||||
|
||||
@@ -312,14 +313,208 @@ fragment stage cannot make. Rendering and hit-testing both traverse the full
|
||||
mask chain and use the same rounded-rectangle coverage; `iris/tests/mask_sdf.rs`
|
||||
checks the WGSL implementation against the CPU SDF.
|
||||
|
||||
## Offered boxes
|
||||
## Frames, decided boxes and padding
|
||||
|
||||
`Pad` must work in every container: it offers an inset region to its child and
|
||||
reports the child's used size plus padding. In a generous parent it behaves as
|
||||
an inset; in a tight parent it grows the result outward.
|
||||
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.
|
||||
|
||||
When a widget does not fit its offered box, it is redrawn at the box implied by
|
||||
its reported size in the same frame. Deferring would leave ordinary
|
||||
`.background(rect(..))` surfaces one frame behind their content. The settling
|
||||
draw occurs only when the widget's own size changes. Widgets whose size varies
|
||||
with every offered box are therefore unsuitable as `LazySpan` rows.
|
||||
`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.
|
||||
|
||||
The current experiment still redraws some widgets in boxes derived from their
|
||||
own answers. That is the open protocol defect, not a design invariant. The
|
||||
target in `docs/HANDOFF.md` evaluates container bodies only in boxes a parent
|
||||
offered or decided; placing an answer reuses or translates its drawing rather
|
||||
than running the body in an answer-derived box.
|
||||
|
||||
## 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.
|
||||
Reference in new issue
Block a user