815 lines
47 KiB
Markdown
815 lines
47 KiB
Markdown
# Iris extraction handoff
|
||
|
||
Operational handoff for pulling Iris out of ai-app into a standalone framework.
|
||
Not a decisions log; delete it when the extraction is done.
|
||
|
||
## 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 `3f7cd82`, twenty-four commits,
|
||
workspace tests passing, fmt and clippy clean. It is LAYOUT.md §2's position
|
||
chain, generalised to boxes. The last six are a performance pass that changes
|
||
no layout: the two halves of the text fix (`e5f8b6b`, `2525637`), three
|
||
retained-layout details that claimed something they did not do (`f1a47e9`),
|
||
the many-widgets-dirty cases (`77bb75e`), what the settle order is holding up
|
||
(`bf94380`), and carrying a widget's depth rather than walking for it
|
||
(`3f7cd82`). Every one of them keeps the five reference renders and the resize
|
||
render byte-identical, and the hundred-seed sweep passes. `/home/bob/repos/ai-app-2` is on `rustify`, worktree clean.
|
||
|
||
**The one thing waiting on the owner.** `tabs` at 1920x1200 is no longer
|
||
byte-identical to `upstream/main`: 1,283 pixels of 2.3M (0.06%), two
|
||
one-pixel-wide panel edges shifted by a pixel, at x=1056 and x=1337. `view`,
|
||
`minimal` and `text` are identical. Composing a position through the chain in
|
||
the shader associates the arithmetic differently from collapsing it on the CPU,
|
||
so a value that used to land exactly on an integer falls the other side of the
|
||
shader's `floor`. The CPU and the GPU still agree with each other -- both walk
|
||
the chain bottom-up -- so hit testing matches what is drawn; what changed is
|
||
only the comparison against the old code. Matching it exactly means composing
|
||
root-down in the shader, which needs the chain collected into an array first.
|
||
Byte-identical against `upstream/main` has been the bar for every slice, so
|
||
this is hers to accept or to spend a commit on.
|
||
|
||
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 *offered*, 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.
|
||
- **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. 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.
|
||
- 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.
|
||
|
||
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.
|
||
|
||
**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.
|
||
|
||
**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, which changes the order widgets settle in and is hers to
|
||
agree first.
|
||
|
||
**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.
|
||
|
||
## 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}`.
|