Only rel doubles: apply_rest turns rest into the whole box and px coincides. Aligned and Scroll honour a hint the same way Span does, so all three double it. Also corrects the note about a fractional hairline, which fails because a fraction is a fraction of its own box rather than through any rounding, and records that a span whose shares run out walks its children backwards instead of overflowing forward. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
64 KiB
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.
A declared rel length is applied twice, and it is the one thing to fix
before #18 lands. .width(rel(0.5)) in a 400-wide span puts the SetSize at
0..200 and its child at 0..100: the span sizes the box from the hint, and
c596bf1 then measures the child in the declared length again inside that
box. px is unaffected, since 200 px of a box already 200 px wide is the whole
box, and rest is unaffected because Len::apply_rest turns any rest into
rel(1.0), which is the whole box too. Only rel doubles, and it doubles
under every placer that honours a hint -- Span, Aligned and Scroll all
call known_len -- while a Pad or the root, which do not, are correct. It shows in the text
example, whose "same words in half the width" panel is 474 px where
upstream/main draws 948, and that is 111,923 pixels of difference, so the
claim above that the five reference renders are byte-identical is wrong for
text; tabs, view and minimal are identical. The structural fix is the
built-in size already queued below -- a declared length resolved once, by the
parent -- rather than a second rule inside SetSize, which cannot tell whether
its box is its own declared length or the whole of what its parent had.
Minimal repro: (inner.width(rel(0.5)), rect()).span(Dir::RIGHT) at 400 wide,
asking where inner landed.
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.
4063635 adds the two hairline tests to tests/layout.rs, and 169db7f
writes the shader's scalar_within the way UiScalar::within writes it --
from + (to - from) * t rather than mix -- so the CPU and the GPU compose a
position with the same arithmetic and one multiplication fewer. All five
renders are unchanged by it.
Check for a review before starting anything, and read the newest submitted_at
rather than the first result:
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.wgslcomposes the chain withwithin. A translation is the special case where the box has its parent's relative extent. The identity isUiRegion::FULL, not zero -- a zeroed entry is a box of no extent and collapses its subtree to a point, whichMoveOffset's comment says beside theZeroablethatPodrequires. - 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/abspair is exactly what distinguishes the two. - Slots are opt-in.
Painter::placedraws a child whose box its parent decides and may decide again, and that child gets a slot;widgetandwidget_withindo not, and share the nearest ancestor's.Span,AlignedandScrollplace. 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::FULLand its box lives in its slot.ActiveData::regionis the box it was placed in, in its parent's slot coordinates, andActiveData::parent_moveis the slot that is in;window_regioncomposes the one through the other, which is the walk the shader does. - Nothing inverts a lerp.
UiRegion::stretch,stretchableandUiScalar::stretchare 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 atFULLof 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_underredraws a child whose size the widget read unless it declares an exactsize_hintfor 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, soOnResize::Scaleearns its keep on moves and on subtrees whose sizes nobody read rather than on every stretch. redraws_underis 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, andwithinis a function of thatrelalone, so the shader'sfloor(rel * dim)is the same for both edges while thepxparts 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 thefloor; 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 bytests/layout.rs, which composes sixteen hairlines under four levels of span and padding onrest(3)/rest(7)/rest(5)weights and applies the shader's own snapping at four output sizes. A span short of room takes it from its shares and never from a fixed length, though a share that has run out does not stop at zero: three 60 px children in a 100 px box overflow forward to 0..60, 60..120 and 120..180, which is what the owner intends, but putting arestshare between each pair gives the shares -40 px and walks the children backwards to 0..60, 20..80 and 40..100. Clamping a share at zero is the fix, and is not in yet: the shares reach zero and then go past it, so the fixed parts start overlapping each other -- at 400 wide three marks sit at 132, 265 and 399, at 3 wide at 0, 1 and 2, and at 1 wide two of them land on the same pixel. Overlapping is what hides a separator in an over-constrained tree; collapsing one never happens. The same line written asrel(1.0 / 1920.0)fails the test, but not through rounding: a fraction is a fraction of that widget's box, so at the root of a 1920-wide output it is exactly one pixel and two spans down, where the box is half the output, it is half a pixel and floors to nothing. That is whatrelmeans rather than a defect, and it is the reason a hairline is written inpx. - 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,SetSizeandLayerOffsetsayScale; each places in fractions and offsets of its own box and none reads its pixel length.ScrollandMaxSizeread pixels and stayRedraw, which is the general rule:Scaleon an axis unless the draw reads the pixel length of its box on that axis. The default staysRedraw.OnResize::Scalekeeps its name. The owner rejectedStretchon 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_innerkeys 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_summeasures 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:
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
Sizeis 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.patchmissed 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::writeandInstanceList::pushare 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 andset_instancecancels 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::upgraderegisters 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
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:
./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,minimalandtextat 1920x1200, plustabswith a replay that switches to the image tab and adds two images. A.touchline 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
--resizeis 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 incargo testcan 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:
- 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.
- 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/mainrather 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
aibranch in canonical Iris; the fork is the boundary. - Never rewrite a pushed branch. Follow review with additive commits, and
merge
upstream/mainin 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 aDir::DOWNspan reports 200 of 400 and is handed the whole 400, and aPadin between does not change that. Do not "fix" it by reading the child's orthosize_hint-- aPadbetween theSetSizeand 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 letOnResize::Translateapply to centred content, which otherwise has to sayRedrawbecause only its own draw knows where the middle was. Size is the harder half: a declared size beside the onedrawreturns 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 thatAlignedis 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. -
UiRenderStatebehindRc<RefCell<..>>, queued by the owner on 2026-09-13 as fundamental, and especially so for text. -
Len,LayoutLenand dp. The archive splits the type so thatrestis unrepresentable where it is meaningless (a padding), and folds a density in at resolve time. 21 files mentionLen, so it is wide but shallow. -
The input restructure --
src/default/sense.rsbecomessrc/rsc/sense.rs(308 lines to 2313), pluscore/src/event/controller.rs,desktop/input.rs,android/input.rsandsense_tests.rs: pointer capture, drag slop and axis, platform cancellation, mask-aware hit testing, event timestamps. The archive's ownconsumesis what #12 landed, so that part transplants;tests/pointer_routing.rsis 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.
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
PrimitiveRegistrykeys ids byTypeId, so the kind comes from the type and there are noRECT/GLYPH/TEXTUREconstants. 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::rendermakes aPrimitiveRenderthat states the layout its shader reads, uploads whatever it owns, and records its draws.GlyphRenderowns the atlas and binds it once per list;ImageRenderowns 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
Noneminimum puts the binding on wgpu-core's late-sized list, whichis_readyscans on every draw. shader/prelude.wgslplus 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::primitivetakesimpl PrimitiveLike: a primitive, or something that yields one and does whatever else drawing it needs -- a&TextureHandleretains its share on the way through, which aPodprimitive cannot. - Order within a layer means nothing, and the widgets do not rely on it:
Stackgives each child its own layer andTextEditdraws 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_arraywas ruled out by Android support. Revisit only with her. - Layers are never freed (
TODOinprimitive/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, branchrustify.iris/is a submodule pinned at32f6ad8, the complete extracted snapshot, and.gitmodulespoints 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
mainandorigin/archive/full-extractionboth name32f6ad8, the target snapshot.history/fullnames the source-history resulta615bcd. - 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/mainin its own worktree:
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.mdand 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.mdand thethis-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
mainai-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 theiris-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}.