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