The tabs difference does not reproduce at f61e893 against upstream/main built in its own worktree, so nothing is waiting on a decision. Records the instruction counts, frame times and memory taken today rather than mid-branch, and what a one-pixel line and a one-pixel gap do under the chain, which is the question the pixel difference was really about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1069 lines
62 KiB
Markdown
1069 lines
62 KiB
Markdown
# Handoff
|
||
|
||
Where the work in flight stands, for a session picking it up cold. Not a
|
||
decisions log; when a piece of work finishes, replace this with the next one
|
||
rather than accumulating both. The subject now is pulling Iris out of ai-app
|
||
into a standalone framework.
|
||
|
||
## Where things stand
|
||
|
||
Canonical `main` is **`ca2b4b2`** (#17, the headless rig). Sixteen slices are
|
||
in.
|
||
|
||
**#18 `split/18-position-chain`** is open and finished apart from one decision:
|
||
worktree `/home/bob/repos/iris-pr18`, head `f61e893`, thirty-four commits,
|
||
workspace tests passing, fmt and clippy clean. It is LAYOUT.md §2's position
|
||
chain, generalised to boxes. After the performance pass (`e5f8b6b` to
|
||
`3f7cd82`) came the fuzzers and what they found: the shrinking fuzzer
|
||
(`b7caab3`, `386a0d1`), `SetSize` measuring in its declared length
|
||
(`c596bf1`), text re-breaking across the interval a break holds for
|
||
(`9913194`), and a dirty widget measured where its parent asked rather than
|
||
in a box its own answer decided (`02ff8c7`). `f61e893` restores `abs()` in
|
||
the rect shader, which `7c50a3e`'s rename had taken with it so that every
|
||
window failed shader validation while `cargo test` stayed green, and adds
|
||
the device-free shader validation test that would have caught it. The five
|
||
reference renders and the resize render are byte-identical across all of
|
||
it, and the hundred-seed sweep passes. `/home/bob/repos/ai-app-2` is on
|
||
`rustify`, worktree clean.
|
||
|
||
**The `tabs` divergence does not reproduce, so nothing waits on the owner.**
|
||
Re-measured 2026-09-15 at `f61e893` against `upstream/main` built in its own
|
||
worktree: `tabs` at 1920x1200 is byte-identical, and so are a cold 900x1200 and
|
||
a resize from 1920x1200 down to it. Both sides render the same bytes twice, so
|
||
it is not a flaky shot. The earlier reading of 1,283 differing pixels at x=1056
|
||
and x=1337 is withdrawn; it was taken before `02ff8c7` and `f61e893`, and a
|
||
target directory shared between the two checkouts is the trap the render
|
||
section already warns about. Byte-identical against `upstream/main` is met.
|
||
|
||
Check for a review before starting anything, and read the newest `submitted_at`
|
||
rather than the first result:
|
||
|
||
```sh
|
||
TOKEN=$(cat ~/.config/gitea/token)
|
||
N=18
|
||
curl -s -H "Authorization: token $TOKEN" \
|
||
https://git.arirex.me/api/v1/repos/iris/iris/pulls/$N/reviews
|
||
curl -s -H "Authorization: token $TOKEN" \
|
||
https://git.arirex.me/api/v1/repos/iris/iris/pulls/$N/reviews/<id>/comments
|
||
curl -s -H "Authorization: token $TOKEN" \
|
||
https://git.arirex.me/api/v1/repos/iris/iris/issues/$N/comments
|
||
```
|
||
|
||
My replies are ordinary issue comments on the same PR and say what each change
|
||
was for.
|
||
|
||
## How a position resolves now
|
||
|
||
Invariants, not history. Everything in `core/src/ui` rests on them.
|
||
|
||
- **A slot holds a box, in the coordinates of the slot it names.** A primitive
|
||
instance and a mask each name one, and `prelude.wgsl` composes the chain with
|
||
`within`. A translation is the special case where the box has its parent's
|
||
relative extent. The identity is `UiRegion::FULL`, **not zero** -- a zeroed
|
||
entry is a box of no extent and collapses its subtree to a point, which
|
||
`MoveOffset`'s comment says beside the `Zeroable` that `Pod` requires.
|
||
- **A slot has to carry a whole box** rather than a scale and an offset: a
|
||
pixel-space affine map scales everything under it, including a child that
|
||
must keep its pixel length, and the `rel`/`abs` pair is exactly what
|
||
distinguishes the two.
|
||
- **Slots are opt-in.** `Painter::place` draws a child whose box its parent
|
||
decides and may decide again, and that child gets a slot; `widget` and
|
||
`widget_within` do not, and share the nearest ancestor's. `Span`, `Aligned`
|
||
and `Scroll` place. This is what keeps the chain 2-4 deep rather than full
|
||
tree depth, which is the difference between free and +42.6%.
|
||
- **A widget's region is held in the coordinates of the slot it draws in**, so
|
||
a placed widget draws against `UiRegion::FULL` and its box lives in its slot.
|
||
`ActiveData::region` is the box it was *placed in*, in its parent's slot
|
||
coordinates, and `ActiveData::parent_move` is the slot that is in;
|
||
`window_region` composes the one through the other, which is the walk the
|
||
shader does.
|
||
- **Nothing inverts a lerp.** `UiRegion::stretch`, `stretchable` and
|
||
`UiScalar::stretch` are gone. A box that changed length is written to its
|
||
slot and the descendants recompose against it, which also covers the case the
|
||
old guard refused outright: a fixed length has no fraction to recover, so a
|
||
40-tall row could not be stretched on its other axis at all.
|
||
- **Reuse is decided on the box a widget drew against, in pixels**
|
||
(`ActiveData::px`). A region is a fraction of a slot's box, so an unchanged
|
||
region is *not* an unchanged box -- a child drawn at `FULL` of a slot that has
|
||
since halved compares equal to itself. This is the check everything else
|
||
rests on; do not weaken it back to comparing regions.
|
||
- **A size the parent learnt by drawing the child is an answer for that box
|
||
only.** `redraws_under` redraws a child whose size the widget read unless it
|
||
declares an exact `size_hint` for the changed axis -- the one case the parent
|
||
did not have to draw it to find out. The cost is that a size-reading
|
||
container gives up its reuse when its box changes length, which is every
|
||
span, so `OnResize::Scale` earns its keep on moves and on subtrees whose
|
||
sizes nobody read rather than on every stretch.
|
||
- **`redraws_under` is a question asked before reusing, never a marking.**
|
||
Marking descendants for redraw instead does not terminate: the mark escalates
|
||
to that descendant's size reader, which re-places the child, which marks it
|
||
again. Asking first and giving up the whole reuse adds no marks and stops.
|
||
- **A fixed pixel length keeps its pixel width through any chain.** Both edges
|
||
of such a part share their box's `rel`, and `within` is a function of that
|
||
`rel` alone, so the shader's `floor(rel * dim)` is the same for both edges
|
||
while the `px` parts still differ by exactly one. A hairline or a one-pixel
|
||
gap can therefore move by a pixel when a position falls the other side of the
|
||
`floor`; it cannot be widened or deleted by one. Only a length expressed as a
|
||
fraction can round away, and that was as true before the chain. Measured
|
||
2026-09-15 with `examples/thin.rs` (untracked in the #18 worktree): sixteen
|
||
one-pixel lines and sixteen one-pixel gaps under four levels of span, padding
|
||
and `rest(3)/rest(7)/rest(5)`, rendered at six output sizes on
|
||
`upstream/main` and at `f61e893`. Every run is exactly one pixel on both, and
|
||
a line sits within one pixel of where the other revision put it. At 640x481
|
||
the fixture is over-constrained -- the fixed pixels exceed the box -- and the
|
||
same three gaps vanish on both revisions, which is the span overflowing
|
||
rather than anything rounding.
|
||
- **The walk stops where a length did not change.** A part of a box with no
|
||
relative extent on an axis is a fixed length held as offsets from that box's
|
||
start, and composing anything into it leaves no relative extent either -- so a
|
||
widget whose own box did not change length has no descendant whose box did.
|
||
An 80-wide child of a widened row is never asked.
|
||
- **`Span`, `Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset`
|
||
say `Scale`**; each places in fractions and offsets of its own box and none
|
||
reads its pixel length. `Scroll` and `MaxSize` read pixels and stay `Redraw`,
|
||
which is the general rule: `Scale` on an axis unless the draw reads the pixel
|
||
length of its box on that axis. The default stays `Redraw`.
|
||
- **`OnResize::Scale` keeps its name.** The owner rejected `Stretch` on
|
||
2026-09-14: stretch has an opposite and scale does not, and the answer is per
|
||
axis, so the axis is already established where it is read.
|
||
|
||
## Measured, so the next attempt is compared rather than argued
|
||
|
||
| rig | what it says |
|
||
| --- | --- |
|
||
| `tests/chain_cost.rs` | GPU pass time by chain depth at 200k instances. Translate slots: free to depth 8 (+5%), then ~3 us per level, +42.6% at 16 and +221% at 64. Each step is a storage load addressed by the previous one, so it is the chaining that costs, not the arithmetic at a level. A box slot (36 bytes) against a translate slot on the same binary: +0.6% at depth 1, +0.5% at 2, +0.8% at 4, then +9.6% at 8 and +32.2% at 64 -- free where opt-in slots put it, and dear only where the chain already was. |
|
||
| `tests/replace_cost.rs` | Instructions per frame re-placing 200 rows: 1.98M writing each row's slot, 2.38M rewriting its regions, 7.13M redrawing it. A load for `perf`, not a check. Five primitives per row; the regime that decides whether the chain is worth it is a transcript row of a few hundred glyphs, **so re-run it with 200 characters of text per row before concluding anything from it**. |
|
||
| `tests/draw_cost.rs` | What recording a frame costs on the CPU by layer count. Dispatch per list is 6 instructions, 0.1% of a frame at 256 and at 1024 layers. |
|
||
|
||
A chain is irrelevant at an example's couple of hundred primitives; a
|
||
transcript's glyphs are tens of thousands, which is the regime `chain_cost`
|
||
measures.
|
||
|
||
### How much of that work is necessary
|
||
|
||
Measured 2026-09-14 on a random tree at seed 1, depth 7 -- 1061 widgets, 839
|
||
of them drawn, 10,872 primitive instances -- against the same rig at `43ce8c7`
|
||
(the last commit before #16 sized a widget by drawing it) and at `f942385`
|
||
(#16 itself). The rig is a `Harness` load counting widget draws, text shapes
|
||
and instance writes per frame, plus the GPU pass through timestamp queries;
|
||
it lives in the scratch worktrees `/home/bob/repos/iris-size-{old,new}` and is
|
||
not in the repository, because the counters it reads are patched into
|
||
`iris-core`.
|
||
|
||
| per frame | before #16 | #16 | #18 head |
|
||
| --- | --- | --- | --- |
|
||
| cold layout | 19.3 ms, 839 draws | 30.9 ms, 3317 | 23.6 ms, 2755 |
|
||
| repaint one leaf | 0.000 ms, 1 draw | 8.5 ms, 1683 | 6.5 ms, 1313 |
|
||
| scroll one scroller | 0.001 ms, 1 draw | 8.9 ms, 1683 | 6.5 ms, 1313 |
|
||
| resize the output | 3.2 ms, 839 draws | 31.8 ms, 6940 | 25.2 ms, 5512 |
|
||
| GPU pass | 0.129 ms | 0.114 ms | 0.115 ms |
|
||
|
||
**The GPU is not the subject.** The pass is a tenth of a millisecond at every
|
||
revision and every phase; all of this is the CPU laying out.
|
||
|
||
`a640c6c` and `84f589e` remove most of the CPU work without weakening the
|
||
retained-layout rules. Against the same seed-1/depth-7 load, widget draws are
|
||
now 2,117 cold, 1 for a leaf repaint, 11 for a scroll, and 3,139 for a resize
|
||
(from 2,755, 1,313, 1,313, and 5,512 respectively). Workspace tests pass; the
|
||
five reference renders, resize render, and image-tab replay are byte-identical
|
||
to the prior #18 head.
|
||
|
||
Those draw counts explain mechanism, not total layout cost. Before #16,
|
||
measurement was a separate operation: the cold and resize rows each made 839
|
||
draws plus 489 size queries, 314 of which hit the size cache. Current sizing is
|
||
a draw, so its draw count includes the provisional work and cannot be compared
|
||
directly with the old draw count.
|
||
|
||
### Against the old code, on a tree both revisions build
|
||
|
||
**The random generator is not a fixed load, so it cannot carry an old-versus-new
|
||
comparison.** Padding, scrolling and alignment grew into it during this work, so
|
||
`seed 1, depth 7` is 144 widgets today and was the 1061-widget tree the rows
|
||
above were measured on. Earlier statements here that the retained layout was
|
||
"about 1.6x the old 3.14 ms resize" compared a 260-widget depth-8 tree against
|
||
that 1061-widget depth-7 one, and are withdrawn.
|
||
|
||
`tests/revision_cost.rs` is the load that does carry it: one hand-written tree
|
||
-- 40 rows of `Dir::RIGHT` span holding a 40px rect and a `Dir::DOWN` span of
|
||
one wrapping and one short non-wrapping text, each its own random words -- in
|
||
source that compiles unchanged on `43ce8c7` and on #18. Drop it into an old
|
||
worktree and run it there; take the number from `perf stat -e instructions:u`
|
||
on the test binary rather than the clock. It also prints where the layout put
|
||
the paragraphs, which is the other half of the comparison, and `text_memory`
|
||
in the same file reports what the tree holds.
|
||
|
||
**The old code is faster because it skips the question, not because it answers
|
||
it more cheaply.** Two shortcuts pay for the 2.2x, and both are wrong:
|
||
|
||
- `SizeCtx::len_inner` keys its memo on the widget id alone. It stores the
|
||
constraint box the answer was computed under and never compares it, so the
|
||
first asker's box decides the answer for every later one -- 314 of those 489
|
||
hits per frame. The frame-local answer cache rejected on seed 98 at least
|
||
compared the box; this does not.
|
||
- `Span::len_sum` measures every child against the container's whole axis
|
||
rather than the part left for it, which its own comment records as a
|
||
deliberate choice ("tempting to subtract the abs & rel from the ctx outer,
|
||
but that would create inconsistent sizing"). In the fixture above that shapes
|
||
a paragraph at 900 rather than the 860 left beside the rect, and it draws
|
||
29 px past the edge of the output.
|
||
|
||
So the amplification #18 pays is the cost of asking in the right box. That does
|
||
not excuse the size of it, and most of it turns out not to be layout at all.
|
||
|
||
`480f0bc` retains that instrumentation behind the `layout-diagnostics` Cargo
|
||
feature; none of it is compiled into a normal Iris build. The ignored
|
||
`tests/layout_diagnostics.rs` rig selects `cold`, `repaint`, `size`, `scroll`,
|
||
or `resize` with `IRIS_PHASE`, plus seed, depth and frame count. Run it with the
|
||
feature for explanatory counters and inclusive phase timers, and without the
|
||
feature under `perf` for unperturbed CPU totals:
|
||
|
||
```sh
|
||
IRIS_PHASE=resize IRIS_DEPTH=8 IRIS_FRAMES=100 \
|
||
cargo test --release --features layout-diagnostics \
|
||
--test layout_diagnostics -- --ignored --nocapture
|
||
|
||
IRIS_PHASE=resize IRIS_DEPTH=8 IRIS_FRAMES=1000 \
|
||
perf stat -e cycles:u,instructions:u cargo test --release \
|
||
--test layout_diagnostics -- --ignored --nocapture
|
||
```
|
||
|
||
`82fa6c1` adds targeted tracing to the same feature. Call
|
||
`layout_diagnostics::trace_widget(id)` before a frame; `take().traces()` then
|
||
returns the selected widgets' ordered draw requests and pixel boxes, reuse
|
||
outcomes, placements, hints, size reads and reported sizes, and text widths.
|
||
The selection is a set, survives `take()`, and is removed with
|
||
`untrace_widget` or `clear_traced_widgets`. This replaces temporary
|
||
text-specific logging without imposing anything on normal builds.
|
||
|
||
The first depth-8 resize run made the amplification concrete. A 260-widget
|
||
tree has 215 active widgets, but a resize averaged 1,395 widget draws, 913
|
||
placement calls, 1,313 reads of drawn sizes and 34,844 primitive writes. Only
|
||
12 distinct text widgets rendered, yet they rendered and reshaped 282 times
|
||
per frame with no shape-cache hits. Text rendering accounted for 11.6 of 13.2
|
||
ms, including 9.9 ms shaping and 1.7 ms placing glyphs. The hottest two text
|
||
widgets each drew 96 times below nested `Span`, `Aligned`, `Scroll`, `Pad`, and
|
||
`SetSize` ancestors.
|
||
|
||
Tracing one of them showed that 96 was two multipliers, not 96 necessary
|
||
layout iterations. One traversal of the nested positioning chain shaped it 32
|
||
times at 12 widths; three resize-dependent descendants then caused that whole
|
||
traversal to run three times through the same highest size reader. Resize
|
||
marking had queued each pixel reader independently, and each leaf discovered
|
||
and redrew the same reader path in turn.
|
||
|
||
`82fa6c1` coalesces that frontier by marking every resize-dependent leaf and
|
||
its size-reader chain first, then settling the shallowest shared reader once.
|
||
Ordinary content and size changes remain deepest-first. On the same depth-8
|
||
load, an actual resize now averages about 694 widget draws, 435 placement
|
||
calls, 649 drawn-size reads, 15,343 primitive writes, and 116 text shapes; the
|
||
hottest texts draw 32 times. Instrumented layout is about 5.5 ms instead of
|
||
13.2 ms. An uninstrumented 1,000-frame resize run measured a 5.61 ms median,
|
||
60.79B instructions total (60.8M/frame), and 23.85B cycles. This is about 58%
|
||
fewer instructions than the roughly 138M/frame #18 path before coalescing.
|
||
The remaining multiplier is within one constraint traversal. Re-sending the
|
||
existing output size also no longer starts a resize.
|
||
|
||
`b1b3eca` records pixel reads and retained size inputs per axis. `Text` says it
|
||
reads only its offered width, while `Scroll` says it reads only its scrolling
|
||
axis. `Span`, `Aligned`, and `Scroll` ask for an exact hint or a retained child
|
||
length valid under the box they are about to offer, and draw to measure only
|
||
when neither exists. On the same depth-8 load, resize now averages 449 widget
|
||
draws, 296 placement calls, 485 drawn-size reads, 12,164 primitive writes, and
|
||
107 text shapes. Cached uninstrumented 1,000-frame runs measured 4.95–5.24 ms
|
||
medians, 55.4B instructions total (55.4M/frame), and 21.5–21.6B cycles: about
|
||
9% fewer instructions than `82fa6c1`.
|
||
|
||
Pixel comparisons now use a 0.05 physical-pixel tolerance. The comparison is
|
||
against the last actual layout, not the preceding resize event or retained
|
||
move, so repeated subpixel changes accumulate and eventually redraw. The
|
||
generated cold-layout oracle uses the same visual tolerance per coordinate.
|
||
|
||
The generated tree now includes `Aligned` with every meaningful per-axis
|
||
alignment. That exposed two retained-layout ordering bugs which `84f589e`
|
||
fixes. The regular cold-layout equivalence suite and the ignored 100-seed
|
||
sweep pass. The latter previously stopped at seed 60 on `140.0` versus
|
||
`139.99996948242188`: exactly two `f32` ULPs from an equivalent composition
|
||
order, not a visible layout difference. The oracle now keeps draw presence
|
||
exact and allows at most 0.05 physical pixels per coordinate. Seed 98 is also
|
||
part of the ordinary suite because it catches stale retained size beneath an
|
||
aligned size-changing chain.
|
||
|
||
The branches are invariants rather than widget exceptions:
|
||
|
||
- A dirty widget draws locally first only while its retained box has the same
|
||
pixel size **and that box is a constraint rather than its own answer**. If
|
||
its returned `Size` is unchanged, no size reader can observe the repaint and
|
||
no ancestor draws. If the size changed, the dependent path lays out. A
|
||
changed pixel box takes the conservative path first, which is the condition
|
||
the discarded `/tmp/escalate.patch` missed on resize. A box decided from the
|
||
widget's own size, on an axis that size reads, takes it too: measuring there
|
||
can only repeat the answer, so the reader that decided it draws instead --
|
||
see "a box that is its own answer" below.
|
||
- Dirty widgets settle deepest-first. A changed size queues only its immediate
|
||
reader; propagation stops as soon as a reader's own answer stays unchanged.
|
||
Output resize is the distinct invariant: all pixel-dependent leaves and
|
||
their reader chains are marked together, then shared reader roots settle
|
||
shallowest-first under the new output. This both chooses the parent box
|
||
before drawing its descendant and coalesces overlapping resize paths.
|
||
- A span measures an unknown child in the part of its axis still available,
|
||
rather than giving every child the whole container and immediately taking
|
||
most of it away. This is the archive's faster shape, recreated on the current
|
||
types; it often makes the measurement box the final box without caching an
|
||
answer under different constraints.
|
||
|
||
A proposed `Scroll` shortcut that reused its direct child's retained size was
|
||
discarded. “Direct child is clean” is not strong enough while a dirty
|
||
descendant's structural change is still propagating; seed 52 demonstrated the
|
||
stale-size failure. Re-measuring the scroll subtree costs 11 draws rather than
|
||
1, but remains two orders of magnitude below the old 1,313 and follows the
|
||
actual dependency invariant.
|
||
|
||
A frame-local cache of several size answers under different constraint boxes
|
||
was also discarded. It reached about 3.5 ms in the instrumented resize rig,
|
||
but seed 98 showed that a valid answer is not enough: the child's one active
|
||
drawing may still realize another constraint, and a parent can then lay out
|
||
from an answer its final child placement does not realize. A correct version
|
||
needs an answer-plus-realization or verification protocol; adding dirtiness
|
||
conditions to the cache does not fix that invariant.
|
||
|
||
Do not restore the archive's old `DrawMode::Measure`. It skipped primitive and
|
||
retained-state writes while still walking widgets and shaping text, and once
|
||
improved a streamed-frame benchmark from 1.39 to 1.22 ms p50. Retained
|
||
placement later replaced it because a provisional draw is usually already
|
||
usable in its final box; measure-only mode would discard that useful output
|
||
and force another real traversal. The owner confirmed on 2026-09-14 that
|
||
direct placement is the intended path.
|
||
|
||
### What a resize frame is actually spent on
|
||
|
||
`perf record` on #18's head, resize phase, seed 1 depth 8: about 63% of the
|
||
frame is text -- shaping in parley, harfrust and icu\_segmenter, then
|
||
`TextData::place` at 10.8% on its own -- and `draw_inner` is 0.9%. **The
|
||
retained-layout machinery is not what the frame costs.** A container that
|
||
measures by drawing makes a text draw 32 times at about a dozen distinct
|
||
widths; every one of those re-runs the shaper, because the caches in front of
|
||
it (`TextView::width`/`tex` and `TextBuffer::layout_key`) each hold exactly one
|
||
entry and a trial width alternating with a final width evicts the answer that
|
||
is about to be asked for again.
|
||
|
||
`e5f8b6b` and `2525637` are the two halves of the answer.
|
||
|
||
**A new width is a line break, not a shaping.** Only the breaking depends on
|
||
the width -- the font selection, the unicode analysis and harfrust under it are
|
||
a function of the text and the attrs, and parley re-breaks them in place, which
|
||
is what its own editor does on every resize. On the depth-8 tree that is 107
|
||
breaks at 0.119 ms where the shapings they replace were 4.0 ms. This is the
|
||
half that holds however far the width moves.
|
||
|
||
**A bounded store on `TextData` holds what re-breaking still cannot avoid:**
|
||
the glyphs, placed per width, keyed by the text, the attrs and the width, with
|
||
`TextBuffer` holding the pair it is drawn as -- which is where `TextView::tex`
|
||
was, so there is one place to invalidate rather than two. Bounding the store
|
||
rather than each buffer is the point: a per-buffer cache of eight cost +79% on
|
||
a tree of 4,000 texts, and lazy eviction cannot fix that, because the texts
|
||
holding the memory are exactly the ones a retained layout has stopped drawing.
|
||
|
||
Instructions per frame over 500 resize frames of `tests/revision_cost.rs`, for
|
||
widths that alternate -- the friendly case for anything that remembers an
|
||
answer -- and widths that never repeat, which is a drag:
|
||
|
||
| | alternating | never repeating |
|
||
| --- | --- | --- |
|
||
| before #16 (`43ce8c7`) | 56.1M | 56.2M |
|
||
| #18 head (`b1b3eca`) | 124.2M | 123.6M |
|
||
| the store alone | 17.7M | 45.9M |
|
||
| re-breaking alone | 32.9M | 32.8M |
|
||
| both | 20.6M | 24.2M |
|
||
|
||
Neither alone is good at both, which is the reason for having both. The frame
|
||
times that go with the last row are 1.88 ms median and 2.54 ms worst
|
||
alternating, 2.46 and 3.24 never repeating, against 4.99 and 6.47 before #16 --
|
||
so the worst frame more than halves, and the two gestures are within a
|
||
millisecond of each other rather than a factor of two apart.
|
||
|
||
Memory on the same rig at 2,000 rows: 108.6 MB on #18's head, 111.6 MB with
|
||
both, and **132.0 MB before #16**, which grows 23 MB over the resizes where
|
||
neither of the others grows at all.
|
||
|
||
**Re-measured at `f61e893`, which is the number to quote.** The same rig, the
|
||
same two worktrees each with its own target directory, 500 resize frames at 40
|
||
rows, taken with `perf stat -e instructions:u` on the test binary:
|
||
|
||
| | before #16 (`43ce8c7`) | #18 head (`f61e893`) |
|
||
| --- | --- | --- |
|
||
| instructions/frame, alternating | 56.1M | 15.3M |
|
||
| instructions/frame, never repeating | 56.1M | 15.5M |
|
||
| median frame | 5.32 ms | 1.29 ms |
|
||
| worst frame | 8.06 ms | 1.81 ms |
|
||
| RSS after 40 resizes, 2,000 rows | 132.3 MB | 111.1 MB |
|
||
|
||
So it is 3.6x fewer instructions than the code it replaces, on top of being the
|
||
revision that asks in the right box: in the same 900-wide output the old code
|
||
lays the second paragraph out to x=929 and the third to x=910, both past the
|
||
edge, where this one keeps them at 877 and 889.
|
||
|
||
Widget draws do not move at all -- 449 either way -- so none of this touches a
|
||
retained-layout rule. That matters for more than purity: **text is the load
|
||
that makes a redundant draw expensive, and so the thing that shows when the
|
||
layout is drawing more than it needs to.** The counters keep saying so -- a
|
||
text render is counted per ask rather than per shaping, and breaks and glyph
|
||
placements are counted separately -- so neither half can hide the amplification
|
||
it is paying for.
|
||
|
||
This is not the frame-local answer cache seed 98 rejected. That one let a
|
||
parent lay out from a size the child's one drawing might not realize; this
|
||
holds a *drawing* keyed on what produced it, and the widget still draws. The
|
||
five reference renders and the resize render are byte-identical, and the
|
||
sweep passes.
|
||
|
||
### Settling a dirty set, and why the order is not free to change
|
||
|
||
**There are two ways to obtain a retained size and only one of them is
|
||
guarded.** `Painter::known_len` -> `RenderState::retained_size` is the path
|
||
that answers a child's length without drawing it, and it refuses on
|
||
`size_is_invalid(id) || dirty_size_under(id)` -- it asks the deep question.
|
||
`try_reuse` is the path that keeps a child's *drawing* for a new box, and it
|
||
asks only whether that widget is itself in `needs_redraw`; the size comes back
|
||
as a by-product of the reuse succeeding, and never got the guard the other path
|
||
has. So a reader is not reaching past anything: it asks a clean child, and the
|
||
child answers from a record that is stale only because something below it has
|
||
not settled. **The settle order is standing in for a missing check on one of
|
||
two implementations of the same concept**, which is why the order is
|
||
load-bearing for the answer and why that was easy to miss.
|
||
|
||
That also says why bolting `dirty_size_under` onto `try_reuse` costs about what
|
||
the sort saves. On the `many` phase at 130 of 260 dirty the guarded path runs
|
||
47 times a frame and `try_reuse` runs 569 -- so the O(subtree) walk moves from
|
||
a cold path to one twelve times hotter. **The owner rejected building a maintained count for this on
|
||
2026-09-14**, and the reasoning stands: the count is only clarity, it costs a
|
||
pairing obligation at every site that marks or consumes a mark plus a cost in
|
||
every shipped frame, and bottom-up is needed for its own sake regardless. A
|
||
count that drifts low is a silent stale size, which is worse than what it
|
||
replaces. Its one remaining argument would have been freedom to settle in any
|
||
order, and the rows below measure that as costing more than it saves.
|
||
|
||
What guards the invariant instead: `a_long_run_of_seeds_agrees` compares a warm
|
||
incremental frame against a cold rebuild of the same tree over a hundred seeds,
|
||
which is what caught the defect on seed 2 and is a stronger oracle than an
|
||
assertion. A `debug_assert!(!self.dirty_size_under(..))` on the two paths in
|
||
`try_reuse` that return a size would make the dependence on the order fail
|
||
loudly there rather than silently -- those are the exact and moved paths, 133
|
||
calls a frame on the `many` load rather than all 569 attempts -- and it
|
||
compiles out of release, matching what `depth` already does against
|
||
`walked_depth`.
|
||
|
||
**Why picking any dirty widget does not work, found 2026-09-14.** `try_reuse`
|
||
asks whether the widget in front of it is dirty and, if not, hands its parent
|
||
the size it last reported. It does not ask whether a dirty widget sits under it
|
||
through the size dependencies -- which is the check `retained_size` makes, on
|
||
the path that does not draw, for exactly this reason. **The settle order is
|
||
what covers that gap**, and it was written down nowhere: taking the deepest
|
||
first means that by the time a reader draws, what it reads has drawn and
|
||
propagated.
|
||
|
||
The trace that shows it, on seed 2 of `tests/generated.rs`: with the deepest
|
||
first and with an arbitrary pick, the two are identical event for event until a
|
||
`Span` reports its size -- 147 px one way and 317 px the other, from the same
|
||
child sizes. It had reused a subtree exactly, and inside that subtree sat a
|
||
`SetSize` whose declared width had changed and which had not been drawn yet.
|
||
24 widgets end up wrong.
|
||
|
||
So the order is not about cost, and this is the thing to fix before changing
|
||
it. Adding the missing check to `try_reuse` does make any order correct -- all
|
||
six generated cases and the hundred-seed sweep pass with the dirty set taken in
|
||
whatever order it yields. But `dirty_size_under` walks the size-dependency
|
||
subtree on every reuse that hands a size back, and that costs about what the
|
||
sort saved: at 130 of 260 widgets dirty, 7.83M instructions per frame against
|
||
8.23M; at 32 dirty, 3.32M against 2.84M, so *worse* where the dirty set is
|
||
small. Two other shapes measured and rejected on the way: putting the check at
|
||
the top of `try_reuse` rather than on the two paths that return a size (7% dearer
|
||
again), and phrasing it as `size_is_invalid`, which also lets a resize mark
|
||
through and takes the resize phase from 7M to 106M instructions per frame.
|
||
|
||
**What would actually pay is a maintained count** rather than a walk: each
|
||
widget holding how many dirty widgets sit under it through size-dependency
|
||
edges, incremented up the reader chain when a mark is added and decremented
|
||
when one is consumed. That is O(depth) at a mark and O(1) at the check, where
|
||
today it is O(dirty x depth) per widget settled. It wants agreeing first: every
|
||
place that inserts into or removes from `needs_redraw` has to pair with it, and
|
||
a count that is too low is a stale size rather than a slow frame.
|
||
|
||
`3f7cd82` takes most of it without touching the order: a widget's depth is
|
||
known where it is drawn -- its parent's plus one -- so `Painter` carries it and
|
||
`ActiveData` keeps it, and the choice reads a field instead of walking an
|
||
ancestry. Being reused counts as being visited, so the two reuse paths keep it
|
||
current; only a subtree nothing looked at can hold an old one, and nothing
|
||
under an unvisited subtree is being ordered. `depth` asserts the kept value
|
||
against the ancestry in debug builds, and the hundred-seed sweep passes with
|
||
those assertions on, reshuffles included -- those being what moves a widget to
|
||
another parent. Same load at 130 of 260 dirty: 8.16M instructions per frame to
|
||
7.14M, median 0.813 ms to 0.639, and the choosing from 25.8% of the frame to
|
||
4.7%.
|
||
|
||
What is left of it is iterating the dirty set, which a `HashSet` walks by
|
||
capacity rather than by length. Ordering it -- a `BTreeSet` keyed by the kept
|
||
depth, or a bucket per depth -- would take that too, but `needs_redraw` lives
|
||
on `Widgets` and is inserted from places with no view of the tree, so either
|
||
means a second structure inside the render state kept in step with it. For 4.7%
|
||
that is not obviously worth the coupling.
|
||
|
||
**Going order-free costs more than the order now does.** Measured 2026-09-14
|
||
with the depth carried, instructions per frame, on the same three loads:
|
||
|
||
| | 32 dirty | 130 dirty | resize |
|
||
| --- | --- | --- | --- |
|
||
| ordered, no check (what is in) | 2.80M | 7.14M | 6.70M |
|
||
| ordered, with the check | 2.83M | 7.18M | 6.72M |
|
||
| arbitrary, with the check | 3.12M | 7.38M | 6.28M |
|
||
|
||
So the check is nearly free where the order already makes it redundant, and
|
||
dropping the order costs 11% on a small dirty set. **Keeping the answer does
|
||
not help: it never hits.** The dirty set moves about once per check -- every
|
||
widget drawn takes its own mark out -- so an answer worked out against one
|
||
state of it is asked for against another, and a generation stamp invalidates
|
||
everything each time. Only a count maintained up the reader chain as marks come
|
||
and go would answer in O(1) without an invalidation to lose, and that is the
|
||
version still unbuilt.
|
||
|
||
One thing not understood: the arbitrary-order rows above are consistently ~6%
|
||
cheaper with the kept answer in than without it, though the kept answer never
|
||
hits and only two lines differ between the builds. Nothing here rests on that
|
||
difference, but it means those two rows are worth re-measuring before anyone
|
||
builds on them.
|
||
|
||
### An immediate-mode path, proposed by the owner
|
||
|
||
Iris raised this on 2026-09-15, as something to have rather than something to
|
||
do now: a way to render with nothing cached at all, redrawing everything cold.
|
||
Per widget would be nicer, but a switch on the ui, or a second entry point
|
||
alongside `update`, is the useful start. Two uses -- the performance floor a
|
||
retained layout is measured against, and a second opinion on correctness that
|
||
does not share any of the retained machinery.
|
||
|
||
Worth recording what it would and would not have caught. Not the `SetSize`
|
||
defect below: that one is a first frame laying out wrongly, with no retained
|
||
state involved, so an immediate path would have reproduced it faithfully. It
|
||
would catch anything where keeping a drawing is what goes wrong, which is what
|
||
`generated.rs` uses a cold `Harness` for today -- and a cold `Harness` is a
|
||
weaker instrument, because building a second tree is not the same code path as
|
||
refusing to reuse the first.
|
||
|
||
### Why a warm frame and a cold one disagree
|
||
|
||
Three defects, found on 2026-09-15 by shrinking a fuzzer's counterexamples.
|
||
Two are fixed. The third is the one the others were hiding.
|
||
|
||
**A first frame was wrong, and nothing about retained state was involved.**
|
||
`SetSize` drew its child in whatever box it had been offered and then reported
|
||
its *declared* length, so the child answered about a box it was never going to
|
||
have -- and the answer on the other axis was taken under that. A wrapping text
|
||
under `SetSize(x: 76px)` was measured in the whole 640 available, said one
|
||
line, and the parent sized itself to one line; the text was drawn again at 76
|
||
and said two, too late. It now measures in the length it declares. Six widgets
|
||
reproduce it in `tests/unsettled.rs`.
|
||
|
||
That one matters beyond itself: **`generated.rs` compares a warm frame against
|
||
a cold one, and cold was not a fixed point either.** Some of what this document
|
||
previously called a retained-layout defect was the cold side being wrong.
|
||
|
||
**Re-breaking a text at its own longest line is a knife edge.** A parent that
|
||
sizes to a child offers back the length the child just reported, composed back
|
||
through the box chain -- so it lands an ulp either side, and which side decides
|
||
whether the longest line still fits. Three lines at 167.41 or four at 163.49,
|
||
from the same text in the same box. A greedy break does not need recomputing
|
||
there: breaking at one width gives lines that each fit and none of which could
|
||
have taken another word, so at any width down to the longest of them the same
|
||
break holds. `TextBuffer::shape` answers across that interval, with a
|
||
sub-pixel tolerance for the edge.
|
||
|
||
**A box that is its own answer.** A span measures its children in its own
|
||
box, and its own box is what its parent gave it, from the size it reported,
|
||
from those children. Four widgets:
|
||
|
||
Aligned(mid, -, Span[ Text(wrap), OneLine ])
|
||
|
||
Swap the two children of a live tree and the span is still 663.376 wide, so the
|
||
text is offered 357.44, which is what it already holds; the size is valid, the
|
||
span reports 663.376 again, nothing moves. Grow the same tree in that order and
|
||
the span is offered the window, the text is asked for 334.06 and answers
|
||
318.45, and the span settles at 624.38. **Both are stable.** Which one you get
|
||
depends on what the tree was before.
|
||
|
||
No rule about when to keep a drawing can fix that, and several were tried
|
||
(`Painter::settle` -- place a child into its own reported size without
|
||
measuring it there -- passed the hand-written cases and failed two generated
|
||
ones). The defect is in *where a dirty widget is measured*: a local redraw
|
||
draws it in the box it was placed in, and when a reader decided that box from
|
||
the widget's answer, the old answer is a fixed point of measuring there
|
||
whatever the content now says. So `ActiveData::offered_px` keeps the pixel
|
||
size of the box the parent *first* asked about the child in -- `known_len` or
|
||
the first `place` of a draw -- beside `px`, the box it drew against. A dirty
|
||
widget whose size reads an axis on which some reader up its chain gave what
|
||
it read a box other than the one it asked in is not drawn locally: the chain
|
||
is marked and the parent of the highest such placement draws, since above it
|
||
every box is a constraint. The walk has to go up the whole reader chain rather
|
||
than one edge, because a pass-through hands a derived box down unchanged: the
|
||
second shrunk case is a `Scroll` placing a `SetSize` in a box the content
|
||
decided, and the span under it placed once, in that box.
|
||
|
||
That made `Scroll` show a second thing: it read its box's pixel length for
|
||
the clamp through `px_len`, which records the reported size as depending on
|
||
the box, and it does not -- a `Scroll`'s size is its content's. Every scroll
|
||
tick was then a size question asked in a derived box, at 34x the
|
||
instructions. `Painter::px_len_for_draw` is the read that records nothing,
|
||
for a draw whose reported size does not follow from it. Instructions per
|
||
frame on the depth-8 rig, 1000 frames, against the head before this:
|
||
|
||
| | before | after |
|
||
| --- | --- | --- |
|
||
| `many`, 32 dirty | 0.66M | 0.74M |
|
||
| `many`, 130 dirty | 27.7M | 26.5M |
|
||
| `resize` | 15.8M | 15.0M |
|
||
| `scroll`, `repaint`, `size` | 0.36M | 0.36M |
|
||
|
||
The small load pays 12% for the escalations that are now required; the
|
||
larger ones get cheaper because a `Scroll`'s retained size no longer goes
|
||
invalid when its box changes. The reorder fuzzer passes 200 trees at depth 7
|
||
in every case, and the sweep passes.
|
||
|
||
`OrthoSize::{Fill, Children}` -- a span choosing whether to fill across its
|
||
axis or report its longest child -- is written and parked for the same reason:
|
||
`Children` reads the span's own box to rank candidates, which is this cycle
|
||
with a second face. It is not the cause of anything, and the divergence it was
|
||
blamed for reproduces without it.
|
||
|
||
### Shrinking a counterexample
|
||
|
||
`tests/generated.rs` is a fuzzer, and a seed is not a lead anybody can read:
|
||
reconstructing one of its failures by hand failed three times. Two things fixed
|
||
that, and both are worth keeping.
|
||
|
||
`describe` prints what each ancestor of a mismatch was *configured* with rather
|
||
than its type name, so a run says `Text < SetSize{x:34 px;} < Aligned{x:neg,
|
||
y:pos}` and the tree can be written out again. `Widget: Any`, so it needs no
|
||
plumbing.
|
||
|
||
`tests/shrink.rs` grows trees from a description it can simplify -- drop a
|
||
child, unwrap a wrapper, shorten a text, drop a declared length, reorder a
|
||
span -- and takes the first simplification that still fails until none does. It
|
||
reduced 402 widgets to 6, 905 to 6, and 486 to 4. It lives in the tests and the
|
||
library knows nothing about it. Validate it after changing it: with the
|
||
box-length check in `try_reuse` deliberately disabled it should reduce a
|
||
96-widget tree to 2, and an early version of it silently found nothing because
|
||
it never framed before resizing, so "warm" had no retained state at all.
|
||
|
||
Run the ordinary tests first, then the fuzzers, and turn what they find into a
|
||
fast test rather than leaving a seed as the record.
|
||
|
||
**Depth 4 was hiding all of this.** `tests/generated.rs` ran at a constant
|
||
`DEPTH = 4`; `IRIS_GENERATED_DEPTH` and `IRIS_GENERATED_SEEDS` now select the
|
||
load, and `SHRINK_DEPTH`, `SHRINK_SEEDS` and `SHRINK_CASE` do the same for the
|
||
shrinking fuzzer. The generator widens two to four ways per level, so depth
|
||
buys overlap between dependency paths rather than ancestry. Nothing yet covers
|
||
a deep narrow chain, which is a gap: Iris asked for high layer counts on
|
||
2026-09-14 and a hundred-deep tree is still unreachable by turning this knob.
|
||
|
||
The comment on `a_long_run_of_seeds_agrees` blames a wrapping text on a span's
|
||
own axis and says such a text is stable on any other axis. Both halves are
|
||
wrong -- a divergence was found on a span whose axis is Y -- and the "7 of
|
||
these 90 / 30 with it" numbers beside it do not match a sweep that passes
|
||
clean. Re-measure before trusting them.
|
||
|
||
### Dirtying many widgets at once
|
||
|
||
**A frame that dirties many widgets at once was not being checked, and it is
|
||
where the settle order shows.** `77bb75e` adds two generated cases -- every
|
||
declared size changing together, and a spread of widgets marked for redraw
|
||
together, where nothing changes and so no box may move -- and `IRIS_PHASE=many`
|
||
to the rig, with `IRIS_DIRTY` widgets marked per frame. At 130 of 260 widgets,
|
||
**choosing which dirty widget to settle next is 24.5% of the frame**: the dirty
|
||
set is scanned once per widget settled, and a `HashSet` is walked by capacity
|
||
rather than by length. Memoizing the depth walk within one scan does not pay
|
||
(it trades parent lookups for memo lookups, 4% more instructions); the fix is
|
||
to stop rescanning.
|
||
|
||
**That fix does not have to change the order, and the cost is not the
|
||
choosing.** Measured 2026-09-14 on the `many` phase at 130 of 260 dirty, all
|
||
4.7% of it is one symbol: hashbrown's `RawIterRange::fold_impl`, the table walk
|
||
under `max_by_key`. Since `3f7cd82` the key is a field read, so what is left is
|
||
the cost of *finding* an element in a set walked by capacity, once per widget
|
||
settled -- not the cost of deciding between them. A depth-bucketed worklist in
|
||
`UiRenderState` keeps the order exactly and makes the pop O(1); the objection
|
||
that `needs_redraw` lives on `Widgets` and is inserted from places with no view
|
||
of the tree does not apply to it, because the buckets are a hint rather than a
|
||
second truth. A bucket entry whose mark has since been consumed is skipped on
|
||
pop for one hash lookup, so the obligation is only never to miss an insert,
|
||
never to stay in step.
|
||
|
||
**Going order-free is not the thing to buy.** `iter().next()` in place of the
|
||
deepest-first pick, with no check added, takes widget draws from 502 to 670 per
|
||
frame and instructions from 6.84M to 9.01M. The extra draws are not a parent
|
||
drawn twice on its own: the same widgets appear at the top of the per-widget
|
||
draw counts in both orders, at the same trial widths (a `Text` under
|
||
`SetSize < Scroll < Span` is drawn at 14 distinct widths either way), with
|
||
every count about 1.5x. The measure-by-drawing loop is not exploring more, it
|
||
is being re-entered. What drives the re-entry is that boxes change more often:
|
||
`try_reuse` refusals for a changed box go from 79 to 116 own-resize and 126 to
|
||
187 descendant-resize, because a reader settled before its children hands out
|
||
boxes from sizes that are about to move, and when they move every child in the
|
||
subtree refuses reuse. The `dirty_size_under` check is what buys that back,
|
||
which is why the order-free rows above come out 3.4% dearer at 130 dirty rather
|
||
than 32%. So the order is paying for itself in draws, and the count maintained
|
||
up the reader chain would be a way to afford dropping something worth keeping.
|
||
|
||
One counter does not fit that account and was not chased down: queue pops fall
|
||
from 87 to 80 and local redraws from 66 to 59, where re-marking a reader should
|
||
raise both. The likely reading is that a re-marked reader is consumed inside a
|
||
later pop's subtree draw rather than popped on its own, but it is inferred from
|
||
`redraw`'s escalation path rather than traced. `EagerReaderRedraws` is 4 and 3,
|
||
so the escalation itself is not where the draws come from.
|
||
|
||
**What a frame is made of now**, `perf record` on the `many` phase at 130 of
|
||
260 dirty, which is the heaviest thing the rig has: `Layers::write` 16%,
|
||
parley's `break_all_lines` 15%, `Painter::glyphs` 14%, `InstanceList::push` 7%,
|
||
`LayerDraws::free` 6%, `draw_inner` 5.5%, choosing the next dirty widget 4.7%.
|
||
Four fifths of it is primitive bookkeeping, and none of that has had a pass.
|
||
That is where to look next rather than at the settle loop.
|
||
|
||
The remaining costs after this are:
|
||
|
||
- **Writing a glyph instance per glyph per draw.** With shaping and placement
|
||
kept, `Painter::glyphs`, `Layers::write` and `InstanceList::push` are most of
|
||
what is left. A cold frame writes 48,050 instances for a tree that
|
||
holds 10,872. What reaches the GPU is 10,872, since a layer uploads whole and
|
||
`set_instance` cancels a dirty mark when the bytes are unchanged -- so this is
|
||
CPU cost only, but a one-leaf repaint still uploads 5,863 instances where the
|
||
pre-#16 code uploaded 106.
|
||
- **A container measures a child by drawing it in a box it will not keep.**
|
||
The remaining-region trial removes many mismatches, but an unknown child's
|
||
measured length can still make its final box differ, and nested containers
|
||
compound those redraws. The general form of the shaping store is a widget
|
||
answering from a box it has already drawn in, which -- unlike a size answer --
|
||
is realized by definition; the cost is retaining more than one drawing's
|
||
primitives, which is why text, where only the shaped layout has to be kept,
|
||
is where it is worth doing first.
|
||
|
||
## The random trees
|
||
|
||
`iris::random` grows a seeded tree -- spans in every direction holding two to
|
||
four children, stacks, scrolls on either axis, alignment on either axis,
|
||
padding with each of its four sides its own number,
|
||
rects with varying opacity, text both wrapping and overflowing, a declared size
|
||
over half of it, stopping at a depth. `examples/random.rs` draws one
|
||
(`IRIS_SEED`, `IRIS_DEPTH`). `tests/generated.rs` grows each seed twice -- once
|
||
and then changed, once with the change built in -- and compares every widget's
|
||
box across eight scenarios: a size change, a resize, both, and five ways of
|
||
changing what a span holds. `a_long_run_of_seeds_agrees` is the ignored sweep,
|
||
100 seeds across all eight, 800 comparisons, about four minutes.
|
||
|
||
The property is that laying a tree out again lands where growing it cold does,
|
||
which is the same thing as layout being a function of the state. It earned
|
||
itself immediately: it found the non-terminating marking, the pixel-box reuse
|
||
check and the measured-size rule above, none of which the hand-written tests
|
||
reached, and it says the result is better than what it started from -- 90 of 90
|
||
against 83 on `db1751f`.
|
||
|
||
Four things about it that are easy to get wrong:
|
||
|
||
- **Both trees must make the same widgets in the same order.** Comparison is
|
||
index for index, so a tree that makes fewer widgets, or frees one whose id is
|
||
then handed to the next, stops lining up at the first difference and every
|
||
comparison after it is against the wrong widget. Hence three spare leaves
|
||
grown beside every span whether they end up in it or not, and detached
|
||
children held until the comparison is over.
|
||
- **Attaching a spare moves it.** A widget belongs to one parent;
|
||
`WeakWidget::upgrade` registers an add and panics with "cannot add a widget
|
||
twice", so it is for a handle that was never added, not a second share.
|
||
- **Each shuffle asserts the tree actually changed** before comparing, or a
|
||
case that quietly did nothing passes green.
|
||
- **A failure prints the widget's ancestry**, marking the ones that own a slot,
|
||
because where two trees disagree is rarely where the cause is.
|
||
|
||
## Verifying a slice
|
||
|
||
```sh
|
||
cd <iris-worktree>
|
||
cargo fmt --all --check
|
||
cargo clippy --workspace --all-targets -- -D warnings
|
||
cargo test --workspace
|
||
```
|
||
|
||
50 tests pass on #18's head. `--workspace` matters: `rig-input` is a crate of
|
||
its own.
|
||
|
||
Render checks are the last pass, not the iteration loop -- the owner asked for
|
||
that on 2026-09-14, since the layout tests cover the CPU part and the shots
|
||
cost real time:
|
||
|
||
```sh
|
||
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/out.png
|
||
./scripts/run-headless.sh tabs --replay /tmp/taps.touch --shot /tmp/out.png
|
||
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --resize 900x1200@60Hz --shot /tmp/rs.png
|
||
```
|
||
|
||
- The reference shots are `tabs`, `view`, `minimal` and `text` at 1920x1200,
|
||
plus `tabs` with a replay that switches to the image tab and adds two images.
|
||
A `.touch` line is `<ms> down|move|up <x> <y>` in the output's own pixels; the
|
||
tab strip is at y=24 and the five tabs at x = 192, 576, 960, 1344 and 1728,
|
||
with the image tab's add button near (1836, 1116).
|
||
- **A resize is its own case**, and `--resize` is it: the output changes under
|
||
the running app, and what it lands on must match a cold start at that size
|
||
byte for byte. That caught both of #16's defects and nothing in `cargo test`
|
||
can see it.
|
||
- **Run one at a time.** The rig reuses a single compositor and a single
|
||
output, so two invocations at once resize each other's window and quietly
|
||
screenshot the wrong thing. Two sets of shots were thrown away learning that.
|
||
- **Give a comparison worktree its own target dir.** While one was shared
|
||
between two checkouts I got results I could not reproduce afterwards; the
|
||
mechanism was never pinned down, so re-run any cross-checkout comparison in
|
||
isolation before believing it.
|
||
|
||
Two drawing paths still have no shot of their own, and each needs a ui the
|
||
examples do not have, so both are throwaway examples written into the worktree
|
||
and deleted after:
|
||
|
||
1. An image alone in a layer, which is the case that failed GPU validation when
|
||
every other test happened to have a rectangle in the same layer.
|
||
2. Six lines of 400px text, which forces the atlas to four pages and proves the
|
||
array grew and its group was rebuilt.
|
||
|
||
`tabs` with the image replay covers rects, glyphs and images together, so that
|
||
one is an ordinary check now.
|
||
|
||
## How the work is sequenced
|
||
|
||
**Most fundamental first**, from the owner on 2026-09-13: *"please do more
|
||
fundamental changes first, such as library updates and core framework changes,
|
||
so that code only has to be written once"*, and *"should probably start adding
|
||
tests early on rather than later, so you don't have to make separate test
|
||
scripts and stuff."* So slices are ordered by how much depends on them, not by
|
||
what is nearest ready, and a slice arrives with tests rather than with a script
|
||
in `/tmp`.
|
||
|
||
**Agree a design before sending another variation of it.** The owner stopped
|
||
the fourth round of #11 with *"we should probably agree on the design here
|
||
rather than you keep submitting variations that I review"*. When a review comes
|
||
back about the shape of something rather than a defect in it, put the options
|
||
and a recommendation in front of her and implement what she picks.
|
||
|
||
**Nothing is submitted without a separate review pass** -- the installed
|
||
`pre-submit-review` skill: build clean, review the code, review the comments on
|
||
their own once the code has settled, then verify the claim by running it. The
|
||
fixes a review produces are themselves unreviewed code, so the passes repeat
|
||
until a round finds nothing. It has earned its place repeatedly: four defects
|
||
on #11 that format, clippy, tests and five headless renders had all passed, and
|
||
on #12 a regression introduced by the review's own first draft. `audit.sh` in
|
||
the skill directory prints every comment line a branch adds against a base ref;
|
||
the owner's standing complaint is verbose agent comments, and the default
|
||
verdict is delete. **Machine-specific notes do not belong in the repository** --
|
||
they live in `~/.claude/MACHINE.md` or a `this-machine-*` skill.
|
||
|
||
Other standing instructions from the owner:
|
||
|
||
- Pull Iris out even if the Rust application switchover is not accepted. No
|
||
app, session, transcript, setup or server concepts in Iris; the dependency
|
||
runs one way from `app/` to Iris.
|
||
- **Small, coherent PRs.** The original extraction PR was too large to review.
|
||
A slice may be redone rather than transplanted, and need not remove every old
|
||
feature. Non-conflicting pull requests may be open at once -- disjoint path
|
||
sets, each branched from current `upstream/main` rather than stacked. She
|
||
reviews small ones as they arrive and only avoids two *large* ones in flight.
|
||
- **Order by dependency, largest reach first.** On 2026-09-13: *"do the large
|
||
reaching framework changes first so less has to be redone."*
|
||
- Do not recreate an `ai` branch in canonical Iris; the fork is the boundary.
|
||
- **Never rewrite a pushed branch.** Follow review with additive commits, and
|
||
merge `upstream/main` in rather than rebasing when a branch falls behind.
|
||
- Respond to each review finding with a fix or a concise explanation. Do not
|
||
add a ceremonial comment when the changed code already answers it.
|
||
- **A test has to guard something that could break again.** She deleted #14's
|
||
test as pointless: the rename it guarded cannot regress. When a fix is
|
||
structural, the structure is the test.
|
||
- **Say who decided a constraint.** LAYOUT.md §2's "move slots carry
|
||
translation only" was written by an agent on 2026-09-04, was never asked for,
|
||
and read as settled until she said *"I was not aware that an agent decided
|
||
position slots should be translate only."* Mark an agent's own choice as one.
|
||
|
||
## What is left
|
||
|
||
**Next, and small:** `LazySpan`, the last of LAYOUT.md §2. `set_child_offset`
|
||
is no longer part of it -- a child offset is just placing the child, which
|
||
`Painter::place` now does.
|
||
|
||
Then, roughly in dependency order:
|
||
|
||
- **Built-in alignment, and probably size**, which the owner moved ahead of the
|
||
rest on 2026-09-14. Reproduced in the harness: `.width(rel(0.5))` inside a
|
||
`Dir::DOWN` span reports 200 of 400 and is handed the whole 400, and a `Pad`
|
||
in between does not change that. **Do not "fix" it by reading the child's
|
||
ortho `size_hint`** -- a `Pad` between the `SetSize` and the span has no hint
|
||
of its own, so the declared width silently goes back to filling. It works
|
||
only when nothing is in the way. Alignment has to belong to the widget rather
|
||
than be discovered through whatever happens to sit on top of it.
|
||
|
||
Two things beyond the bug argue for it. Built-in size removes `SetSize`, and
|
||
with it a wrapper reporting one size while handing its child the whole box.
|
||
And built-in alignment is what would let `OnResize::Translate` apply to
|
||
centred content, which otherwise has to say `Redraw` because only its own
|
||
draw knows where the middle was. Size is the harder half: a declared size
|
||
beside the one `draw` returns is two sources of truth for one thing, so
|
||
settle what each means before building it. This is independent of #18's
|
||
retained-update fix now that `Aligned` is in its generated coverage; land
|
||
#18 first, then design built-in alignment and size together as the next
|
||
structural slice rather than mixing that representation change into this
|
||
performance correction.
|
||
- **`OnResize::Translate`, which still does nothing.** The chain removed half
|
||
its obstacle: a placed widget's slot holds the box it was offered while its
|
||
drawing is a set of fractions of that box, so the two are no longer one
|
||
field. What is still missing is a widget saying *where* in a bigger box its
|
||
unchanged drawing should sit, which is the alignment work above.
|
||
- **`UiRenderState` behind `Rc<RefCell<..>>`**, queued by the owner on
|
||
2026-09-13 as fundamental, and especially so for text.
|
||
- **`Len`, `LayoutLen` and dp.** The archive splits the type so that `rest` is
|
||
unrepresentable where it is meaningless (a padding), and folds a density in
|
||
at resolve time. 21 files mention `Len`, so it is wide but shallow.
|
||
- **The input restructure** -- `src/default/sense.rs` becomes `src/rsc/sense.rs`
|
||
(308 lines to 2313), plus `core/src/event/controller.rs`, `desktop/input.rs`,
|
||
`android/input.rs` and `sense_tests.rs`: pointer capture, drag slop and axis,
|
||
platform cancellation, mask-aware hit testing, event timestamps. The
|
||
archive's own `consumes` is what #12 landed, so that part transplants;
|
||
`tests/pointer_routing.rs` is the acceptance criterion.
|
||
- Retained span, scrolling and layout placement.
|
||
- Retained paints, selection, overlays and shared UI runtime state.
|
||
- Generic desktop/Android framework hosts and reusable example/APK tooling.
|
||
- Application-owned fonts and application-named font families.
|
||
- Shared resource-handle bookkeeping and replaceable glyph-atlas buckets.
|
||
- Positioned text overflow and cluster-safe ellipsis.
|
||
|
||
Dependencies are current apart from `winit`, which stays on 0.30.12 until 0.31
|
||
leaves prerelease. `parley` 0.11.1 and `image` 0.25.10 are latest.
|
||
|
||
The archive is a reference, not a patch to apply -- it writes `Widget::draw`
|
||
against `painter.set_size`, which #16 replaced with a returned `Size`, and
|
||
lengths against `LayoutLen` and `density`, which canonical does not have.
|
||
Recreate a change on today's types, leave app-specific behaviour out, and
|
||
verify it independently.
|
||
|
||
```sh
|
||
cd /home/bob/repos/iris && git fetch upstream
|
||
git diff --stat upstream/main..origin/archive/full-extraction
|
||
```
|
||
|
||
## How the renderer works now
|
||
|
||
Current invariants, not history. Worth reading before touching `core/render`.
|
||
|
||
- **A primitive registers itself by being drawn.** The type carries its own
|
||
WGSL, and `PrimitiveRegistry` keys ids by `TypeId`, so the kind comes from
|
||
the type and there are no `RECT`/`GLYPH`/`TEXTURE` constants. Nothing is
|
||
seeded, so an id depends on what a ui drew first and a ui pays only for the
|
||
pipelines it uses.
|
||
- **Each primitive records its own draws.** `Primitive::render` makes a
|
||
`PrimitiveRender` that states the layout its shader reads, uploads whatever
|
||
it owns, and records its draws. `GlyphRender` owns the atlas and binds it
|
||
once per list; `ImageRender` owns the images and binds one per instance; the
|
||
default owns nothing and draws every instance in one call. The renderer sets
|
||
the pipeline, the shared group, the list's data and its vertex buffer, and
|
||
knows nothing else.
|
||
- **The shared bind group is the window, the masks and the move chain**, given
|
||
to every draw. A mask texture would go here too. What a primitive samples is
|
||
its own group, and a primitive that samples nothing has no such group in its
|
||
pipeline.
|
||
- **Every binding size is stated.** A `None` minimum puts the binding on
|
||
wgpu-core's late-sized list, which `is_ready` scans on every draw.
|
||
- **`shader/prelude.wgsl` plus one file per primitive**, because one module
|
||
cannot declare two types at the same binding. The prelude carries only what
|
||
every primitive uses -- window, masks, the chain walk, the vertex shader,
|
||
`masked()` -- and its header is where binding numbers are written down.
|
||
- **A texture handle is drawn like anything else.** `Painter::primitive` takes
|
||
`impl PrimitiveLike`: a primitive, or something that yields one and does
|
||
whatever else drawing it needs -- a `&TextureHandle` retains its share on the
|
||
way through, which a `Pod` primitive cannot.
|
||
- **Order within a layer means nothing**, and the widgets do not rely on it:
|
||
`Stack` gives each child its own layer and `TextEdit` draws its view in a
|
||
child layer above the selection rectangles.
|
||
- **Images are one texture and one bind group each**, so each drawn image is a
|
||
draw call. The owner chose that on 2026-09-13 over packing images into arrays
|
||
like atlas pages; a bindless `binding_array` was ruled out by Android
|
||
support. Revisit only with her.
|
||
- **Layers are never freed** (`TODO` in `primitive/layer.rs`), so every layer a
|
||
session creates is walked every frame thereafter. Measured at ~2ns per empty
|
||
layer per frame, which is why it is the TODO's problem and not a bug of its
|
||
own.
|
||
|
||
## Repository topology
|
||
|
||
### ai-app checkout
|
||
|
||
- `/home/bob/repos/ai-app-2`, `origin = git@git.arirex.me:iris/ai-app.git`,
|
||
branch `rustify`.
|
||
- `iris/` is a submodule pinned at `32f6ad8`, the complete extracted snapshot,
|
||
and `.gitmodules` points at the **bot fork**, not canonical Iris.
|
||
- Do not change either casually: ai-app needs the complete snapshot while
|
||
canonical Iris is only partly caught up. Reconcile when canonical contains
|
||
what ai-app needs, or when the owner accepts a temporarily non-building pin.
|
||
|
||
### standalone Iris checkout
|
||
|
||
- `/home/bob/repos/iris`, `origin` = fork, `upstream` = canonical.
|
||
- Fork `main` and `origin/archive/full-extraction` both name `32f6ad8`, the
|
||
target snapshot. `history/full` names the source-history result `a615bcd`.
|
||
- **Do not reset, overwrite or force-push fork `main`**: it is both the target
|
||
reference and the commit ai-app pins.
|
||
- Start each new branch from current `upstream/main` in its own worktree:
|
||
|
||
```sh
|
||
cd /home/bob/repos/iris && git fetch upstream
|
||
git worktree add -b split/19-name /home/bob/repos/iris-pr19 upstream/main
|
||
```
|
||
|
||
`/home/bob/repos/iris-pr18` is the live one. Every other `iris-pr*` worktree
|
||
holds a merged branch; they are readable references, not places to build.
|
||
`/home/bob/repos/iris-perf2` is detached at `b1b3eca`, built, and is the
|
||
"before" side of any measurement of the last two commits; `iris-size-old` is
|
||
`43ce8c7` with the old patched counters, and is how the pre-#16 column above
|
||
was measured. Copy `tests/revision_cost.rs` into either to compare.
|
||
`iris-old-cmp` (`43ce8c7`) and `iris-main-cmp` (`upstream/main`) are unpatched
|
||
worktrees with target directories of their own, for the instruction counts and
|
||
the reference renders above; `43ce8c7` calls it `Len::abs` where this branch
|
||
says `Len::px`, which is the only edit a copied fixture needs.
|
||
|
||
## Cautions
|
||
|
||
- Read `/home/bob/repos/ai-app-2/AGENTS.md` and the machine-wide rules first.
|
||
Anything about this machine -- the GPU that comes and goes, measuring a small
|
||
performance difference, the emulator -- is in `~/.claude/MACHINE.md` and the
|
||
`this-machine-*` skills, and belongs there rather than here.
|
||
- Keep Iris generic: session drivers, transcripts, setup and server concepts,
|
||
app icons and product fonts stay in ai-app. Android and desktop code is Iris
|
||
work only when it is a generic host or platform integration.
|
||
- Preserve the dirty-worktree rule. All worktrees were clean at handoff;
|
||
anything found later may be the owner's or another agent's.
|
||
- Do not delete the archived snapshot or the fork `main` ai-app pins.
|
||
- A complete target branch is not permission to recreate the giant PR.
|
||
- Another agent was freeing disk on this VM and removed `target/` from the
|
||
`iris-pr*` worktrees once. Sources and git state were untouched. Tell peers
|
||
before changing shared machine tooling, and expect a cold rebuild sometimes.
|
||
|
||
## Merged so far
|
||
|
||
| PR | On canonical `main` |
|
||
| --- | --- |
|
||
| #2 | Build on the current nightly (`4275314`) |
|
||
| #3 | Request a frame after resize (`936fbdd`) |
|
||
| #4 | Decouple `iris-core` from winit (`465e430`) |
|
||
| #5 | Use vsync by default (`ec2b5d4`) |
|
||
| #6 | Notify winit before presenting (`db9b0f2`) |
|
||
| #7 | Keep unsafe reference helpers internal (`0191f20`) |
|
||
| #8 | Initialize the window uniform from the surface (`6e271e8`) |
|
||
| #9 | Preserve primitive-count recursion (`b90c855`) |
|
||
| #10 | Text layout and rendering on Parley (`0f6a28b`) |
|
||
| #11 | Atlas as an array texture, and the primitive rendering overhaul (`b234497`) |
|
||
| #13 | Build on wgpu 30 (`00d2230`) |
|
||
| #14 | Rename the `Sized` widget to `SetSize` (`32b1038`) |
|
||
| #15 | Run a ui without a window, and test one (`c8ac669`) |
|
||
| #12 | Route pointer input per kind (`43ce8c7`) |
|
||
| #16 | Size a widget while drawing it, not in a pass of its own (`f942385`) |
|
||
| #17 | Bring the headless rig into the repository (`ca2b4b2`) |
|
||
|
||
URLs are `https://git.arirex.me/iris/iris/pulls/{number}`.
|