Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6450615434 | ||
|
|
d24ff83d6b | ||
|
|
ea66fc3f71 | ||
|
|
e397680a19 | ||
|
|
b788cd1382 | ||
|
|
338dd00f76 | ||
|
|
a7da12a8ff | ||
|
|
5f01dc65c8 | ||
|
|
17423a57b8 | ||
|
|
ddaf9b37d6 | ||
|
|
992a4a2e97 | ||
|
|
e6a035d06e | ||
|
|
048738bb9d | ||
|
|
4c7072e62f | ||
|
|
ab8b05b762 | ||
|
|
149a6838af | ||
|
|
6a92857079 | ||
|
|
da6b003a1e | ||
|
|
8adad924b9 | ||
|
|
0b60713806 | ||
|
|
5b34d406c7 | ||
|
|
29dd84e04e | ||
|
|
5bf0491ee1 | ||
|
|
adce0fcdbe | ||
|
|
0ace0e017c | ||
|
|
734c521f5d | ||
|
|
718923c217 | ||
|
|
03c8933612 | ||
|
|
362191c3f3 | ||
|
|
52a5c2f40c | ||
|
|
55b51d9442 | ||
|
|
1442443e78 | ||
|
|
d12cbcd499 | ||
|
|
424f655d60 | ||
|
|
721a8fc50d | ||
|
|
61a2f601bb | ||
|
|
265ce6f829 | ||
|
|
56437dc90f | ||
|
|
1f9eea879f | ||
|
|
93a6830fcd | ||
|
|
90400f50ac | ||
|
|
f3c9ab8106 | ||
|
|
67584cedda | ||
|
|
3ecd676bcf | ||
|
|
e4fada298d | ||
|
|
1ef31bc9e7 | ||
|
|
2fa734cd63 | ||
|
|
2a3072b90a | ||
|
|
5798877829 | ||
|
|
150245071f | ||
|
|
36ce66554b | ||
|
|
da1807664a | ||
|
|
e0b4308de4 | ||
|
|
aa1a85b316 | ||
|
|
f33981e1e6 | ||
|
|
1c52bc4e19 | ||
|
|
9f988a8315 | ||
|
|
82c0ac4960 | ||
|
|
ef324aa88b | ||
|
|
39978ff8ea | ||
|
|
3eda6f00c8 | ||
|
|
280fad7472 | ||
|
|
bdddb610c0 | ||
|
|
b2484b900e | ||
|
|
4b976e240e | ||
|
|
152bed7ec4 | ||
|
|
1777a92205 | ||
|
|
398e4efaeb | ||
|
|
5c35efd498 | ||
|
|
dc45f7bb3e | ||
|
|
2bee0b4b53 | ||
|
|
cb4557f1bc | ||
|
|
12b7364998 | ||
|
|
384b6a1150 | ||
|
|
ccc42f34f8 | ||
|
|
32151f7f9f | ||
|
|
035c7f20e8 | ||
|
|
264dc4f0c2 | ||
|
|
8f1a5e0a46 | ||
|
|
0080bcbcba | ||
|
|
3084491b9b | ||
|
|
8c16f9d0fd | ||
|
|
0cdda1713f | ||
|
|
be895375ec | ||
|
|
faa4e98311 | ||
|
|
f64d6a8d4d | ||
|
|
4763e1a70d | ||
|
|
c0b0921973 | ||
|
|
4e75019b9a | ||
|
|
9a26862bce | ||
|
|
82646e8408 | ||
|
|
9f825ed6fb | ||
|
|
da2f93254e | ||
|
|
b0c13b85a9 | ||
|
|
82c006cd59 | ||
|
|
a271795408 | ||
|
|
06f9ae7799 | ||
|
|
bb5b79f2f6 | ||
|
|
10e46522b4 | ||
|
|
663a95f4a2 | ||
|
|
9ed90505b3 | ||
|
|
388a6a060d | ||
|
|
f83be016ba | ||
|
|
6081726314 | ||
|
|
2d860587a4 | ||
|
|
617331f913 | ||
|
|
ddf746d8a4 | ||
|
|
1a73aba1cd | ||
|
|
76334cd5aa | ||
|
|
085fc97334 | ||
|
|
cf7b33da39 | ||
|
|
8db0969d9d | ||
|
|
7b8b43a7d2 | ||
|
|
3a5d96a8ae | ||
|
|
af751599f6 | ||
|
|
df55e181d9 | ||
|
|
a128648bd2 | ||
|
|
324f0f02ef | ||
|
|
e6b9cb29b7 | ||
|
|
89d20c31dc | ||
|
|
155b7650a9 | ||
|
|
39409b6683 | ||
|
|
98dcb31c23 | ||
|
|
1ffe6ea067 |
No files matched your search
+1
-1
@@ -4,4 +4,4 @@
|
||||
[submodule "iris"]
|
||||
path = iris
|
||||
url = git@git.arirex.me:iris-ai/iris.git
|
||||
branch = main
|
||||
branch = app-pin
|
||||
@@ -6,9 +6,11 @@ submodule. The
|
||||
Android app uses a thin Java activity and `android-view`; desktop uses winit.
|
||||
|
||||
`docs/PLAN.md` is the design source of truth. Read it before structural work
|
||||
and update it when a decision changes. Working documents are pruned as work
|
||||
lands: preserve current invariants, measurements, and failed hypotheses, not a
|
||||
chronicle of completed tasks. Do not create a decisions log.
|
||||
and update it when a decision changes. `docs/HANDOFF.md` is where the work in
|
||||
flight stands; read it first in a fresh session and keep it current. Working
|
||||
documents are pruned as work lands: preserve current invariants, measurements,
|
||||
and failed hypotheses, not a chronicle of completed tasks. Do not create a
|
||||
decisions log.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
+466
@@ -0,0 +1,466 @@
|
||||
# Handoff
|
||||
|
||||
Where the work in flight stands. The settled layout design, the vocabulary
|
||||
and the measurement method are in `docs/LAYOUT.md`; what the review of #19
|
||||
found is in `docs/LAYOUT_LOG.md`.
|
||||
|
||||
## A sweep for allocation, and the cost per child, is on #19
|
||||
|
||||
The fourteenth sweep landed at `97fca76`, with the refcount removal below at
|
||||
`429a4f1`. It read the whole branch again, aimed first at what allocates and
|
||||
then at whatever the reading turned up. Nine findings, the largest two both
|
||||
about cost per thing rather than per frame:
|
||||
|
||||
- **A container's draw was quadratic in its children.** Five per-child steps
|
||||
each asked "have I done this one already?" by searching a list; at 1,600
|
||||
children 70% of the redraw was those searches. A draw now takes a `DrawId`
|
||||
and leaves it on every widget it asks about, which answers the question in
|
||||
one read -- one note per widget suffices because the handle a container holds
|
||||
a child by cannot be cloned. `tests/children_cost.rs` is new and is the only
|
||||
rig here that varies width: 3.680 ms to 0.811 ms at 1,600 children, and flat
|
||||
per child at every count. A transcript is exactly this shape.
|
||||
- **A mask's rectangle was resolved once per fragment**, a walk up to 64 links
|
||||
long, although it is the same rectangle for every fragment of an instance.
|
||||
Resolved in the vertex stage now, which takes `masks` and `move_offsets` out
|
||||
of the fragment stage entirely. GPU timestamps over one screenful of clipped
|
||||
rows: 838.4 us to 95.7 us at chain depth 64, 141.2 to 69.4 at depth 8, and
|
||||
unchanged at depth 1. `chain_cost.rs` could not see this -- its instances are
|
||||
two pixels wide so vertex work dominates -- and has a masked fixture now.
|
||||
|
||||
The other six: `TextBuffer::shape` copying its attrs before the check that
|
||||
would not need them, which was one allocation per named-family text per frame
|
||||
and 800 to 0 over a measured hundred frames; `cargo test --release` failing,
|
||||
because a `#[should_panic]` test asserts a `debug_assert` release does not
|
||||
compile; `Fixed::div` and `Div for Fixed`, reached only by their own test,
|
||||
whose deletion also settles the `div`-versus-`ratio` zero-divisor item this
|
||||
file used to list; `Moves::remove` re-uploading an array it cannot have
|
||||
changed; and two comments this branch's own commits falsified -- one saying
|
||||
`widget_trait!` takes no attributes after `76aaf06` taught it to, one saying
|
||||
`Scroll` clips when masking is a capability a caller opts into with `Masked`.
|
||||
|
||||
The ninth is the one Bryan asked for on 2026-09-21, having first been left for
|
||||
the pre-gate pass: `StrongWidget` allocated a `RefCounter(Arc<AtomicU32>)` it
|
||||
could never use, since it deliberately has no `Clone`, so every widget paid a
|
||||
heap allocation and two atomic read-modify-writes for a count that stayed at
|
||||
zero. It is the id, the sender and the type now, and `Drop` sends;
|
||||
`size_of::<StrongWidget>()` is 32 rather than 40. `RefCounter` stays for
|
||||
`TextureHandle`, which does clone, trimmed to what that needs.
|
||||
|
||||
It also left five things, with the numbers. Text allocates about six times per
|
||||
re-broken paragraph per frame, and removing all of them is **0.07%** of
|
||||
instructions (3.7107B to 3.7081B over 500 sweeping resize frames of 40
|
||||
paragraphs), so the count is recorded rather than the machinery built --
|
||||
allocation count and cost are not the same quantity, and this is where they
|
||||
part. Seven dead items older than #19 are listed under the pre-gate review
|
||||
below rather than fixed here.
|
||||
|
||||
`docs/LAYOUT_LOG.md` has all of it. Verified: format, clippy both ways, 190
|
||||
ordinary, 193 diagnostic and 189 release tests, the cold dump byte-identical to
|
||||
`cbccfb6` across all 34,986 boxes, all three seed scans, the three deferred
|
||||
corpora, and `mask_clip.rs` reading the clipped pixels back off the GPU to the
|
||||
same 6,000 in the same bounds before and after the shader change.
|
||||
|
||||
## Built-in bounds replace `MaxSize` on PR #19
|
||||
|
||||
Bryan clarified that the built-in attribute should replace the wrapper.
|
||||
Work is in `/home/bob/repos/iris-deferred`, on `layout/one-ask`, at
|
||||
`cbccfb6` (pushed). Bounds now constrain the offer in `Placing::ask`;
|
||||
declarations are retained as resolved window lengths, so movement does not
|
||||
decide the bound again.
|
||||
`.max_width`/`.max_height` now edit the same widget, like the minimum
|
||||
helpers, and `MaxSize` is removed. Preferred lengths and bounds are
|
||||
independent, so either order of `.width` and `.max_width` works. The app pin
|
||||
is unchanged.
|
||||
|
||||
The review fixed three cases: `window_holds` erasing an ask's bound crossing
|
||||
(seed 104, depth 5), inactive bound edits failing to update the parent's offer
|
||||
dependencies (seed 144, depth 6, reduced further to three widgets), and a
|
||||
fractional intrinsic cap being read against its already-allocated slot.
|
||||
The latter has an absolute 75-in-300 geometry check; warm/cold agreement
|
||||
alone cannot catch it.
|
||||
|
||||
206 ordinary and 210 diagnostic tests pass, with warning-clean Clippy both
|
||||
ways (207 and 211 at `2ac0843`, less the duplicate test the sweep merged).
|
||||
All three layout scans pass (400 depth 5, 1,000 depth 6, 2,000 depth 4), as
|
||||
do 400 depth-5 trees in each of three deferred-request corpora. All 34,986
|
||||
unbounded cold boxes match `4cb6f68`; bounded offers intentionally change.
|
||||
The final unbounded-path optimization preserves every bounded cold box too.
|
||||
|
||||
Five-run release instruction medians show capped rows using 21.77–29.64%
|
||||
less CPU work than the wrapper. Plain/fixed-width retained resizes differ by
|
||||
under 0.05%; forced redraws improve about 0.3%. Text workloads differ by at
|
||||
most 0.13%. Independent preferences and bounds cost 32 extra inline bytes per
|
||||
widget; all three allocation fixtures remain allocation-free after warm-up.
|
||||
`docs/LAYOUT.md` records the contract and measurements. Artifacts are in
|
||||
`/tmp/attribute-bounds/`.
|
||||
|
||||
## A request is held in the arena its nodes are allocated in
|
||||
|
||||
`05e6ced`. `SizeRequest` was a second expression shape beside the one
|
||||
`RequestArena` already had -- the same three operators, the same constant fold
|
||||
written twice, and an `import` that walked a pointer tree rebuilding it as
|
||||
arena nodes. Bryan settled it on 2026-09-20: one node type, an arena, and no
|
||||
refcount, since nothing shares a request and nothing outside widget code holds
|
||||
one. A rule's expression now holds a small arena of the pass's own node type,
|
||||
and `import` grafts those nodes across. A plain length stays inline, so
|
||||
`size_of::<SizeRule>()` is 40 either way. `bounds_cost` `MODE=cap` is -1.34%
|
||||
and `revision_cost` resize -0.79% in release instruction medians.
|
||||
`docs/LAYOUT_LOG.md` has the full entry. Verified with all three seed scans,
|
||||
the three deferred corpora, 206 and 210 tests, and the cold dump byte-identical
|
||||
to `2ac0843` across all 34,986 boxes.
|
||||
|
||||
## A full sweep of #19 is submitted on it
|
||||
|
||||
The thirteenth sweep landed at `cbccfb6`. Unlike the twelve before it, it
|
||||
read the whole branch rather than one commit, and it is the first review of
|
||||
`f48e04e`. Seven findings, the largest being that a length of zero printed as
|
||||
the empty string: `Display for LayoutLen` leaves out each part that is zero,
|
||||
and `f48e04e` had just pointed `Debug` at `Display`, so a request of zero
|
||||
printed as nothing in the only place requests are compared -- and
|
||||
`scenario::describe` printed a `.width(0)` rule as `-`, which is what it
|
||||
prints for a widget with no rule at all. Also: `Fixed::ceil_from_f32` stepped
|
||||
past the top of the grid and wrapped, so the largest measurement came back as
|
||||
the most negative length; `Moves::depth` walked the move chain a second way
|
||||
with its own copy of `CHAIN_LIMIT`; `Harness::set_len` claimed to set a
|
||||
length "the way `.width()` sets one" and dropped any bound beside it; three
|
||||
rigs each spelled "one seed, or a range of them" by hand; `diag::outside`
|
||||
could count a refused reuse and explain it with nothing; and
|
||||
`cases/deferred` sat outside `suite.rs`'s alphabetical list.
|
||||
|
||||
It also re-measured a recorded hole. `Sow::bound` grows bounds in pixels
|
||||
because two depth-5 trees once disagreed warm against cold with fractions in
|
||||
them -- named by seed, and `generated.rs` says in its own comment that seeds
|
||||
stopped naming those trees when the leaves grew images. 600 depth-5 trees
|
||||
over all sixteen cases now agree with every bound a fraction (93.18s). The
|
||||
generator still grows pixels, because `deferred_generated.rs` already varies
|
||||
that dimension and growing fractions here would move every box in the cold
|
||||
dump; the comment says that instead of describing an open defect.
|
||||
|
||||
`docs/LAYOUT_LOG.md` has all seven, with the four things the sweep looked
|
||||
at and left and the one it withdrew. Verified: format, clippy both ways,
|
||||
208 and 212 tests, and the cold dump byte-identical to `f48e04e` across all
|
||||
34,986 boxes.
|
||||
|
||||
## A quality sweep of the request arena is submitted on PR #19
|
||||
|
||||
The twelfth sweep landed at `f48e04e`. It is the first review of `05e6ced` --
|
||||
one node type for a request, an arena per owner, and `Widgets::edit_bound`.
|
||||
Four findings, the largest being that the one path copying a request into
|
||||
another arena was run by nothing: all 206 tests passed with a `panic!` in it,
|
||||
because every expression in the corpora and in the cases compares an
|
||||
expression against a plain length. A fixture with absolute geometry and a
|
||||
fourth arm in the deferred corpus cover it now, and the path was already
|
||||
correct. The other three: `Debug` on a request still printed the derived
|
||||
arena its own new `Display` was written to avoid, a method asked nothing of
|
||||
its receiver, and a closure parameter shadowed the rule it was called on.
|
||||
`docs/LAYOUT_LOG.md` has all four, with the four rules it tripped and left.
|
||||
Verified: format, clippy both ways, 207 and 211 tests, the cold dump
|
||||
byte-identical to `05e6ced` across all 34,986 boxes, and 400 depth-5 trees in
|
||||
each of the three deferred corpora in 200.95s.
|
||||
|
||||
## A quality sweep of the bounds work is submitted on PR #19
|
||||
|
||||
The eleventh sweep landed at `ea1f836`. It is the first review of `2ac0843`
|
||||
-- `SizeRule` from an enum to a preferred length beside an independent bound,
|
||||
the offer constrained in `Placing::ask`, and `MaxSize` removed. Eight
|
||||
findings, the two largest being a hint read that answered "cannot say" above
|
||||
the diagnostics and so counted as no read at all, and five spellings of
|
||||
reading one environment variable across the rigs, which is the class the
|
||||
tenth sweep found one commit earlier -- there is now one `env` in
|
||||
`tests/rig/`, used by all six. `bounds_cost` also verified 128 regions inside
|
||||
its measured loop; the check moved out, and the 0.65% it measured is written
|
||||
beside it. `docs/LAYOUT_LOG.md` has all eight, with the two rules it tripped
|
||||
and left. Verified: format, clippy both ways, 206 and 210 tests, 400 depth-5
|
||||
trees warm against cold in 64.19s, and the cold dump byte-identical to
|
||||
`2ac0843` across all 34,986 boxes.
|
||||
|
||||
## A quality sweep of the deferred system is submitted on PR #19
|
||||
|
||||
The tenth sweep landed at `4cb6f68` in `/home/bob/repos/iris-deferred`. It is
|
||||
the first review of `76aaf06` through `0e838e9` -- the bound rules,
|
||||
the deferred request path and the invalidation fix. Ten findings, the largest
|
||||
two being a bound's held length looked up a second time through a value that
|
||||
could not promise the end it named (`Bound::at` `expect`ed it), and a bound in
|
||||
pixels pinning the rel base it is not read against. `docs/LAYOUT_LOG.md` has
|
||||
them all, with the two rules it tripped and left and two corpus questions for
|
||||
Bryan. Verified: format, clippy both ways, 197 and 201 tests, all three seed
|
||||
scans (69.02s, 174.87s, 330.39s), and the cold dump byte-identical to
|
||||
`0e838e9` across all 34,986 boxes.
|
||||
|
||||
## Performance sweep is submitted on PR #19
|
||||
|
||||
The performance fix is `0e838e9`. It keeps dependencies only for size requests the allocator uses, and borrows
|
||||
`Widgets::size_rules` rather than cloning both axes at every lookup. Used
|
||||
hints retain their reader dependency; adding a cap after layout is checked for
|
||||
both hinted and measured shares. Ordinary text measurement still tracks its
|
||||
own dependencies. The app's Iris pin is untouched.
|
||||
|
||||
Repeated release instruction counts reproduce the reported +5.9% resize cost
|
||||
and find a larger defect: an unchanged paragraph repaint invalidated its span,
|
||||
turning 0.376B instructions into 3.010B in a 40-paragraph, 2,000-frame fixture.
|
||||
The fix is 0.370B. Text resize is now +0.99% against `de1eb7e`, the width sweep
|
||||
+1.23%, and edits/scroll are within 0.3%. `docs/LAYOUT_LOG.md` has the full
|
||||
performance sweep, including deep-tree regressions, RSS, upstream-base
|
||||
comparison, and GPU timestamp measurements. This is not a universal speedup.
|
||||
|
||||
Passed: format, workspace Clippy with and without diagnostics, all 197 ordinary
|
||||
and 201 diagnostic workspace tests, zero steady allocation fixtures, 400
|
||||
depth-5 / 1,000 depth-6 / 2,000 depth-4 trees, 400 depth-5 expression trees,
|
||||
400 relative-bound trees, GPU recording and move-chain rigs, and identical
|
||||
cold geometry for all 34,986 bounded boxes against `8780b40`. Code and comments
|
||||
were reviewed separately; the final test/comment refinements passed their
|
||||
focused checks. Artifacts are under `/tmp/pr19-perf/`.
|
||||
|
||||
## Deferred comparisons are submitted on PR #19
|
||||
|
||||
The deferred-comparison implementation landed at `8780b40`, before the
|
||||
performance correction above.
|
||||
`/home/bob/repos/iris` remains on `layout/bounds`; that branch's `de1eb7e` was
|
||||
fast-forwarded into #19 before this work. The app's Iris pin is untouched.
|
||||
|
||||
The new request path composes min/max/clamp before assigning leftover slots,
|
||||
while ordinary widgets keep returning `Size`. Known requests skip provisional
|
||||
painting; measured leaves can complete a nested expression. The implementation
|
||||
uses a reusable expression arena and retained draw buffers. The allocation rig
|
||||
checks zero allocations after warm-up for unchanged plain and clamped trees.
|
||||
Generalized cross-axis maxima are deferred because hidden main-axis shares
|
||||
must not contribute to them. The current design is in `docs/LAYOUT.md`.
|
||||
|
||||
## The Iris layout repair is submitted
|
||||
|
||||
**Iris PR #19** (`layout/one-ask`) replaces closed #18. Before the deferred
|
||||
comparison work above, its review rounds past `cadfba0` are described in
|
||||
`docs/LAYOUT_LOG.md`:
|
||||
|
||||
- **The repair**, `add6774` and `84dad21` -- collapsed-share placement,
|
||||
retained mask ownership, a redraw-on-reparent defect, and repeated work in
|
||||
the test harness.
|
||||
- **The vocabulary and the container API**, `5642f20` through `58ce74d`.
|
||||
- **Naming**, `55df32a` through `40b89c1`.
|
||||
- **A sweep over the logic those names exposed**, `8d2b7a5` and `6c84b6f`.
|
||||
- **A clarity sweep**, `3da1c71` through `1ebd4d3` -- naming the pairs layout
|
||||
returns, `Span::slot`, a diagnostic that printed the rel base while calling
|
||||
it the box, and `in_parent` matching a place's own cases.
|
||||
- **A quality sweep**, `aea0387` through `69ba915` -- a kept contract judged
|
||||
against the placed box rather than the box asked about, two things nothing
|
||||
read, three reuse rejections the diagnostics could not see, and a fuzz case
|
||||
that ran only in the long scan.
|
||||
- **A sweep over the renderer, the text store and the retained path**,
|
||||
`d8d5122` through `1096c31` -- a contract kept where the new window left
|
||||
it out, a surface configured under its own texture, a counter naming the
|
||||
wrong contract, things nothing reads, and a question asked through a value
|
||||
rather than a reference.
|
||||
- **A sweep over the shader boundary and the position widgets**, `b7b8d09` --
|
||||
two constants the shader and the CPU both count in written twice, and a
|
||||
`Scroll` positioning content the framework positions, which cost a redraw
|
||||
at the default alignment.
|
||||
- **A sweep over the rigs, `Fixed`, and `b7b8d09` itself**, `f8aa0c5` -- the
|
||||
other half of that same `Scroll` test, which could never decide it; a grid
|
||||
conversion and its helper that only their own test called; `Len`
|
||||
arithmetic written a component at a time; a question asked through a value
|
||||
one line from its `&self` sibling; and `run-headless.sh --resize` leaving
|
||||
a replayed gesture scaled against the mode the output used to have.
|
||||
- **A sweep over the tests and the seventh sweep's own fix**, `77ed7a2` --
|
||||
four shrunk fixtures naming one widget under three names and counting
|
||||
each in the list of boxes the case compares, seven copies of a helper
|
||||
sitting at the top of the same file, the GPU rigs' adapter probe written
|
||||
twice, a mask resolved three times, a field nothing reads, a shrinker
|
||||
claim its own assertion does not make, and three stale numbers.
|
||||
- **A sweep over the widget vocabulary and the eighth sweep's own fix**,
|
||||
`c2b8bf8` -- a widget's own size hint overriding a length rule the hint
|
||||
cannot express, marking a widget for redraw having no name at twenty-one
|
||||
call sites, the helper the eighth sweep shared being unable to see the
|
||||
defect it had just fixed, two bare arrays where the framework names the
|
||||
pair, forty-five lines nothing references, and one word for two things.
|
||||
- **A leftover as a minimum, and images in the trees**, `b295c8b` and
|
||||
`2dba90b` -- a share under a parent that divides nothing losing the
|
||||
overflow its pixels asked for, said as the place the parent gives and
|
||||
sharing one comparison with the span; and `Image` grown in the generated
|
||||
trees, which is the only widget here whose hint is a length in pixels.
|
||||
- **One ask, the root's included**, `0d03267` -- the root had a layout path
|
||||
of its own, so a rule that reads the box it is offered reached every widget
|
||||
but that one. `Placing::WINDOW` is the box nobody drew and `Placing::ask`
|
||||
the one place a box is decided; the root's own path is now the bookkeeping
|
||||
a widget with no parent keeps. Bryan asked for this rather than a widget
|
||||
above the root, which would pin the tree to pixels.
|
||||
- **The deferred request system's own sweep**, `4cb6f68` -- described at the
|
||||
top of this file and in full in `docs/LAYOUT_LOG.md`.
|
||||
- **The bounds work's own sweep**, `ea1f836` -- the same, over `2ac0843`.
|
||||
- **One node type for a request**, `05e6ced` -- the expression a rule holds
|
||||
and the expression the pass allocates were two shapes of one thing, with
|
||||
the fold written twice and an `Arc` where nothing shares.
|
||||
- **The request arena's own sweep**, `f48e04e` -- the join that copies one
|
||||
request's nodes into another's, which nothing in the suite ran.
|
||||
- **A full sweep of the branch**, `cbccfb6` -- a length of zero that printed
|
||||
as nothing, a ceiling that stepped off the top of the grid, and a harness
|
||||
setter that dropped the bound beside the length it set.
|
||||
- **A sweep for allocation and the cost per child**, `97fca76` and `429a4f1`
|
||||
-- a container's draw quadratic in its children, a mask's rectangle resolved
|
||||
once per fragment, and the refcount a widget handle can never raise.
|
||||
Described at the top of this file.
|
||||
|
||||
The settled design of the vocabulary rounds is in `docs/LAYOUT.md` under
|
||||
"Three names, and the one argument that says them". Bryan settled the API
|
||||
over 2026-09-17 to 19; it is current, not frozen.
|
||||
|
||||
The core design remains sound. Round-to-nearest is still unchanged.
|
||||
|
||||
The Iris worktrees share Git storage. `ai-app-2/iris` stays on `app-pin` at
|
||||
the app's `32f6ad8` pin until the integration below is ready.
|
||||
|
||||
### The branch layout, and the trap that used to be here
|
||||
|
||||
`main` is PR #19's base and tracks `upstream/main` (`iris/iris`), so the
|
||||
obvious commands are the right ones:
|
||||
|
||||
git merge-base main layout/one-ask # ca2b4b2, the PR's base
|
||||
git diff main...layout/one-ask # exactly what #19 changes
|
||||
|
||||
It was not always so. Until 2026-09-20 `main` tracked `origin/main` -- the
|
||||
*fork's* line, which carries the app's 45 commits, is not an ancestor of
|
||||
upstream's main, and sat four merged pull requests behind it (#10 parley
|
||||
text, #12 pointer routing, #16 draw/size merge, #17 headless rig). Diffing
|
||||
against it showed all four as this branch's work, which is how the parley
|
||||
migration's undo path kept being reported as #19's; the sixth sweep lost
|
||||
half a session to it before Bryan caught it, and the fourth and fifth
|
||||
sweeps deleted `Painter::text_data` partly on the same false reading.
|
||||
|
||||
Bryan asked for the setup fixed rather than documented. What changed:
|
||||
|
||||
- The app's line is now the branch **`app-pin`** (`32f6ad8`), pushed to
|
||||
`origin`. That is the name to use for it; it is no longer called `main`.
|
||||
- `main` points at and tracks `upstream/main`.
|
||||
- `.gitmodules` pins the `iris` submodule to `branch = app-pin`, so
|
||||
`git submodule update --remote` follows the app's line and cannot drag
|
||||
the pin onto upstream.
|
||||
- `git iris-base` and `git iris-diff` are still configured and still
|
||||
correct; they now agree with plain `main`.
|
||||
|
||||
`origin/main` on the fork was force-pushed to match, and `origin/HEAD`
|
||||
follows it, so a fresh clone of the fork with no `upstream` remote gets the
|
||||
right base from the plain commands too -- verified by cloning one. The
|
||||
fork is managed entirely by agents and nobody works from it directly, so
|
||||
rearranging its branches is always safe where it improves the workflow
|
||||
(Bryan, 2026-09-20); that licence does not extend to the `iris/`
|
||||
repositories, which are what pull requests target.
|
||||
|
||||
The submodule's recorded commit is unchanged at `32f6ad8` and stays
|
||||
reachable through `app-pin`, so nothing about the app's pin moved -- only
|
||||
the name it is reachable by.
|
||||
|
||||
### How to check a round
|
||||
|
||||
**Always**, because they cost nothing: format, workspace clippy under
|
||||
`-D warnings` with and without `layout-diagnostics`, the workspace tests, and
|
||||
the **cold dump**. `layout_dump` over 400 depth-5 trees is **34,986** boxes
|
||||
since `76aaf06` grew bounds in the trees, and is unchanged through `f48e04e`.
|
||||
It was 34,571 from `2dba90b`, which grew the images, and 34,488 before those; the fourth through eighth sweeps all
|
||||
repeated 34,492, which is a `wc -l` of the whole run rather than of its box
|
||||
lines, so count the lines that are a box (`grep -cE '^[0-9]+ [0-9]+ '`). It is the only thing that catches two
|
||||
same-typed values being swapped, which is the failure mode of a rename or a
|
||||
move. The repair moved 650 of those boxes, all from the collapsed-share
|
||||
correction. Subsequent review-only changes preserved cold geometry through
|
||||
`4cb6f68`; the offered-bound change in `2ac0843` intentionally changes bounded
|
||||
geometry while preserving all unbounded boxes.
|
||||
|
||||
**Only when the change can alter what layout computes**: the three seed scans
|
||||
-- 400 at depth 5, 1,000 at depth 6, 2,000 at depth 4. They cost about a
|
||||
quarter of an hour and they exist to find logic that is wrong on some tree
|
||||
shape, so a rename has nothing for them to find (Bryan, 2026-09-19). Never
|
||||
start one and then edit the tree: cargo rebuilds mid-flight and exits 1 from
|
||||
a compile error, which reads exactly like a fuzzer failure.
|
||||
|
||||
## What is next, in order
|
||||
|
||||
1. **Bryan's review of #19.** Fixes are themselves unreviewed code: repeat
|
||||
`pre-submit-review` over each round's changes, and apply the gate above to
|
||||
whatever each one touched. The ordinary oracle does not replace absolute
|
||||
geometry and retained-primitive expectations. The seventh sweep is the
|
||||
evidence: reading `b7b8d09`, the sixth sweep's own fix, found that it had
|
||||
removed one dead operand from a test and left the one beside it.
|
||||
The eighth adds a second lesson, about the tests themselves: a fixture a
|
||||
fuzzer shrank is a regression test, so a round that rewrites one has to
|
||||
prove the tree is the same rather than that the case still passes, since
|
||||
a fixture that quietly changed still passes and covers nothing. The ninth
|
||||
adds a third: where a round fixed a class of defect one instance at a
|
||||
time, put the check in the shared helper, which both closes the class and
|
||||
tests the instances that round said were already fine.
|
||||
The twelfth adds a fourth, about coverage rather than about a defect: a
|
||||
branch only a combination of the public API reaches can be reached by
|
||||
nothing and still look covered, since every test around it passes. Put a
|
||||
`panic!` in the arm and run the suite; it costs one build and it answers
|
||||
the question the test names cannot.
|
||||
The thirteenth adds a fifth, about the record rather than the code: a
|
||||
comment describing an open defect by the seed that found it stops being
|
||||
true the moment the generator changes, and `generated.rs` says so in its
|
||||
own comment. Re-run the measurement before repeating what one says.
|
||||
2. **A review of everything written before the review gate existed.**
|
||||
`pre-submit-review` and the rule that nothing is submitted unreviewed
|
||||
arrived on 2026-09-13, well after the Rust port and most of Iris were
|
||||
written, so all of that code went in unreviewed and none of the sweeps
|
||||
above covered more than the layout branch. It wants a pass of its own
|
||||
(Bryan, 2026-09-20). The surface-texture defect in `02048ea` is the
|
||||
argument: nothing about that arm was hard, and it was written wrong
|
||||
anyway, which is what a first reader catches and a later sweep of some
|
||||
other subject does not. Three things it already has waiting, left out of
|
||||
#19 because they are outside its diff: `Align::tuple` and both
|
||||
`partial_align`s have no callers, `Vec2::align`/`partial_align` are
|
||||
`UiVec2`'s with a conversion in front, and `impl_op!` carries four
|
||||
grammars of which `core/src/util/vec2.rs` uses two, one line apart.
|
||||
`Align` now has `Index<Axis>`, so the `if let Some` each
|
||||
`partial_align` writes twice collapses when that sweep reaches them.
|
||||
The fourteenth sweep adds more, all dead: `Size::to_uivec2`, `Size::rel`,
|
||||
`Size::leftover`, `Len::to_uivec2`, `Vec2::with_x`, `Vec2::with_y`,
|
||||
`Vec2::ceil` and `Layers::iter_orderless_mut`.
|
||||
3. **Integrate the app's Iris capabilities before changing its pin.**
|
||||
`32f6ad8` has 45 commits not reachable from the review branch; shared UI
|
||||
ownership, richer masks, Android support, and app-side performance work
|
||||
must survive the integration. What has to survive is those capabilities,
|
||||
not the calls the app makes today: the app is to be largely rewritten
|
||||
against the new API rather than ported call by call, so nothing in Iris
|
||||
is kept alive for the app's sake (Bryan, 2026-09-20).
|
||||
4. **Round-to-nearest**, CPU and shader together as one verified change.
|
||||
Bryan approved it on 2026-09-17 and neither half has landed; the
|
||||
derivation, the form to use and what to re-check are in `docs/LAYOUT.md`
|
||||
under "Rendering the grid (pending)".
|
||||
|
||||
Wanted but not started, recorded in `iris/TODO`: transforms on a move entry,
|
||||
so a whole subtree scales or rotates with one buffer write and no redraw.
|
||||
Compose-style stretch at the end of a scroll area is the use that prompted
|
||||
it. A move entry only translates today, and composing through one scales the
|
||||
`rel` part while `px` passes through untouched, so fixed-size content and
|
||||
glyphs do not follow a shortened entry.
|
||||
|
||||
## Smaller layout items, none urgent
|
||||
|
||||
- Nested spans pass `leftover` weight up, so three leftover children in one
|
||||
inner span beside one in another get three quarters to one quarter. No
|
||||
other layout system does that; confirm it is wanted.
|
||||
- A span can overflow itself without bound, so boxes of negative length reach
|
||||
children and nothing states what a widget may assume about one.
|
||||
- A `leftover` under a parent that does not divide is a minimum size --
|
||||
`max(box, px + rel*box)` (Bryan, 2026-09-20), which is a `SizeRule::Min` of
|
||||
`rel(1.0)` and shares `Painter::longer_than` with the span. Done in
|
||||
`b295c8b`, and in `0d03267` for the root as well, which used to read it the
|
||||
old way.
|
||||
- `docs/LAYOUT.md` §4, §5 and the density section name `Painter::place`,
|
||||
`Painter::region()`, `SetSize`, `desired_width`, `apply_rest`, `Len::dp`,
|
||||
`Aligned`, which no longer exist; `MaxSize` now exists with the bounds API.
|
||||
Do not restore `OnResize::Translate` or `OrthoSize`.
|
||||
- **`layout/bounds` (`de1eb7e`) is included in #19** for the deferred
|
||||
comparison implementation. A rule holds what a widget answers
|
||||
(`SizeRule::{Min, Max, Clamp}`); a widget holds the box (`MaxSize`, which
|
||||
`.max_width`/`.max_height` build). Bryan settled the split on 2026-09-20
|
||||
after four readings were measured; the reasoning is in `docs/LAYOUT_LOG.md`.
|
||||
Bryan superseded that split with the built-in bounds work above. The
|
||||
deferred-request work fixes stale relative-bound reuse by tracking the
|
||||
input base and comparing resolved bounds, with a reduced natural-size-hint
|
||||
regression and 400 depth-5 relative-bound trees. Intrinsic bounds remain
|
||||
`Len`s; comparisons involving leftover use `SizeRequest`. Ordinary generated
|
||||
trees retain pixel bounds; `deferred_generated` supplies relative bounds and
|
||||
expressions in separate corpora.
|
||||
- `LazySpan`.
|
||||
- `Scroll` taking a direction rather than one axis.
|
||||
|
||||
Other product work is in `docs/PLAN.md` and the focused documents it links.
|
||||
Do not mix it into the Iris layout branch.
|
||||
@@ -1,818 +0,0 @@
|
||||
# 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.
|
||||
|
||||
### Settling a dirty set, and why the order is not free to change
|
||||
|
||||
**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.
|
||||
|
||||
### 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, 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}`.
|
||||
+630
-99
@@ -4,10 +4,139 @@ A widget draws once and records its size on the `Painter`. Reading a child
|
||||
`DrawResult::size()` records a retained size dependency; drawing the child
|
||||
without reading that result does not make the parent's size depend on it.
|
||||
|
||||
§1 and §2 have landed in Iris (#16 and #18) and the notes below have been
|
||||
brought to what shipped rather than what was proposed; §3 to §6 describe the
|
||||
same design as it stands. `docs/IRIS_EXTRACTION_HANDOFF.md` has the invariants
|
||||
the code now rests on and what is still to do.
|
||||
§1 landed in Iris as #16. §2 and §3 were #18, which is closed: Iris PR #19
|
||||
carries those commits whole plus the retained-layout repair built on them. §4 to §6 and the density section
|
||||
retain the rationale of the design but still name types that have since been
|
||||
replaced; they are not an API reference. `docs/HANDOFF.md` is where the work
|
||||
in flight stands. The sections from "Three names, and the one argument that
|
||||
says them" onwards are the settled design, the findings that outlived the
|
||||
working log, and the measurement method.
|
||||
|
||||
## Bounds on the offered box
|
||||
|
||||
`SizeRule` carries an optional preferred request and independent min/max bounds.
|
||||
The bounds constrain both the box offered to a widget and its reported size.
|
||||
`.min_width`, `.max_width` and their height variants edit that widget's rules
|
||||
and preserve its identity and type; `MaxSize` is removed. Setting `.width` or `.height` preserves the bounds; setting a bound
|
||||
preserves the preferred request and the other bound, in either call order.
|
||||
`Widgets::set_len` makes the same edit at runtime; `set_size_rule` replaces
|
||||
the complete axis. This replaces the rule/widget split recorded in
|
||||
`LAYOUT_LOG.md` (Bryan, 2026-09-20).
|
||||
|
||||
The shared ask resolves declarations and bounds against the incoming rel
|
||||
base, compares the offer, and retains the chosen box length in `Declared`.
|
||||
These are window lengths. Placement aligns a retained length in its destination
|
||||
without resolving it again or comparing the destination against the bound.
|
||||
The comparison's window range and incoming-region dependency invalidate the
|
||||
ask when its inputs change. Ask constraints are combined after the draw, so a
|
||||
widget widening its own `window_holds` cannot erase a bound's crossing.
|
||||
Fractional rules also track their incoming base. Allocated slots use the
|
||||
allocator's base for bounds; applying a 25% cap to its own 25% slot would
|
||||
shrink it twice. Changing an inactive bound also reaches the parent, because
|
||||
it changes which offers the parent can safely reuse.
|
||||
A cap constrains wrapping and scroll viewports, but short intrinsic content
|
||||
can still report less than the cap. Shares remain deferred requests for the
|
||||
parent to allocate; the offered box does not replace their reported weight.
|
||||
|
||||
Measured against `4cb6f68`, medians of five release-process instruction counts:
|
||||
|
||||
| Fixture | Before | After | Change |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 128 capped rows, 2,000 resizes | 7.387B | 5.779B | -21.77% |
|
||||
| Same, every widget redrawn | 13.864B | 9.755B | -29.64% |
|
||||
| 128 plain rows, every widget redrawn | 10.041B | 10.010B | -0.31% |
|
||||
| 128 fixed-width rows, every widget redrawn | 10.085B | 10.056B | -0.29% |
|
||||
| 40 text rows, 1,000 resizes | 9.460B | 9.472B | +0.13% |
|
||||
|
||||
Plain and fixed-width retained resizes differ by under 0.05%; text repaint,
|
||||
edit and scroll differ by -0.05%, +0.07% and +0.06%. `tests/bounds_cost.rs`
|
||||
asserts the same geometry for the old builders and new attributes, with 513
|
||||
active widgets before and 385 after in the capped fixture. The text runs use
|
||||
`tests/revision_cost.rs`. These include cold setup and do not measure GPU or
|
||||
phone frame times. The unbounded cold dump remains identical at 34,986 boxes;
|
||||
bounded geometry changes intentionally because the offer is now constrained.
|
||||
|
||||
The storage tradeoff is explicit: a `SizeRule` is now 40 bytes rather than 24,
|
||||
so independent preferences and bounds add 32 inline bytes per widget across
|
||||
both axes. Plain, expression-bounded and attribute-bounded fixtures each
|
||||
perform zero allocations over 100 forced-redraw resize frames after warm-up.
|
||||
Bounds introduce no heap allocation; an expression also loses
|
||||
its former extra outer `Arc`. Artifacts are in `/tmp/attribute-bounds/`.
|
||||
|
||||
## Deferred size requests on PR #19
|
||||
|
||||
`Widget::draw` still returns the small, copyable `Size` of two `LayoutLen`s.
|
||||
An optional `size_request` method describes an axis without drawing it. Its
|
||||
context can query children and compose sums, minima and maxima; `None` means
|
||||
that drawing in a concrete offer is necessary. Existing leaf widgets need no
|
||||
new method: their `size_hint` supplies a plain request when available.
|
||||
|
||||
Declarations can use expressions, for example
|
||||
`rect(color).width(leftover(1).clamp(40, 120))`. In a 300-pixel row beside an
|
||||
uncapped equal share, that child takes 120 and its sibling takes 180. At 100
|
||||
pixels they take 50 each. Comparisons can have shares on both sides, such as
|
||||
`(px(30) + leftover(1)).min(leftover(2))`. Weights must be nonnegative.
|
||||
|
||||
A span discovers requests, measures any unknown content, solves the shared
|
||||
allocation, then places children. Known requests avoid provisional painting.
|
||||
After measuring a container, discovery can use its children's measured answers
|
||||
to finish an expression that was previously incomplete. Thus a measured leaf
|
||||
inside a nested capped row does not force the row's request to become a pixel
|
||||
constant. A custom container must implement request composition to propagate
|
||||
such expressions through itself.
|
||||
|
||||
Plain sums retain the existing symbolic allocation path. Comparisons with a
|
||||
known ordering (pixels with equal fractional/share coefficients, or shares
|
||||
with equal fixed terms) fold to ordinary lengths. Remaining expressions are
|
||||
monotone piecewise linear functions of one share unit. The allocator advances
|
||||
through exact rational crossings until the sum fills the offered room. Caps
|
||||
return room to other shares; floors may overflow; all caps being reached may
|
||||
leave unused room. Prefix rounding gives adjacent slots the same edge.
|
||||
|
||||
Persistent declarations share immutable expression nodes. Temporary composition
|
||||
uses a reusable arena, and each retained drawing keeps its vector capacities.
|
||||
Temporary request handles are valid only within that layout pass. An explicit
|
||||
allocated placement distinguishes a solved slot from a box that merely fills
|
||||
its parent's region, so declarations are not evaluated a second time against
|
||||
the slot they already chose. Request dependencies invalidate the allocator when
|
||||
a descendant's rules or contents change. A solved declaration retains its
|
||||
pixel length when moved, including by scrolling; it must not become a fresh
|
||||
fraction of the destination box. Relative intrinsic bounds record their input
|
||||
base separately from the widget's resulting box, and retained answers compare
|
||||
resolved bounds before reuse.
|
||||
|
||||
Two measured cases remain intentional. Intrinsic fixed content is drawn in the
|
||||
remaining offer and then moved; drawing it in its reported size would change
|
||||
wrapping and overflow. When no leftover room exists, discovered intrinsic
|
||||
shares use that same measured path. Cross-axis span maxima also remain measured:
|
||||
a main-axis share can be hidden, so the maximum of every child's request is
|
||||
not necessarily the maximum of the children that actually draw. Generalizing
|
||||
that would require carrying visibility through the expression system too.
|
||||
|
||||
The allocation rig forces every widget to redraw across 100 resize frames:
|
||||
both the plain and clamped eight-row fixtures allocate zero times after warm-up.
|
||||
This is a measured property of those stable visible trees, not a guarantee for
|
||||
arbitrary widgets or visibility changes. An eight-level known nested span paints
|
||||
its leaf once. Discovery retains dependencies only for requests it actually
|
||||
uses; discarded discovery leaves dependency tracking to the measured draw.
|
||||
Used hints still register request readers so a newly added cap reallocates the
|
||||
parent even when the child's reported share is unchanged.
|
||||
|
||||
The performance sweep of 2026-09-20 found and removed an eightfold increase in
|
||||
the unchanged-paragraph repaint fixture caused by discarded discovery
|
||||
invalidating its span. The 40-row, 1,000-frame text resize rig now costs 9.432B
|
||||
retired instructions against 9.340B at `de1eb7e` (+0.99%), down from 9.890B
|
||||
at `8780b40` (+5.90%). Text draw, line-break and glyph-placement counts agree;
|
||||
the remaining cost is layout bookkeeping. The width sweep is +1.23%, and
|
||||
paragraph edits and scrolling are within 0.3% in the measured fixtures.
|
||||
These are medians of nine process totals, not GPU or phone frame times.
|
||||
Deep generated trees still have workload-dependent wins and regressions;
|
||||
`docs/LAYOUT_LOG.md` records the matrix, storage costs and measurement limits.
|
||||
The 400-tree unbounded cold dump preserves all 34,986 boxes against `de1eb7e`;
|
||||
the performance correction also preserves all 34,986 bounded boxes against
|
||||
`8780b40`.
|
||||
|
||||
The app's submodule pin is unchanged. This design lives on Iris PR #19.
|
||||
|
||||
## Design
|
||||
|
||||
@@ -36,11 +165,12 @@ pub trait Widget: Any {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size;
|
||||
|
||||
fn size_hint(&self, axis: Axis) -> Option<Len> { None }
|
||||
|
||||
fn on_resize(&self, axis: Axis) -> OnResize { OnResize::Redraw }
|
||||
}
|
||||
```
|
||||
|
||||
Two methods, not three: `on_resize` was proposed here and shipped, and §3
|
||||
below replaced it with the `Holds` interval a widget declares while drawing.
|
||||
|
||||
A widget returns what it used of the box it was given. A child
|
||||
draw returns a `DrawResult` that keeps the painter borrowed; calling `.size()`
|
||||
on that result reads the child's retained size and records that the current
|
||||
@@ -48,15 +178,14 @@ widget depends on it. Dropping the result without reading it draws the child
|
||||
without making the parent's own size depend on the child's.
|
||||
|
||||
No `available` parameter: `Painter` already carries the region the parent
|
||||
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already
|
||||
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface
|
||||
size (`output_size()`, `:152`). Passing it again would be the same value
|
||||
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`)
|
||||
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
|
||||
handed down (`Painter::region()`) and already exposes the pixel-resolved form
|
||||
(`px_size()`) and the output surface size (`output_size()`). Passing it again
|
||||
would be the same value under a second name. `desired_width`/`desired_height`
|
||||
and `WidgetAxisFns::desired_len` are deleted outright — not
|
||||
deprecated, not kept as a fallback — because a widget that implements both
|
||||
`draw` and `desired_*` for the same thing is exactly the "two names for one
|
||||
concept" the code rules call out, and it is what today's `Span::desired_ortho`
|
||||
(`iris/src/widget/position/span.rs:98-152`) already complains about in its
|
||||
(as it was then) already complains about in its
|
||||
own comment: "this literally copies draw so that the lengths are correctly
|
||||
set in the context, which makes this slow and not cool." Folding sizing into
|
||||
`draw` deletes that duplicate simulation, not just moves it.
|
||||
@@ -67,97 +196,89 @@ lying hint fails a debug assertion when the widget is drawn.
|
||||
|
||||
### 2. O(1) subtree movement
|
||||
|
||||
Every active widget owns a slot in `UiData::move_offsets`. A slot stores an
|
||||
absolute-pixel delta and its parent slot; each primitive instance stores the
|
||||
slot of the widget that drew it. The vertex shader walks this bounded chain
|
||||
and adds the accumulated translation. Moving a subtree therefore writes one
|
||||
slot instead of rewriting every descendant primitive.
|
||||
A widget opts into one independently movable region with `.region_node()`, or
|
||||
`Widgets::set_region_node` at runtime; `.scrollable()` sets it once as its
|
||||
convenient default. A node holds a whole **box** -- a `UiRegion` in its parent
|
||||
node's coordinates, `UiRegion::FULL` being the identity -- and each primitive
|
||||
instance names the node it was drawn under. Moving a subtree through a node
|
||||
writes one entry. A widget without the property shares the nearest ancestor's
|
||||
node, and moving it remaps its retained primitive, mask and active regions
|
||||
instead, stopping at any descendant node after rewriting that one entry.
|
||||
|
||||
The parent chain is required for independently movable nested subtrees, such
|
||||
as a swipeable row inside a scrolling list. A flat offset table would require
|
||||
rewriting the row whenever an ancestor moved and would restore the very
|
||||
O(subtree) work this design removes. Chain depth is bounded in both Rust and
|
||||
WGSL.
|
||||
A box rather than a translation, because a pixel-space offset would scale a
|
||||
child that has to keep its pixel length; the fraction and the offset in a
|
||||
`UiScalar` are what tell the two apart. The parent chain is what makes nested
|
||||
movable subtrees work -- a swipeable row inside a scrolling list -- and a flat
|
||||
table would rewrite the row whenever an ancestor moved, which is the
|
||||
`O(subtree)` work this removes. `CHAIN_LIMIT` bounds the walk at 64 in both
|
||||
Rust (`core/src/ui/mod.rs`) and WGSL, so a malformed cycle resolves the same
|
||||
way on each side.
|
||||
|
||||
`Painter::place` draws a child whose box its parent decides and may decide
|
||||
again, and gives that child a slot of its own; `widget` and `widget_within` do
|
||||
not, and share the nearest ancestor's. A slot carries a whole **box**, not a
|
||||
translation: a pixel-space scale and offset would scale a child that has to
|
||||
keep its pixel length, and the `rel`/`abs` pair is what distinguishes the two.
|
||||
(That slots carry translation only was an agent's choice on 2026-09-04, never
|
||||
asked for, and #18 replaced it.)
|
||||
`Moves::resolve` performs the same walk on the CPU for hit testing,
|
||||
accessibility and window-coordinate queries, and the shader's `resolve_move`
|
||||
mirrors it. Coordinates cross as whole counts of `1/1024` px and `1/2^24` of
|
||||
a box, which the shader decodes from constants the Rust side prepends: the
|
||||
grid is stated once. Masks carry their own node and resolve it independently,
|
||||
so a stationary viewport clips content that moves inside it.
|
||||
|
||||
`UiRenderState::resolved_region` performs the same chain walk on the CPU for
|
||||
hit-testing, accessibility, and public window-coordinate queries. Masks store
|
||||
the move slot of their owning widget and resolve it independently in the
|
||||
fragment shader, so a stationary viewport can clip moving content.
|
||||
|
||||
Slots follow `ActiveData`'s lifecycle. Removing a widget recursively retires
|
||||
its slot only after descendants are gone, and a reused arena slot is reset
|
||||
before new primitives can reference it. `Primitives::set_instance` also
|
||||
cancels a dirty mark when provisional layout restores the original bytes, so
|
||||
CPU-only measurement positions are never uploaded.
|
||||
Nodes follow `ActiveData`'s lifecycle. Removing one retires its entry only
|
||||
after every descendant has migrated, since reusing the index sooner would
|
||||
make an old parent look current. Changing the property redraws the subtree
|
||||
once, to rebuild the coordinate boundary; it belongs to widget identity,
|
||||
which is safe because a widget has one parent.
|
||||
|
||||
### 3. Resize scope
|
||||
|
||||
A resize is "the region a widget's parent offers it changes such that the
|
||||
widget's draw might produce different output" — as opposed to a move, which
|
||||
by construction cannot (§2 is scoped to pure translation). Two independent
|
||||
narrowings apply, and both are real, measured properties of the code as it
|
||||
stands rather than new machinery:
|
||||
widget's draw might produce different output" -- as opposed to a move, which
|
||||
by construction cannot. Two independent narrowings apply, and both are
|
||||
measured properties of the code rather than new machinery:
|
||||
|
||||
**(a) A window resize does not, by itself, require touching most widgets.**
|
||||
`shader.wgsl` recomputes every primitive's pixel position from `window.dim`
|
||||
and the primitive's stored `rel`/`abs` pair every frame, already, on the GPU.
|
||||
A widget laid out purely in `rel`/`abs` terms is therefore already correct
|
||||
after a resize with zero CPU work. Calls to `Painter::px_size` and
|
||||
`Painter::output_size` mark both concrete-pixel axes; `px_len(axis)` and
|
||||
`output_len(axis)` mark only the axis actually read. Only widgets whose read
|
||||
axes changed by more than 0.05 physical pixels become dirty. The comparison
|
||||
is against each widget's last actual draw, so smaller changes accumulate
|
||||
rather than disappearing event by event.
|
||||
The shader recomputes every primitive's position from `window.dim` and the
|
||||
primitive's stored fraction and offset every frame, already, on the GPU. A
|
||||
widget laid out purely in those terms is therefore correct after a resize
|
||||
with no CPU work at all.
|
||||
|
||||
All pixel-dependent leaves are marked before layout begins, along with every
|
||||
chain of parents that read their sizes. Resize then settles the shallowest
|
||||
shared readers first, under the new output, so overlapping dependency paths
|
||||
are drawn once. Ordinary content changes use the opposite order: deepest
|
||||
dirty widgets first, with a changed returned size propagated one reader edge
|
||||
at a time. Re-reporting the current output size is a no-op.
|
||||
What decides the rest is `Holds`, one interval of box lengths per axis:
|
||||
*give this widget any box in here and it draws the same thing and reports the
|
||||
same size*. A widget that never reads its box in pixels holds for every
|
||||
length. Reading `Painter::px_len(axis)` or `px_size()` narrows the interval
|
||||
to the length read, and `Painter::holds` is how a widget widens it again by
|
||||
saying what its drawing actually depends on -- a greedy line break holds from
|
||||
its longest line up to the width it was made at. A parent holds for whatever
|
||||
keeps every child it asked about or drew inside its own range, each child's
|
||||
interval translated into lengths of the parent's box.
|
||||
|
||||
**(b) A widget's `available` (its parent's offered region) can change
|
||||
without the widget's *content* changing — this is what `Widget::on_resize`
|
||||
answers, per axis.** When a container's own layout shifts (a sibling grew or
|
||||
shrank, changing this widget's offered box), a widget that says `Scale` on the
|
||||
axes that changed is not redrawn: everything it drew is a fraction of its own
|
||||
slot's box, so writing that one box moves and stretches all of it. `Span`,
|
||||
`Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` say `Scale`;
|
||||
`Scroll` and `MaxSize` read their box in pixels and cannot. `Redraw`, the
|
||||
default, is correct always and free never. `Translate` — an unchanged drawing
|
||||
placed somewhere else in a bigger box — is reserved: nothing reads it until a
|
||||
widget can say where in that box its drawing belongs, which is the alignment
|
||||
work.
|
||||
This replaced `Widget::on_resize` and its `Scale`/`Redraw`/`Translate`
|
||||
answers, which said the same thing per widget type and could not say *how
|
||||
far*. There is no per-widget resize mode now: a widget that reads nothing is
|
||||
never redrawn for a resize, one that reads its width is redrawn when its
|
||||
width leaves the interval it declared, and the interval is the whole of the
|
||||
statement. Do not restore `Translate`; a retained subtree that only moves is
|
||||
remapped through the box chain of §2, exactly.
|
||||
|
||||
**Size invalidation travels upward before drawing; drawing itself travels only
|
||||
downward.** Every active widget retains the direct children whose size it read
|
||||
through `DrawResult::size()` or `Painter::known_len`. `redraw_updates` takes
|
||||
one id from the dirty set, follows only those dependency edges upward and marks
|
||||
that path dirty, then redraws its highest already-dirty ancestor. Drawing that
|
||||
ancestor consumes the marks of every dirty descendant it reaches; the loop
|
||||
then takes whatever remains. Drawing never synchronously invalidates or invokes
|
||||
a parent, so there is no layout recursion and no provisional child draw on a
|
||||
different layer.
|
||||
Lengths are whole counts of `1/1024` px, so "the box changed" is equality
|
||||
rather than a tolerance: a change too small to reach the next step is not a
|
||||
change, and one that reaches it is, however little of a pixel it is worth.
|
||||
|
||||
An exact `size_hint` stops propagation when both axes still equal the retained
|
||||
size. Otherwise propagation is deliberately conservative: the child may have
|
||||
changed size, and only its dependent ancestors can assign the final boxes.
|
||||
Unchanged descendants still take `draw_inner`'s retained skip-or-move path.
|
||||
An active widget also retains which offered-box and output axes flowed into
|
||||
the size it reported, directly or through a child size it read. A container
|
||||
may use that answer for the same prospective box when every observed input is
|
||||
still within 0.05 physical pixels; content dirtiness anywhere in its size
|
||||
dependency subtree rejects the answer. This is a generic constraint rule, not
|
||||
a text exception. Wrapped text is merely the common example: it reads width,
|
||||
so changing only height leaves its answer valid.
|
||||
**(b) Size invalidation travels upward before drawing; drawing itself travels
|
||||
only downward.** Every active widget retains the direct children whose size it
|
||||
read through `DrawResult::size()` or `Painter::known_len`. `redraw_updates`
|
||||
takes one id from the dirty set, follows only those dependency edges upward
|
||||
and marks that path dirty, then redraws its highest already-dirty ancestor.
|
||||
Drawing that ancestor consumes the marks of every dirty descendant it
|
||||
reaches; the loop then takes whatever remains. Drawing never synchronously
|
||||
invalidates or invokes a parent, so there is no layout recursion.
|
||||
|
||||
Dirty widgets settle deepest-first. `dirty_size_under` has been deleted;
|
||||
settling consumes descendant marks bottom-up, so no clean retained answer can
|
||||
hide an unsettled size dependency. An exact `size_hint` stops propagation when
|
||||
both axes still equal the retained size; otherwise propagation is deliberately
|
||||
conservative, since only a dependent ancestor can assign the final boxes.
|
||||
This is a generic constraint rule, not a text exception. Wrapped text is
|
||||
merely the common example: it reads width, so changing only height leaves its
|
||||
answer valid.
|
||||
|
||||
### 4. Wrapped text, and "needs child height before choosing width"
|
||||
|
||||
@@ -307,6 +428,11 @@ physical pixels, unchanged.
|
||||
|
||||
## Masks
|
||||
|
||||
This section and "UI ownership and rel base access" describe the app's pinned
|
||||
Iris implementation. The upstream #19 review branch still has single
|
||||
rectangular masks and separate `UiData`/`UiRenderState`; adopting it requires
|
||||
integrating the app-side capabilities as well as migrating layout calls.
|
||||
|
||||
A `Mask` references a rectangle primitive and its parent mask. Nested masks
|
||||
multiply coverage. Plain `.masked()` creates an undrawn rectangle at the
|
||||
widget's region; `.masked_by(shape)` draws the shape behind the content and
|
||||
@@ -319,14 +445,419 @@ fragment stage cannot make. Rendering and hit-testing both traverse the full
|
||||
mask chain and use the same rounded-rectangle coverage; `iris/tests/mask_sdf.rs`
|
||||
checks the WGSL implementation against the CPU SDF.
|
||||
|
||||
## Offered boxes
|
||||
## Three names, and the one argument that says them
|
||||
|
||||
`Pad` must work in every container: it offers an inset region to its child and
|
||||
reports the child's used size plus padding. In a generous parent it behaves as
|
||||
an inset; in a tight parent it grows the result outward.
|
||||
Settled with Bryan on 2026-09-17 and 2026-09-19. Layout has exactly three
|
||||
things, and only two of them are boxes:
|
||||
|
||||
When a widget does not fit its offered box, it is redrawn at the box implied by
|
||||
its reported size in the same frame. Deferring would leave ordinary
|
||||
`.background(rect(..))` surfaces one frame behind their content. The settling
|
||||
draw occurs only when the widget's own size changes. Widgets whose size varies
|
||||
with every offered box are therefore unsuitable as `LazySpan` rows.
|
||||
- **region** — the box a parent asks a widget in. It draws there, and a text
|
||||
wraps at it. `ActiveData::region`, `Painter::region`.
|
||||
- **placement** — the box its drawing ends up in: a box the size it reported,
|
||||
positioned inside the region by its own alignment, and equal to the region
|
||||
wherever it fills one. It is computed after `draw` returns, is not on
|
||||
`Painter`, and no widget mentions it. `ActiveData::placement` is what
|
||||
`window_region` resolves for hit testing.
|
||||
- **rel_base** — not a box. The length a fraction the widget declares or
|
||||
reports is a fraction of, per axis, as a length of the window. It is the
|
||||
one value that never changes coordinate space: a region node re-expresses
|
||||
the region locally and takes the rel base untouched.
|
||||
|
||||
`frame` and `extent` are retired and must not come back in this sense; the
|
||||
word "frame" for one rendered frame is unaffected.
|
||||
|
||||
A container says all three in one argument. `Painter::widget_at(id, place)`
|
||||
and `place_at(id, place)` take `impl Into<PlaceDesc>`, so a wrapper that only
|
||||
hands over a box passes a `UiRegion` and says nothing else — which is all
|
||||
`Pad`, `Offset` and `Painter::widget` do.
|
||||
|
||||
`PlaceDescAxis` is one axis. It is built by chaining off the value that says
|
||||
it, never by a constructor naming the type, because a constructor makes the
|
||||
reader go back to the start of the line. The `_desc` suffix is what says which
|
||||
type comes out. The three are named after the operations the geometry already
|
||||
had, because two of them take the same span and differ only in how it is
|
||||
applied:
|
||||
|
||||
- `UiSpan::within_desc()` — `UiSpan::within`: composed into the caller's box, so it
|
||||
moves and scales with it. What an inset speaks.
|
||||
- `UiSpan::shifted_desc()` — `UiSpan::shift`: window lengths from where the caller's
|
||||
box starts. What a container dividing room speaks, and what makes a moved
|
||||
box re-place every child by addition, exactly. Not directional: `Dir::Neg`
|
||||
is handled by the span before the numbers get here.
|
||||
- `Len::as_desc()` — the body of `placement` with the length given from above
|
||||
rather than reported. What a stack's sizing child decides for the rest.
|
||||
|
||||
What is optional is a builder, so a caller writes only what it decided:
|
||||
`.fills()` says the region is the placement, and `.rel_base(len)` names the
|
||||
child's rel base outright. **The rel base a caller does not name follows the
|
||||
constructor** — `within_desc` narrows it the way the box is narrowed,
|
||||
`shifted_desc` passes it through, `as_desc` is it. That rule is what makes the common case
|
||||
right by default; stating it by hand was the one thing a container could get
|
||||
wrong with nothing failing.
|
||||
|
||||
`PlaceDesc` is the pair, with `x` and `y` fields and the `Index<Axis>` and
|
||||
`from_axis` of every other pair here, so the joint work — resolving a
|
||||
region, reading the fill flags — is written once rather than per axis.
|
||||
`PlaceDescAxis::on_axis(axis)` goes the other way, lifting one axis into a pair
|
||||
with the whole box across it.
|
||||
|
||||
**Every pair here is a struct of two per-axis values, read with `[axis]`.**
|
||||
`impl_axis_index!` gives it `Index<Axis>`/`IndexMut<Axis>`; there are no
|
||||
`axis`/`axis_mut` methods and no bare `[T; 2]`. A pair kept as arrays of its
|
||||
fields instead — which `LayoutHolds` was, until `AxisHolds` — cannot write
|
||||
any of its own operations once.
|
||||
|
||||
## Rel bases, decided boxes and padding
|
||||
|
||||
Containers that only divide room are transparent to fractions. A child rel base
|
||||
is narrowed by a length its parent decided: a declared `px` or `rel` length,
|
||||
or the resolved slot of a `leftover` child. A box a widget reports for itself
|
||||
does not narrow its descendants' rel base.
|
||||
|
||||
`Pad` is an inset (Bryan, 2026-09-18): it subtracts the padding from both
|
||||
the child's rel base and the child's box, and reports the child's size plus the
|
||||
padding. A `rel(1.0)` child inside padding fills the pad without overflowing
|
||||
it; a wrapping text inside padding wraps at the box the pad was given less
|
||||
the padding. A span's rel base never subtracts siblings, so a padded fixed
|
||||
child measured after a 24 px sibling in a 900 px row wraps at 844 while a
|
||||
`rel(1.0)` inside it is 868. There is no outset kind and no mixed kind.
|
||||
|
||||
A widget draws once, in the box it is asked in; its answer is placed inside
|
||||
that box by re-expressing the drawing, and nothing is drawn again in a box an
|
||||
answer chose. `Holds` is a contract about the ask box alone, read only to
|
||||
decide whether a re-ask can be skipped. A container that puts an answer
|
||||
somewhere other than where it asked says so with `Painter::place_at`, which
|
||||
never runs the body.
|
||||
|
||||
The answer box is never a question, so no contract can be demanded of a
|
||||
widget there: a `Text` asked at 45 px whose longest word is 89.5 px cannot
|
||||
promise its drawing holds for the box its own answer chose. An earlier plan
|
||||
asserted exactly that and was unsatisfiable.
|
||||
|
||||
A container that can learn a child's length without drawing it does.
|
||||
`Painter::size_hint` answers from the child's rule, or from
|
||||
`Widget::size_hint` where it implements one, resolved against the asking
|
||||
widget's rel base; `Scroll`, `Masked` and a `Stack` without a sizing child hint
|
||||
`LEFTOVER`, since each always reports the whole of its box. `Span` takes each
|
||||
child's length from its hint where there is one and asks that child exactly
|
||||
once, in the slot it decided; a child with no hint is drawn in the room left
|
||||
from the cursor, because a text has to wrap at the width actually there, and
|
||||
its drawing is moved to its slot with `place_at`. Reading a hint records a
|
||||
size dependency and pins the rel base where the hint declared a fraction, the
|
||||
same pin a rule that is a fraction takes.
|
||||
|
||||
**What that leaves, and the design that would remove it.** A widget that
|
||||
reports a share its children gave it -- a span whose children report
|
||||
`leftover`, a stack sized by such a child, a wrapper round either -- can have
|
||||
no hint, so its parent asks it in the room and again in its slot, and both
|
||||
asks draw, since the room drawing divided the room. Under a resize that
|
||||
multiplies down the tree: at seed 1, depth 8, the hottest widget in the
|
||||
resize frame is a `Span` drawn 8 times. Removing it means two answers on one
|
||||
record -- a room answer from an explicit measuring ask (`Painter::measure`,
|
||||
so which ask is the measurement is stated rather than inferred) and a slot
|
||||
answer from the placing ask, with the drawing belonging to the placing one --
|
||||
plus a local redraw that re-asks both questions and marks the parent if
|
||||
either answer moved, which also retires `re_asked`'s deferral. Do it only
|
||||
once an app screen shows the cost, and measure that screen first.
|
||||
|
||||
**There is one coordinate unit, the window** (`1512d84`, `23523ee`). Every box in the
|
||||
tree is a region in window units; a widget's rel base is a *length* in the same
|
||||
units and is only what fractions resolve against, so a widget's box need not
|
||||
be its rel base and padding can take from both. A region node's entry is a
|
||||
translation -- a `rel 1` region anchored where its box starts -- so nothing
|
||||
composes a rel base back up a chain.
|
||||
|
||||
A rel base decided for a child is therefore a length of the window too: a row's
|
||||
slot, padding's rel base less its pixels, or the box a stack's sizing child
|
||||
decided (which arrives as `PlaceDescAxis::sized`). It is never a fraction of the
|
||||
parent's rel base, because a slot of a row is not a fraction of anything the row
|
||||
can name -- a division of two lengths, which a `rel + px` length cannot hold.
|
||||
A widget's own declaration *is* a fraction, of whichever of those reached it,
|
||||
and it is the only one of them that also places the box inside the part.
|
||||
|
||||
Validity has a pin for each: `LayoutHolds::region_len` for the box's symbolic
|
||||
length and `rel_base` for the rel base's. A range of window pixels cannot say
|
||||
which rel base an answer is a fraction of, since two rel bases are different
|
||||
lengths at the same window size. Resolving any of these lengths in pixels is
|
||||
a read of the window, and `Painter::to_px` is where that read is taken --
|
||||
pinning the window where the length has a fraction in it, and nothing where
|
||||
it is only pixels.
|
||||
|
||||
## Layout decisions and invariants (2026-09-15 to 2026-09-17)
|
||||
|
||||
Moved here from the handoff on 2026-09-18. These are settled unless a
|
||||
subsection explicitly says it is pending.
|
||||
|
||||
### Fixed point
|
||||
|
||||
Decided with Bryan on 2026-09-15. Layout decides on a grid rather than in
|
||||
floats.
|
||||
|
||||
- **`Fixed<SHIFT>` is an `i32` counting `1 / 2^SHIFT`.** Adding and
|
||||
subtracting are exact; `mul` drops to the step below (Bryan, 2026-09-16:
|
||||
truncation is preferable); `div`, `div_int` and `ratio` round to nearest;
|
||||
`to_scale` takes the nearest step. Two routes to one place that land on
|
||||
one number are the same place, so everything downstream compares for
|
||||
equality.
|
||||
- **`Px` is `1/1024` px, `Rel` is `1/2^24` of a box, `Weight` is `1/65536`
|
||||
of a share.** `PX_SHIFT` and `REL_SHIFT` are the only statement of the
|
||||
first two; the shader's copy is prepended from them by
|
||||
`render::module_source`. `Px` was `1/64` first, where one rounding's
|
||||
residue was 0.016 px and enough to move a box. Range is +/-2.1M px and
|
||||
conversion to `f32` is exact to 16,384 px.
|
||||
- A weight is not a fraction: a list divides its room by the total of its
|
||||
weights, and `Rel::ratio` turns two weights into a share on the finer
|
||||
grid.
|
||||
- **Arithmetic wraps** (`4febabf`, Bryan: a coordinate past the range will
|
||||
not draw reasonably anyway, so wrap and break clearly). Saturating cost a
|
||||
twelfth of layout's instructions. `MIN` and `MAX` stand in for an
|
||||
unbounded end and are only ever compared against; `from_f32` is the one
|
||||
operation that clamps, and `Holds` keeps a saturating `narrow`.
|
||||
- A pointer, a wheel notch, a shaped glyph advance and a window size arrive
|
||||
as floats and go on the grid where they arrive. `Vec2` is what the GPU
|
||||
and the platform speak; `PxVec2` is what layout decides in.
|
||||
- **Do not widen the grid to chase a residue.** Every failure seen was one
|
||||
value reached by two expressions, sitting on a boundary defined by the
|
||||
same value coming back the other way. No precision shrinks a residue that
|
||||
is the whole distance.
|
||||
- **A value that comes back as a box is rounded away from the measurement,
|
||||
not to the nearest step.** `Fixed::ceil_from_f32` exists for that and is
|
||||
the only rounding on the grid that is not to nearest. Rounding to nearest
|
||||
is right for a value being carried and wrong for a bound; a text reporting
|
||||
`ceil` of its longest line is what keeps the box it is handed back one its
|
||||
line fits in (`4bd8607`).
|
||||
- **A structural decision may not be taken on a hair's breadth.** A
|
||||
boundary that decides which children exist (a span's leftover split) is
|
||||
derived through the inverse of the expression that draws, never by a
|
||||
second expression for the same length: `mul` floors while `div` rounds,
|
||||
so a boundary derived with a division guards a drawing made with a
|
||||
multiply (`53b00c6`).
|
||||
|
||||
### A box in pixels is one multiply from the window
|
||||
|
||||
Every length in the tree is a length of the window, so it becomes pixels in
|
||||
one multiply wherever it is read: `Painter::px_size` and `px_len` for the
|
||||
box, `rel_base` for the rel base. There is no chain to walk and no coordinate
|
||||
rel base in the way, so a region node cannot break it and warm and cold reach
|
||||
every length by the same expression. (Before `1512d84` a rel base was a length
|
||||
of its parent's rel base and a local redraw walked back up the parent chain in
|
||||
`asked_px`; both are gone.)
|
||||
|
||||
- **`Holds::through` is the exact preimage of `px + floor(rel * box)`**:
|
||||
`floor(rel * B) >= lo - px` is `rel * B >= (lo - px) << REL` and
|
||||
`floor(rel * B) <= hi - px` is `rel * B < (hi - px + 1) << REL`, two
|
||||
`div_toward`s once the sign of `rel` has said which bound is which. The
|
||||
answer is an interval even for a single length, because a floor is not
|
||||
invertible. The range has to contain the box a drawing was made in (the
|
||||
`Holds` assertion in `draw_at`, debug only) and must not contain a box
|
||||
the drawing does not hold for (the oracle); being the preimage makes
|
||||
those one statement rather than a trade-off.
|
||||
- **Symbolic regions are for the GPU, hit testing and remaps alone.**
|
||||
`Moves::resolve` is the only walk left and it is the vertex shader's.
|
||||
Nothing layout decides is composed back up the move chain.
|
||||
- **`px` is not stored on `ActiveData`, deliberately.** A resize every
|
||||
widget's `Holds` admits redraws nothing, so a stored pixel length would
|
||||
be stale on every widget in the tree with nothing to say so. What is
|
||||
stored is the symbolic rel base, which a redraw resolves against the window
|
||||
it has.
|
||||
- **The window is not a move entry** (`5b78002`). A chain bottoms out in
|
||||
`MoveIdx::NONE`; the window is applied where a fraction becomes pixels,
|
||||
`to_px(output_size)` on the CPU and the uniform in the shader. A resize
|
||||
rewrites no retained entry and re-uploads nothing but the uniform; its
|
||||
cost is whatever `Holds` redraws.
|
||||
- **A move that keeps a box's length is a translation, and exact.** A box
|
||||
that changed length re-expresses each part as a fraction of the new one,
|
||||
which rounds. `tests/cases/drift.rs` pins that the grid does not drift
|
||||
either way. A length given in pixels is that many pixels wherever it ends
|
||||
up (`Len::within` adds a part's own pixels rather than scaling them);
|
||||
equal shares come out one or two steps apart because positions, not
|
||||
lengths, are what gets rounded, so the row fills and no two children
|
||||
leave a seam.
|
||||
|
||||
### Retained-layout invariants
|
||||
|
||||
- `Holds` is the interval of box lengths for which a widget's drawing and
|
||||
reported size remain valid. Reading `Painter::px_len` or `px_size` narrows
|
||||
it; `Painter::holds` widens it. The contract is trusted rather than checked
|
||||
defensively on every use.
|
||||
- A retained drawing is reusable only when its `Holds` contains the new box
|
||||
on both axes, its parent and region-node choice match, it is on the layer it
|
||||
was drawn on, its inherited mask matches, and the widget is clean. A valid
|
||||
ordinary subtree moves by recursive remap; a region node moves by one entry. A container that draws a
|
||||
child to learn its size uses `Painter::child_layer_at`, the layer the child
|
||||
will actually occupy.
|
||||
- An answer's validity and its final drawing's validity are independent. A
|
||||
parent may reuse an answer while redrawing the placed output. Translate the
|
||||
drawing contract back through its placement; do not intersect it into the
|
||||
answer contract.
|
||||
- A fraction resolves once against its rel base. A report returns raw and is
|
||||
composed only where a parent narrowed that rel base. A part's own pixel length
|
||||
is added rather than scaled, so a pixel length remains that many pixels at
|
||||
every nesting depth.
|
||||
- An asked-but-undrawn size dependency belongs to the widget that asked. Keep
|
||||
it recorded so a later child change reaches the parent that decided not to
|
||||
draw it. Dirty size dependencies settle deepest-first.
|
||||
- A widget that creates a mask clips to and reports its box. Its own mask and
|
||||
its inherited mask are distinct retained state: the former says which mask
|
||||
a move rewrites, while a local redraw receives the latter. The owner retains
|
||||
one mask reference and reuses its slot across redraws, independently of
|
||||
primitive references; dropping the mask or undrawing the owner releases it.
|
||||
- A span's leftover/no-leftover boundary is a strict structural decision, not
|
||||
a tolerance. Derive the boundary through the inverse of the expression that
|
||||
places children. A cap may not contain `leftover`, because feeding the
|
||||
span's own room division back into a cap admits multiple fixed points.
|
||||
- Text shaping is retained separately from line breaking. A greedy break
|
||||
holds from its longest produced line through the width at which it was
|
||||
made, expressed with `Painter::holds`.
|
||||
- A region node stores one whole `UiRegion` in its parent node's coordinates;
|
||||
`FULL` is the identity. Changing node ownership redraws the subtree once,
|
||||
and a removed node's move entry remains alive until every descendant has
|
||||
migrated.
|
||||
- Alignment is one value per axis and defaults to the middle because neither
|
||||
edge is neutral without a direction. One widget has one length per axis; a
|
||||
second length requires a second widget through `.wrapper()`.
|
||||
- No measurement is a different state from a measured zero, so
|
||||
`ActiveData::answer` is an `Option` rather than a zero size.
|
||||
|
||||
### What the fuzzers tolerate, and what they reach
|
||||
|
||||
Warm and cold pixel regions must compare exactly; there is no step
|
||||
allowance. When a row's grid-step count is not divisible by the number of
|
||||
children, individual share widths differ, but every rerun of that layout
|
||||
must still agree exactly.
|
||||
|
||||
- The routine runs are ten seeds (fast oracle), 400 at depth 5 through the
|
||||
shrinker, and 1000 at depth 6. The 2000-seed depth-4 scan over every case
|
||||
is what found seeds that had failed on every commit for a week, so run it
|
||||
after any layout change. `Rng::new` is `seed | 1`, so an even seed and the
|
||||
odd one above it are one tree.
|
||||
- The shrinker panics per thread at the first failing seed, so a run reports
|
||||
at most one seed per chunk of about 58. Check a single seed against the
|
||||
unpatched code before calling it new.
|
||||
- Depth finds things and so does breadth; widen one axis at a time and
|
||||
record which.
|
||||
- If a seed fails once and never again, suspect the rig's determinism before
|
||||
believing an edit fixed it: seed 30 at depth 5 did exactly that on
|
||||
2026-09-19 and reverting each part of the change did not bring it back.
|
||||
- The cases are what a change is *then* compared at. Until the `size-resize`
|
||||
case was added, every one of them compared at the window the change was
|
||||
made in, and a length kept as a fraction of the wrong box agrees there and
|
||||
parts from cold at every other window. There is still no case that changes
|
||||
a tree twice with a resize between, and none that resizes twice.
|
||||
- The fuzzer grows no `rel` rules and never re-parents a widget, so neither
|
||||
a fraction resolved against a rel base nor a subtree changing hands is
|
||||
covered by any generated tree. Both have cost a defect that only a
|
||||
hand-written expectation or a render caught.
|
||||
|
||||
### Failed hypotheses worth not repeating
|
||||
|
||||
- **"Which draw is the measurement" cannot be defined.** Four bookkeeping
|
||||
rules and two experiments each fixed some seeds and broke others. The bit
|
||||
existed only because a widget was drawn twice; one draw leaves it nothing
|
||||
to name. A plan that proposes bookkeeping for a distinction should first
|
||||
ask whether the distinction has to exist.
|
||||
- **A rel base narrowed by a region does not move when its part moves.** A
|
||||
narrowed rel base must be a *length*, put back into the part on every
|
||||
placement, exactly as a declared rule already is.
|
||||
- **Choosing between a fixed and a relative child in pixels at the span's
|
||||
current width** admits multiple self-sizing fixed points; seed 13 settles
|
||||
differently warm and cold under it. The same circularity is what a cap
|
||||
containing `leftover` would put into `SizeRule::Max`.
|
||||
- **A tolerant endpoint on the span's leftover split** retains zero-height
|
||||
children (seed 16). The boundary is derived through the inverse of the
|
||||
expression that places children, and is pinned by
|
||||
`unsettled::a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over`.
|
||||
- **Not reading a span's own length where no slot depends on it** saves
|
||||
nothing (`wip/inset`, measured 2026-09-19): counters identical at every
|
||||
phase. A symbolic pin survives a resize because every ask box is
|
||||
symbolically stable across one, so the pin only fails where an answer
|
||||
changed, which is a real relayout.
|
||||
- **Resolving a rule that is a fraction of the rel base from the record instead
|
||||
of redrawing** recovers nothing: `reuse outside: a rel base` is 3 of
|
||||
264 cold draws at seed 1 and 1 of 758 at seed 13.
|
||||
- **A child rel base expressed as a fraction of the parent's region** (the
|
||||
"extent-relative child frame" trial, 2026-09-17) was removed. The
|
||||
size/resize cost it was aimed at came from conflating answer and drawing
|
||||
dependencies, not from `Pad` and `Stack` reading their raw placement
|
||||
conservatively. A decided rel base is a length of the window, not a
|
||||
fraction of anything the parent can name.
|
||||
- **A `git bisect`** once named a commit that could not be the cause; read
|
||||
the tree rather than the bisect when that happens.
|
||||
|
||||
### Rendering the grid (pending)
|
||||
|
||||
`snap_floor` in `prelude.wgsl` adds half a layout step before flooring,
|
||||
which absorbs float error and not a layout step, so a third of 900 px
|
||||
(299.999 on the grid) lands at 299 on screen. Bryan approved on 2026-09-17
|
||||
rounding to the nearest pixel in the shader together with round-to-nearest
|
||||
in `Fixed::mul` on the CPU, as one change with one verification; neither has
|
||||
landed. The reason for the CPU half: a `Rel` is off by at most `2^-25` of
|
||||
its box, so with round-to-nearest every product whose true value is a whole
|
||||
number of steps is exact for boxes under about 8,000 px, where truncation
|
||||
leaves half of them one step short and layout then decides "does not fit"
|
||||
on a container the user meant to fit exactly. Use the branchless
|
||||
round-half-up form, `(a * b + (1 << (BY - 1))) >> BY`; re-derive
|
||||
`Holds::through` for it; check with `nm` that `UiSpan::within` still
|
||||
inlines.
|
||||
|
||||
## Measuring layout cost on this machine
|
||||
|
||||
- **Counters, at two seeds, before anything else.**
|
||||
|
||||
IRIS_SEED=1 IRIS_DEPTH=8 cargo test --release --features layout-diagnostics \
|
||||
--test layout_diagnostics -- --ignored --nocapture layout_cost
|
||||
|
||||
reports each phase's `widget draws` and `distinct widgets`, and
|
||||
`IRIS_PHASE=resize IRIS_FRAMES=2` names who is drawn how often. Where the
|
||||
one-ask protocol stands, draws / distinct at depth 8, beside #18:
|
||||
|
||||
| seed 1 | #18 | now | | seed 13 | #18 | now |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| cold | 369/261 | 264/232 | | cold | 1330/707 | 758/627 |
|
||||
| many | 157/95 | 41/41 | | many | 524/159 | 16/16 |
|
||||
| size | 16/12 | 3/3 | | resize | nothing | nothing |
|
||||
| scroll | 2 | 1 | | | | |
|
||||
| resize | 13/13 | 36/13 | | | | |
|
||||
|
||||
`many`, `size` and `scroll` are at their floor: every draw is a marked
|
||||
widget, or the parent a marked widget deferred to. `resize` at seed 1 and
|
||||
cold's draws over its distinct count are the reported-share cost above.
|
||||
|
||||
- **A change that could move cold layout gets the dump diff**, which the
|
||||
warm/cold oracle cannot replace -- both of its sides move together:
|
||||
|
||||
IRIS_DUMP_SEEDS=400 IRIS_DUMP_DEPTH=5 cargo test --release \
|
||||
--test layout_dump -- --ignored --nocapture | grep -E '^[0-9]+ [0-9]+ '
|
||||
|
||||
once at each commit, then `diff`. Zero differing lines of 34,488 is the
|
||||
expectation for a cost change.
|
||||
|
||||
- **Check the work counters before comparing two commits' times.**
|
||||
`tests/layout_diagnostics.rs` prints drawn widgets, widget draws and
|
||||
primitive writes; a comparison is only worth reading when they match.
|
||||
`random.rs`'s `Branch` picks a subtree by a measured pixel length, so the
|
||||
fixture's shape moves with the thing measured; `Edits::fixed_branches`
|
||||
pins it for timing and the oracle keeps measured branches on purpose. A
|
||||
3x once reported was that artifact.
|
||||
- **`perf stat` in this VM returns garbage readings** for both
|
||||
`instructions:u` and `cycles:u`, roughly a quarter of the time, off by a
|
||||
factor of five to fifteen. Take medians of nine or more and report how
|
||||
many readings a filter kept. Instruction counts hold to 0.02% within a
|
||||
binary and move 0.5% across a rebuild, so build the baseline beside the
|
||||
thing measured and quote a delta.
|
||||
- **What moves cycles is whether `UiSpan::within` inlines.** It is the
|
||||
hottest line in layout; `nm` shows it as a symbol when it does not.
|
||||
Shrinking its body until the inliner takes it won; `#[inline]` on the
|
||||
body it had lost 1.5% cycles. Shrink it, do not annotate it.
|
||||
- `Holds::through` divides twice per call and accounts for essentially all
|
||||
of a run's `i64` divisions: 2.8% of a 500-frame `many`.
|
||||
- Tried and rejected, with numbers: a float reciprocal for the remap
|
||||
division, +6% cycles; branchless `shift_round`, +6.7%; removing the
|
||||
per-child hash lookup in `remap_subtree`, 0.0%; short-circuiting
|
||||
`apply_scalar` where the fraction is nought or one, +17%. Short-circuits
|
||||
guarding a saturating multiply stopped paying once the multiply wrapped;
|
||||
re-price a short-circuit before keeping it. Rust does not contract
|
||||
`a + b * c`. Wrapping (`4febabf`) was -8.6% instructions; truncating
|
||||
(`08c9d5a`) costs a share a thousandth of a pixel of its row.
|
||||
- Threading the pixel box down the draw (2026-09-17) was free on cold
|
||||
layout and 9-13% of instructions off the retained paths, measured against
|
||||
`5b78002` at seed 1, depth 8, medians of 21.
|
||||
+1675
File diff suppressed because it is too large.
Load diff
@@ -864,6 +864,25 @@ such as corner radius and text's position within horizontal overflow use
|
||||
`Len`. Flexible padding participates in the same proportional allocation as a
|
||||
span rather than silently discarding its `rest` component.
|
||||
|
||||
**The one-ask layout design is settled, and lives in `docs/LAYOUT.md`**
|
||||
(2026-09-17 to 2026-09-19). What this entry used to describe as the
|
||||
"frame/extent experiment" is Iris PR #19, and it has its own vocabulary now:
|
||||
the box a parent asks a widget in is its *region*, where the drawing ends up
|
||||
is its *placement*, and the length a fraction resolves against is its *rel
|
||||
base*. A container says all three in one `PlaceDesc` argument. `Widget::draw`
|
||||
remains the only layout body. PR #19 also has optional size-request discovery
|
||||
for deferred min/max/clamp expressions (2026-09-20); ordinary widgets still
|
||||
return `Size`, and plain requests retain symbolic allocation. Built-in
|
||||
min/max/clamp rules also constrain the offered box; the dimension helpers
|
||||
edit the widget's own attributes, replacing the `MaxSize` wrapper. Preferred
|
||||
lengths and bounds are independent, so setting `.width` preserves `.max_width`
|
||||
and vice versa. Bound choices are retained as resolved lengths so moving a
|
||||
drawing cannot resolve them against a different box. The invariants,
|
||||
the measured costs, the fuzzing method and the failed hypotheses are all in
|
||||
`docs/LAYOUT.md`; the app's pin at
|
||||
`32f6ad8` predates every part of it, so nothing here depends on it until the
|
||||
integration described in `docs/HANDOFF.md` lands.
|
||||
|
||||
**A `Span` leaves its children in the offered orthogonal region unless
|
||||
explicitly compacted** (2026-09-12). It still reports the widest orthogonal
|
||||
child as its intrinsic size, so a row nested in a column keeps its content
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user