Compare commits
121
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 |
No files matched your search
+1
-1
@@ -4,4 +4,4 @@
|
|||||||
[submodule "iris"]
|
[submodule "iris"]
|
||||||
path = iris
|
path = iris
|
||||||
url = git@git.arirex.me:iris-ai/iris.git
|
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.
|
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
|
`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
|
and update it when a decision changes. `docs/HANDOFF.md` is where the work in
|
||||||
lands: preserve current invariants, measurements, and failed hypotheses, not a
|
flight stands; read it first in a fresh session and keep it current. Working
|
||||||
chronicle of completed tasks. Do not create a decisions log.
|
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
|
## 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.
|
||||||
File diff suppressed because it is too large.
Load diff
+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
|
`DrawResult::size()` records a retained size dependency; drawing the child
|
||||||
without reading that result does not make the parent's size depend on it.
|
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
|
§1 landed in Iris as #16. §2 and §3 were #18, which is closed: Iris PR #19
|
||||||
brought to what shipped rather than what was proposed; §3 to §6 describe the
|
carries those commits whole plus the retained-layout repair built on them. §4 to §6 and the density section
|
||||||
same design as it stands. `docs/IRIS_EXTRACTION_HANDOFF.md` has the invariants
|
retain the rationale of the design but still name types that have since been
|
||||||
the code now rests on and what is still to do.
|
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
|
## Design
|
||||||
|
|
||||||
@@ -36,11 +165,12 @@ pub trait Widget: Any {
|
|||||||
fn draw(&mut self, painter: &mut Painter) -> Size;
|
fn draw(&mut self, painter: &mut Painter) -> Size;
|
||||||
|
|
||||||
fn size_hint(&self, axis: Axis) -> Option<Len> { None }
|
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
|
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()`
|
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
|
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.
|
without making the parent's own size depend on the child's.
|
||||||
|
|
||||||
No `available` parameter: `Painter` already carries the region the parent
|
No `available` parameter: `Painter` already carries the region the parent
|
||||||
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already
|
handed down (`Painter::region()`) and already exposes the pixel-resolved form
|
||||||
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface
|
(`px_size()`) and the output surface size (`output_size()`). Passing it again
|
||||||
size (`output_size()`, `:152`). Passing it again would be the same value
|
would be the same value under a second name. `desired_width`/`desired_height`
|
||||||
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`)
|
and `WidgetAxisFns::desired_len` are deleted outright — not
|
||||||
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
|
|
||||||
deprecated, not kept as a fallback — because a widget that implements both
|
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
|
`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`
|
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
|
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
|
set in the context, which makes this slow and not cool." Folding sizing into
|
||||||
`draw` deletes that duplicate simulation, not just moves it.
|
`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
|
### 2. O(1) subtree movement
|
||||||
|
|
||||||
Every active widget owns a slot in `UiData::move_offsets`. A slot stores an
|
A widget opts into one independently movable region with `.region_node()`, or
|
||||||
absolute-pixel delta and its parent slot; each primitive instance stores the
|
`Widgets::set_region_node` at runtime; `.scrollable()` sets it once as its
|
||||||
slot of the widget that drew it. The vertex shader walks this bounded chain
|
convenient default. A node holds a whole **box** -- a `UiRegion` in its parent
|
||||||
and adds the accumulated translation. Moving a subtree therefore writes one
|
node's coordinates, `UiRegion::FULL` being the identity -- and each primitive
|
||||||
slot instead of rewriting every descendant 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
|
A box rather than a translation, because a pixel-space offset would scale a
|
||||||
as a swipeable row inside a scrolling list. A flat offset table would require
|
child that has to keep its pixel length; the fraction and the offset in a
|
||||||
rewriting the row whenever an ancestor moved and would restore the very
|
`UiScalar` are what tell the two apart. The parent chain is what makes nested
|
||||||
O(subtree) work this design removes. Chain depth is bounded in both Rust and
|
movable subtrees work -- a swipeable row inside a scrolling list -- and a flat
|
||||||
WGSL.
|
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
|
`Moves::resolve` performs the same walk on the CPU for hit testing,
|
||||||
again, and gives that child a slot of its own; `widget` and `widget_within` do
|
accessibility and window-coordinate queries, and the shader's `resolve_move`
|
||||||
not, and share the nearest ancestor's. A slot carries a whole **box**, not a
|
mirrors it. Coordinates cross as whole counts of `1/1024` px and `1/2^24` of
|
||||||
translation: a pixel-space scale and offset would scale a child that has to
|
a box, which the shader decodes from constants the Rust side prepends: the
|
||||||
keep its pixel length, and the `rel`/`abs` pair is what distinguishes the two.
|
grid is stated once. Masks carry their own node and resolve it independently,
|
||||||
(That slots carry translation only was an agent's choice on 2026-09-04, never
|
so a stationary viewport clips content that moves inside it.
|
||||||
asked for, and #18 replaced it.)
|
|
||||||
|
|
||||||
`UiRenderState::resolved_region` performs the same chain walk on the CPU for
|
Nodes follow `ActiveData`'s lifecycle. Removing one retires its entry only
|
||||||
hit-testing, accessibility, and public window-coordinate queries. Masks store
|
after every descendant has migrated, since reusing the index sooner would
|
||||||
the move slot of their owning widget and resolve it independently in the
|
make an old parent look current. Changing the property redraws the subtree
|
||||||
fragment shader, so a stationary viewport can clip moving content.
|
once, to rebuild the coordinate boundary; it belongs to widget identity,
|
||||||
|
which is safe because a widget has one parent.
|
||||||
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.
|
|
||||||
|
|
||||||
### 3. Resize scope
|
### 3. Resize scope
|
||||||
|
|
||||||
A resize is "the region a widget's parent offers it changes such that the
|
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
|
widget's draw might produce different output" -- as opposed to a move, which
|
||||||
by construction cannot (§2 is scoped to pure translation). Two independent
|
by construction cannot. Two independent narrowings apply, and both are
|
||||||
narrowings apply, and both are real, measured properties of the code as it
|
measured properties of the code rather than new machinery:
|
||||||
stands rather than new machinery:
|
|
||||||
|
|
||||||
**(a) A window resize does not, by itself, require touching most widgets.**
|
**(a) A window resize does not, by itself, require touching most widgets.**
|
||||||
`shader.wgsl` recomputes every primitive's pixel position from `window.dim`
|
The shader recomputes every primitive's position from `window.dim` and the
|
||||||
and the primitive's stored `rel`/`abs` pair every frame, already, on the GPU.
|
primitive's stored fraction and offset every frame, already, on the GPU. A
|
||||||
A widget laid out purely in `rel`/`abs` terms is therefore already correct
|
widget laid out purely in those terms is therefore correct after a resize
|
||||||
after a resize with zero CPU work. Calls to `Painter::px_size` and
|
with no CPU work at all.
|
||||||
`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.
|
|
||||||
|
|
||||||
All pixel-dependent leaves are marked before layout begins, along with every
|
What decides the rest is `Holds`, one interval of box lengths per axis:
|
||||||
chain of parents that read their sizes. Resize then settles the shallowest
|
*give this widget any box in here and it draws the same thing and reports the
|
||||||
shared readers first, under the new output, so overlapping dependency paths
|
same size*. A widget that never reads its box in pixels holds for every
|
||||||
are drawn once. Ordinary content changes use the opposite order: deepest
|
length. Reading `Painter::px_len(axis)` or `px_size()` narrows the interval
|
||||||
dirty widgets first, with a changed returned size propagated one reader edge
|
to the length read, and `Painter::holds` is how a widget widens it again by
|
||||||
at a time. Re-reporting the current output size is a no-op.
|
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
|
This replaced `Widget::on_resize` and its `Scale`/`Redraw`/`Translate`
|
||||||
without the widget's *content* changing — this is what `Widget::on_resize`
|
answers, which said the same thing per widget type and could not say *how
|
||||||
answers, per axis.** When a container's own layout shifts (a sibling grew or
|
far*. There is no per-widget resize mode now: a widget that reads nothing is
|
||||||
shrank, changing this widget's offered box), a widget that says `Scale` on the
|
never redrawn for a resize, one that reads its width is redrawn when its
|
||||||
axes that changed is not redrawn: everything it drew is a fraction of its own
|
width leaves the interval it declared, and the interval is the whole of the
|
||||||
slot's box, so writing that one box moves and stretches all of it. `Span`,
|
statement. Do not restore `Translate`; a retained subtree that only moves is
|
||||||
`Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` say `Scale`;
|
remapped through the box chain of §2, exactly.
|
||||||
`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.
|
|
||||||
|
|
||||||
**Size invalidation travels upward before drawing; drawing itself travels only
|
Lengths are whole counts of `1/1024` px, so "the box changed" is equality
|
||||||
downward.** Every active widget retains the direct children whose size it read
|
rather than a tolerance: a change too small to reach the next step is not a
|
||||||
through `DrawResult::size()` or `Painter::known_len`. `redraw_updates` takes
|
change, and one that reaches it is, however little of a pixel it is worth.
|
||||||
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.
|
|
||||||
|
|
||||||
An exact `size_hint` stops propagation when both axes still equal the retained
|
**(b) Size invalidation travels upward before drawing; drawing itself travels
|
||||||
size. Otherwise propagation is deliberately conservative: the child may have
|
only downward.** Every active widget retains the direct children whose size it
|
||||||
changed size, and only its dependent ancestors can assign the final boxes.
|
read through `DrawResult::size()` or `Painter::known_len`. `redraw_updates`
|
||||||
Unchanged descendants still take `draw_inner`'s retained skip-or-move path.
|
takes one id from the dirty set, follows only those dependency edges upward
|
||||||
An active widget also retains which offered-box and output axes flowed into
|
and marks that path dirty, then redraws its highest already-dirty ancestor.
|
||||||
the size it reported, directly or through a child size it read. A container
|
Drawing that ancestor consumes the marks of every dirty descendant it
|
||||||
may use that answer for the same prospective box when every observed input is
|
reaches; the loop then takes whatever remains. Drawing never synchronously
|
||||||
still within 0.05 physical pixels; content dirtiness anywhere in its size
|
invalidates or invokes a parent, so there is no layout recursion.
|
||||||
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,
|
Dirty widgets settle deepest-first. `dirty_size_under` has been deleted;
|
||||||
so changing only height leaves its answer valid.
|
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"
|
### 4. Wrapped text, and "needs child height before choosing width"
|
||||||
|
|
||||||
@@ -307,6 +428,11 @@ physical pixels, unchanged.
|
|||||||
|
|
||||||
## Masks
|
## 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
|
A `Mask` references a rectangle primitive and its parent mask. Nested masks
|
||||||
multiply coverage. Plain `.masked()` creates an undrawn rectangle at the
|
multiply coverage. Plain `.masked()` creates an undrawn rectangle at the
|
||||||
widget's region; `.masked_by(shape)` draws the shape behind the content and
|
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`
|
mask chain and use the same rounded-rectangle coverage; `iris/tests/mask_sdf.rs`
|
||||||
checks the WGSL implementation against the CPU SDF.
|
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
|
Settled with Bryan on 2026-09-17 and 2026-09-19. Layout has exactly three
|
||||||
reports the child's used size plus padding. In a generous parent it behaves as
|
things, and only two of them are boxes:
|
||||||
an inset; in a tight parent it grows the result outward.
|
|
||||||
|
|
||||||
When a widget does not fit its offered box, it is redrawn at the box implied by
|
- **region** — the box a parent asks a widget in. It draws there, and a text
|
||||||
its reported size in the same frame. Deferring would leave ordinary
|
wraps at it. `ActiveData::region`, `Painter::region`.
|
||||||
`.background(rect(..))` surfaces one frame behind their content. The settling
|
- **placement** — the box its drawing ends up in: a box the size it reported,
|
||||||
draw occurs only when the widget's own size changes. Widgets whose size varies
|
positioned inside the region by its own alignment, and equal to the region
|
||||||
with every offered box are therefore unsuitable as `LazySpan` rows.
|
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
|
`Len`. Flexible padding participates in the same proportional allocation as a
|
||||||
span rather than silently discarding its `rest` component.
|
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
|
**A `Span` leaves its children in the offered orthogonal region unless
|
||||||
explicitly compacted** (2026-09-12). It still reports the widest orthogonal
|
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
|
child as its intrinsic size, so a row nested in a column keeps its content
|
||||||
|
|||||||
Reference in new issue
Block a user