Record the pixel path closing, and the mask a redraw inherits
A box in pixels is threaded down the draw now rather than composed back up the move chain, which closes the offer chain breaking at a region node, makes `Holds::through` an exact preimage, and retires `redraw`'s third ask. The section that diagnosed the defect becomes the one that states the rule, with what the plan got wrong recorded beside it: `px` cannot be stored on `ActiveData`, because a resize that every `Holds` admits redraws nothing and would leave it stale everywhere. `AGREE_STEPS` stays 2, measured rather than assumed: one step passes the 100-seed oracle and fails the 400-seed shrinker on `resize-size` by 0.002 px, so the second step belongs to the resize path re-expressing a part as a fraction of a box that changed length. The review of that change found an older defect of its own -- a `Masked` widget settled on its own panicked, since `redraw` handed it its own mask as the one it inherited -- and the invariant that closes it is here too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9a26862bce
commit
4e75019b9a
1 file changed
+247
-187
+247
-187
@@ -7,8 +7,8 @@ log.
|
||||
## Where things stand
|
||||
|
||||
Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18
|
||||
`split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`; its local
|
||||
head is **`5f16617`**, eighty-two commits, pushed. Built-in alignment is
|
||||
`split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`; its head
|
||||
is **`ea6dbae`**, eighty-five commits, pushed. Built-in alignment is
|
||||
complete there; see "Built-in alignment" below for the retained-layout
|
||||
details. No PR reviews were present when checked on 2026-09-15.
|
||||
|
||||
@@ -18,6 +18,22 @@ widget that asked in *that widget's* frame, and the window is no longer a
|
||||
move entry -- see "A report is a fraction of the box it was given" and the
|
||||
root item under "Fixed point". `Holds::through` is untouched by either.
|
||||
|
||||
**And then the open defect closed**, in `32542d0` (2026-09-17). A box in
|
||||
pixels is no longer composed back up the move chain at all: it is threaded
|
||||
down the draw one multiply a level, and `UiRenderState::asked_px` walks the
|
||||
same steps back up where a local redraw starts part-way down the tree. That
|
||||
deletes `wide.rs`, `Moves::compose`, `Moves::size_of`, `px_of`, `px_region`,
|
||||
`offered_region`, `slot_wide` and `redraw`'s third ask, makes
|
||||
`Holds::through` the exact preimage of the map it inverts, and fixes the
|
||||
offer chain under a region node -- 252 lines of `core/` net, free on cold
|
||||
layout and 9-13% off the retained paths. It is its own section: "A box in
|
||||
pixels is one multiply from its parent's".
|
||||
|
||||
**`ea6dbae` is a defect the review of that one found**, unrelated to it and
|
||||
older: `redraw` handed a widget its own mask back as the mask it inherited,
|
||||
so a `Masked` widget settled on its own panicked. See the mask item under
|
||||
"Retained-layout invariants".
|
||||
|
||||
The current head completes LAYOUT.md §2's position chain and the requested
|
||||
`leftover` behavior. A child whose length is only `leftover` is not drawn when
|
||||
nothing is left. A child that also asks for pixels or a relative fraction keeps
|
||||
@@ -109,17 +125,23 @@ The tree, five widgets, now running in `tests/cases/unsettled.rs` as
|
||||
|
||||
Scroll(Y) { Span(DOWN) { Scroll(X){ text } as a region node, rect 87x24 } }
|
||||
|
||||
`UiRenderState::redraw` asks a dirty widget in the box its parent asked it in
|
||||
and then again in the box its parent chose from that answer, and it skipped
|
||||
that second ask whenever the two were the same **length**. They are not the
|
||||
same box: the outer scroll offers its whole viewport and places the span 24px
|
||||
above it, snapping to the end of a content 24px longer than its box, so the
|
||||
offer was as long as the final box and 24px below it. A region node writes
|
||||
the box it drew in into its own move entry, so the inner scroll stayed where
|
||||
the offer put it. `d3b0ebf` had already compared whole boxes for
|
||||
`UiRenderState::redraw` asked a dirty widget in the box its parent had
|
||||
*offered* it and then again in the box its parent chose from that answer, and
|
||||
it skipped that second ask whenever the two were the same **length**. They
|
||||
are not the same box: the outer scroll offers its whole viewport and places
|
||||
the span 24px above it, snapping to the end of a content 24px longer than its
|
||||
box, so the offer was as long as the final box and 24px below it. A region
|
||||
node writes the box it drew in into its own move entry, so the inner scroll
|
||||
stayed where the offer put it. `d3b0ebf` had already compared whole boxes for
|
||||
`parent_must_place` and left this one a length comparison -- one decision with
|
||||
its two halves disagreeing.
|
||||
|
||||
**The pair of asks it was a disagreement between is gone** (2026-09-17): a
|
||||
local redraw asks in the box its parent *gave*, which is where the widget
|
||||
already sits, and hands anything else back to the parent. See "A box in
|
||||
pixels is one multiply from its parent's". The test stays, and the lesson
|
||||
above it does.
|
||||
|
||||
**The rigs.** `98d4e98` splits `iris::random::grow` into `plan(seed, depth,
|
||||
&edits)` and `build(rsc, &plan)`. A seed cannot be made smaller, which is why
|
||||
a failure the oracle found could never be handed to the shrinker; a `Plan`
|
||||
@@ -141,7 +163,9 @@ the box its parent chose from the widget's answer, handed that box over as if
|
||||
it were an offer, so `draw_inner` ran `placed_box` on an already-placed box
|
||||
and applied the widget's own alignment to it twice. That only showed where
|
||||
the alignment was the widget's own to apply, which is why it stayed hidden
|
||||
while `Stack`, `Pad` and `Scroll` overrode every child.
|
||||
while `Stack`, `Pad` and `Scroll` overrode every child. (That ask no longer
|
||||
exists at all, which is the same defect made unwritable rather than fixed
|
||||
twice.)
|
||||
|
||||
Closed by it: shrinker seed 288 on `region-node` (11 widgets from 42, a
|
||||
`Text` inset 8.8px at each end) and oracle seed 326 at depth 6 (43 from 205,
|
||||
@@ -173,7 +197,37 @@ in, and it is compiled out of the release runs above.
|
||||
|
||||
## Verification
|
||||
|
||||
**At `5f16617`**, the current head:
|
||||
**At `32542d0`**, a box in pixels threaded down the draw (2026-09-17):
|
||||
|
||||
- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets --
|
||||
-D warnings`, `cargo test --workspace`: green, 86 suite tests, 18 core
|
||||
unit tests, 11 generated cases.
|
||||
- The release oracle at 100 seeds in 14.4 s, and **120 seeds in debug** in
|
||||
59 s -- the debug run is the one that exercises the `Holds` assertion in
|
||||
`draw_at`, and the point of an exact range is that a too-narrow one fires
|
||||
it.
|
||||
- All fifteen shrinker cases at 400 seeds of depth 5 in 58 s, and at 1000
|
||||
seeds of depth 6 in 147 s.
|
||||
- Shrinker seed 220 on `reorder`, the defect this closes, and it is a test
|
||||
now:
|
||||
`unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered`
|
||||
fails without the fix and passes with it. **The old offer chain with the
|
||||
old allowance also passes seed 220** -- it is the exact `Holds::through`
|
||||
that exposes it -- so the test was checked against that pair rather than
|
||||
against the old head alone.
|
||||
- `view`, `minimal`, `random`, `tabs` and `text` render byte-identical at
|
||||
1920x1200 against `5b78002`, and so does the `tabs` touch replay, before
|
||||
and after the gesture. `random` live-resized from 1920x1200 to 1280x800 is
|
||||
byte-identical both to the old head's resize and to a cold 1280x800 render
|
||||
(`5a11a464...`).
|
||||
- Twenty-five rig work counters identical on the `cold` and `resize` phases,
|
||||
which is what makes those two rows of the table under "Performance" a
|
||||
measurement rather than a bound.
|
||||
**At `ea6dbae`**, the mask fix on top of it: the suite again at 87 tests, the
|
||||
core unit tests, the release oracle at 100 seeds, the fifteen shrinker cases
|
||||
at 400 seeds of depth 5, and `tabs` byte-identical at 1920x1200.
|
||||
|
||||
**At `5f16617`**:
|
||||
|
||||
- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets --
|
||||
-D warnings`, `cargo test --workspace`: sixteen targets green, 83 suite
|
||||
@@ -459,6 +513,35 @@ What it costs, all of it measured rather than asserted:
|
||||
truncation either side is measurably too narrow. See the item under
|
||||
"Retained-layout invariants".
|
||||
|
||||
**Threading a box in pixels down the draw is free on cold layout and 9-13%
|
||||
off the retained paths** (2026-09-17). Instructions:u, medians of 21 runs of
|
||||
binaries built in one worktree, seed 1 at depth 8, against `5b78002`:
|
||||
|
||||
| phase | before | after | |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| `cold`, 200 frames | 313.1M | 312.9M | -0.04% |
|
||||
| `resize` | 408.1M | 405.6M | -0.61% |
|
||||
| `many` | 1,924M | 1,756M | -8.75% |
|
||||
| `scroll` | 357.3M | 323.4M | -9.49% |
|
||||
| `repaint` | 363.3M | 315.4M | -13.18% |
|
||||
|
||||
**`cold` and `resize` are the pair that compares directly**: all twenty-five
|
||||
work counters are identical on both, so those two rows say the draw path is
|
||||
no more expensive threaded than composed. The other three do *less* work
|
||||
rather than the same work faster, and the counters say how much a frame:
|
||||
`repaint` goes from 23 draw requests and 13 widget draws to **1 and 1**,
|
||||
`scroll` from 20 and 11 to 8 and 2, `many` from 273 and 186 to 207 and 157.
|
||||
Primitive writes are identical in every phase, and so are all five reference
|
||||
renders, which together are what say the output did not move.
|
||||
|
||||
Two things account for it: `redraw` composes nothing any more
|
||||
(`offered_region`, `px_of`, `px_region` and `Moves::compose` are gone), and a
|
||||
widget whose box moved without changing length settles itself instead of
|
||||
escalating to its parent -- which is what `repaint`'s other twelve draws
|
||||
were. Cycles are not quoted because this VM's are not worth quoting: across
|
||||
these runs one unchanged binary's `cycles:u` ranged from 136M to 2,236M,
|
||||
with the garbage readings described below.
|
||||
|
||||
Earlier, against #18's own history: the retained rewrite took `many` from
|
||||
25.17M instructions a frame at `691e3eb` to 6.14M at `29c7881`, and `resize`
|
||||
from 16.08M to 8.39M. That fixture has since changed twice; do not compare
|
||||
@@ -483,6 +566,15 @@ across it.
|
||||
report its whole content length, which is what the `Scroll` above it
|
||||
measures. One that set its own mask would have to report its box and hand
|
||||
the content length up another way.
|
||||
- **A widget's own mask is not the one it inherited**, and `ActiveData` keeps
|
||||
both: `mask` is what its drawing is clipped to and `parent_mask` what
|
||||
whoever drew it handed down. They differ exactly where the widget called
|
||||
`set_mask`, which is what says whose mask a move rewrites -- and a local
|
||||
redraw inherits the second, because handing back the first hands a widget
|
||||
its own mask to set again, which `set_mask` asserts against. Found by
|
||||
review on 2026-09-17: every `Masked` widget redrawn on its own panicked,
|
||||
for as long as there has been a local-redraw path. Pinned by
|
||||
`retained::a_masked_widget_redrawn_on_its_own_sets_its_mask_again`.
|
||||
- A span is as long across itself as its longest child, unless a rule beside
|
||||
it says how long it is -- and then it does not read its children there at
|
||||
all, since the answer is not wanted and reading one is what makes its size
|
||||
@@ -504,14 +596,16 @@ across it.
|
||||
widget has one parent. `.scrollable()` sets it once; raw `Scroll::new` does
|
||||
not, and `Scroll` never reasserts it while drawing.
|
||||
- The first box a parent asks about is the offer. A later box chosen from the
|
||||
child's answer is the final box, not another independent answer. Dirty
|
||||
widgets are re-asked at the offer and only then drawn in the final box. An
|
||||
offer composes through its ancestors' offers, not through their current
|
||||
placed boxes.
|
||||
child's answer is the final box, not another independent answer. An offer
|
||||
composes through its ancestors' offers -- as *lengths* of them, which is a
|
||||
chain of fractions with no coordinate frame in it, so a region node between
|
||||
two widgets changes nothing about it. A dirty widget is re-asked in the box
|
||||
its parent gave it, and only where that box is as long as the offer;
|
||||
anything else is its parent's question to ask again.
|
||||
- An answer is reusable only where both its measurement and the drawing made
|
||||
in its final placed box remain valid. The final drawing's `Holds` interval
|
||||
is translated back into lengths of the offered box and intersected with the
|
||||
answer's interval.
|
||||
is translated back into lengths of the box the widget was asked in and
|
||||
intersected with the answer's interval.
|
||||
- **A container's near-edge override is owed only where the box really is the
|
||||
child's own answer.** `Stack` gives every child the box `box_of` derives
|
||||
from its sizing child, so that one child has no room in it and would be
|
||||
@@ -523,16 +617,23 @@ across it.
|
||||
box it hands its content along the scrolling axis is the content's own
|
||||
length, and where shorter content sits across the viewport is `Scroll`'s own
|
||||
alignment by design.
|
||||
- **A box a parent has already placed is asked about at the near edge.** The
|
||||
second ask in `redraw` is in the final box, not at an offer, so applying the
|
||||
widget's alignment to it again places its content twice (`d8ae9c3`).
|
||||
- Equal box lengths do not imply equal placement. An ordinary widget whose
|
||||
offered and current boxes differ in position must involve its parent again;
|
||||
a region node can settle itself only when its own alignment, rather than a
|
||||
container override, determines the final box. **Both halves of that
|
||||
decision compare whole boxes** -- whether the parent must place it, and
|
||||
whether the offer already was the final box. Comparing lengths for the
|
||||
second left a region node drawn at its offer (`aea878d`).
|
||||
- **A box a parent has already placed is asked about at the near edge**, and
|
||||
the ask that does it is `draw_inner`'s own second one, in the box the
|
||||
answer chose out of the box the parent gave. `redraw` had a third ask for
|
||||
this, in `ActiveData::region` with `decided` forced on, and it is gone:
|
||||
applying the widget's alignment to an already-placed box places its
|
||||
content twice (`d8ae9c3`), and not re-placing it is simpler than undoing
|
||||
the placement.
|
||||
- **What a drawing depends on is the lengths of its box**, so the same
|
||||
lengths somewhere else is the same question and a local redraw of a widget
|
||||
whose box has moved but not changed length asks it. The box it asks in is
|
||||
the one its parent gave, which is where its parent put it, so nothing has
|
||||
to reproduce a position. That replaces the whole-box comparison and the
|
||||
region-node exception beside it: a region node drawn at its offer
|
||||
(`aea878d`) and a widget placed twice (`d8ae9c3`) were both that pair
|
||||
disagreeing about which box a redraw is in. **A box in pixels is not
|
||||
compared with a composed one anywhere any more** -- see the section of
|
||||
that name.
|
||||
- A retained drawing can be reused only when its `Holds` interval contains the
|
||||
new pixel box on both axes, its parent node is unchanged, its region-node
|
||||
choice matches the retained structure, and the widget is clean. A valid
|
||||
@@ -777,11 +878,15 @@ pixels and `Holds`. Decided with Bryan on 2026-09-15.
|
||||
rounding, so it scales with the step: at `1/64` that was 0.016 px, enough
|
||||
to move a box, and at `1/1024` it is a thousandth of a pixel. Range is
|
||||
+/-2.1M px and conversion to `f32` is exact to 16,384 px.
|
||||
- **What the fuzzers ask for is a step per level of nesting**, which is two
|
||||
for these trees. Truncating made each of those steps a whole one rather
|
||||
than half of one; it did not add a level. Traced on 2026-09-16 to the same
|
||||
box reached two ways, each rounding where the other does not -- not
|
||||
accumulation, and not one place. Two of them are fixed in `bdab558`:
|
||||
- **What the fuzzers ask for is two steps, and they are both positions
|
||||
now.** It was a step per level of nesting -- two for these trees -- and
|
||||
threading a box in pixels down the draw did not reduce the count: one step
|
||||
fails the 400-seed shrinker on `resize-size`, seeds 384 and 162, by
|
||||
0.002 px, while passing the 100-seed oracle (tried 2026-09-17). Truncating
|
||||
made each step a whole one rather than half of one; it did not add a level.
|
||||
Traced on 2026-09-16 to the same box reached two ways, each rounding where
|
||||
the other does not -- not accumulation, and not one place. Two of them are
|
||||
fixed in `bdab558`:
|
||||
- `Scroll` wrote a box it had been given back out as its own length in
|
||||
pixels. Centring a part in `rel 1` lands a step from centring it in
|
||||
`px 900`, because `a(x - y)` and `ax - ay` do not round alike. Content
|
||||
@@ -791,41 +896,34 @@ pixels and `Holds`. Decided with Bryan on 2026-09-15.
|
||||
share's rounding along the row. A position is now the fixed parts before
|
||||
it, exact, plus one rounded share. Two hundred equal shares of a 1000 px
|
||||
row ended at 999.999 and now end at 1000.
|
||||
What is left is a box centred in a fraction of its parent against the same
|
||||
box centred in its own pixels, one step per level between them. Closing it
|
||||
means alignment resolved in pixels everywhere -- which costs the retained
|
||||
resize path, since it is the fractional form that re-centres a subtree
|
||||
without redrawing it. Not worth it at a thousandth of a pixel.
|
||||
- `Holds::through` inverts `px + rel * box`, which drops a step, so the answer
|
||||
is an interval even for a single length: inverting the length alone gives a
|
||||
point that need not contain the box the part was drawn in. What it allows
|
||||
for has two parts and they are not the same shape.
|
||||
- **The two routes to a length** -- composed down the chain against
|
||||
measured against the window -- get two half steps either side, since
|
||||
`45a7176` made the walk round once instead of once a level. It was three
|
||||
while composing rounded per level, and four before that, which was
|
||||
overshoot. Tightening it has never moved one of the rig's twenty-five
|
||||
work counters, so slack here buys no reuse.
|
||||
What is left is two positions, and `AGREE_STEPS` stays **2** for them. One
|
||||
is a box centred in a fraction of its parent against the same box centred
|
||||
in its own pixels, which is what shows at zero tolerance -- 0.001 px on a
|
||||
handful of seeds. Closing it means alignment resolved in pixels
|
||||
everywhere, which costs the retained resize path, since it is the
|
||||
fractional form that re-centres a subtree without redrawing it. The other
|
||||
is `AxisRemap::Scale` re-expressing a part as a fraction of a box that
|
||||
changed length, which is the one the `resize-size` seeds above need.
|
||||
Neither is worth a thousandth of a pixel.
|
||||
- **`Holds::through` is the exact preimage of `px + floor(rel * box)`**, as
|
||||
of `32542d0`. `floor(rel * B) >= lo - px` is `rel * B >= (lo - px) << REL`
|
||||
and `floor(rel * B) <= hi - px` is `rel * B < (hi - px + 1) << REL`, which
|
||||
is two `div_toward`s once the sign of `rel` has said which bound comes from
|
||||
which end. No `ROUTES`, no `way_in`, and the whole of a box -- with or
|
||||
without pixels taken off it -- maps back to itself.
|
||||
|
||||
**One half step further is arithmetically available and deliberately not
|
||||
taken.** The `Holds` assertion is quiet at one, and the whole-of-a-box
|
||||
case becomes an exact identity. What stops it is shrinker seed 220 on
|
||||
`reorder` -- diagnosed on 2026-09-16 and written up under "The offer
|
||||
chain breaks at a region node" below. It is not a rounding question.
|
||||
- **The multiply on the way in** gets one more step at the top of the range
|
||||
and nothing at the bottom, since truncation only ever drops. The whole of
|
||||
a box has no multiply in it, however many pixels are added to it, and
|
||||
allowing for one there compounded a step per level down a chain of
|
||||
widgets each taking the whole of its parent (`60367d8`).
|
||||
The answer is still an interval where this range is a single length,
|
||||
because a floor is not invertible: many boxes give one length. What is gone
|
||||
is the *allowance*, which was there because the box arrived by a second
|
||||
route, composed down the move chain against threaded down the draw. There
|
||||
is one route now; see "A box in pixels is one multiply from its parent's".
|
||||
|
||||
**It cannot go to zero while a length reached two ways is two numbers.**
|
||||
The range has to contain the box a drawing was made in, which the `Holds`
|
||||
assertion in `draw_at` checks, and it must not contain a box the drawing
|
||||
does not hold for, which the warm-against-cold oracle checks. Those two
|
||||
only coincide where composition is exact -- the `i64` item under "Next" --
|
||||
and shrinking this allowance is the test of whether that worked. Too wide
|
||||
is the unsound side of it. **Run the generated cases in debug**, since the
|
||||
assertion is compiled out of every release run.
|
||||
**The range has to contain the box a drawing was made in**, which the
|
||||
`Holds` assertion in `draw_at` checks, and it must not contain a box the
|
||||
drawing does not hold for, which the warm-against-cold oracle checks.
|
||||
Being the preimage rather than a margin is what makes those two one
|
||||
statement instead of a trade-off between them. **Run the generated cases
|
||||
in debug**, since the assertion is compiled out of every release run.
|
||||
- **The window is not a move entry** (`5b78002`). It was one, holding the
|
||||
output in pixels, so composing through it zeroed every `rel` below and the
|
||||
shader's `rel * window.dim` and the CPU's final multiply by the output
|
||||
@@ -840,87 +938,74 @@ pixels and `Holds`. Decided with Bryan on 2026-09-15.
|
||||
as floats and are put on the grid where they arrive. `Vec2` stays what the
|
||||
GPU and the platform speak; `PxVec2` is what layout decides in.
|
||||
|
||||
## The offer chain breaks at a region node
|
||||
## A box in pixels is one multiply from its parent's
|
||||
|
||||
**The open defect at `5f16617`**, and the reason `Holds::through` keeps a
|
||||
half step it does not need. It is the fourth thing this session to come down
|
||||
to the offer against the placed box, after `aea878d`, `d8ae9c3` and the
|
||||
`Stack`/`Pad` overrides in `2bc6bdf`.
|
||||
The rule the offer chain broke, and the one layout now rests on. `ActiveData`
|
||||
keeps a widget's box as *lengths of its parent's box* -- `given_len`, and
|
||||
`offer_len` for the box it was first asked about -- `DrawInfo` carries the
|
||||
pixel lengths themselves (`px`, `offered_px`), and a draw threads them down
|
||||
one `Len::to_px` at a time. Two steps a level: the box its parent gave it,
|
||||
and then the part of that box its own answer placed its drawing in, which
|
||||
`placed_lens` states once for both `placed_box` and the walk.
|
||||
`Painter::px_size` and `px_len` read that value rather than composing
|
||||
anything, and `UiRenderState::asked_px` takes the same steps back up the
|
||||
parent chain when a local redraw starts part-way down the tree. Neither
|
||||
chain has a coordinate frame in it, which is what makes both immune to a
|
||||
region node; warm and cold reach every length by the same expression, so
|
||||
they agree by construction rather than by allowance.
|
||||
|
||||
`offered_region` composes a widget's offer through its ancestors' offers,
|
||||
which is the invariant. Where the parent is a **region node** it does not:
|
||||
it falls back to `UiRegion::FULL`, and `redraw` then resolves that against
|
||||
the node's slot entry -- which holds the node's **placed** box. So a widget
|
||||
under a region node is re-asked in the box its parent *placed* that node in,
|
||||
not the box its parent *offered* it.
|
||||
Three things follow, and all three are why it was worth doing:
|
||||
|
||||
That is only wrong where the two differ, and `Scroll` is where they do: it
|
||||
offers its content the viewport and places it in a box as long as the
|
||||
content. So the offer becomes a function of the answer being re-derived, and
|
||||
the old answer confirms itself.
|
||||
- **`Holds::through` is the exact preimage of `px + floor(rel * box)`** --
|
||||
two integer divisions, no `ROUTES`, no `way_in`, and the whole of a box
|
||||
mapping back to itself. It can be exact because there is one route to a
|
||||
length rather than two.
|
||||
- **A local redraw asks the widget in the box its parent gave it**, and only
|
||||
where that box is as long as the one it was offered; any other box is a
|
||||
different question and its parent's to ask, with the mark left on. The
|
||||
`decided`/region-node exception and the third ask in `redraw` went with
|
||||
that: `draw_inner` places the answer inside the given box itself, which is
|
||||
the ask that leaves the widget where its parent put it.
|
||||
- **Symbolic regions are for the GPU, hit testing and remaps alone.**
|
||||
`Moves::resolve` is the only walk left and it is the one the vertex shader
|
||||
does. `wide.rs`, `Moves::compose`, `Moves::size_of`, `px_of`, `px_region`,
|
||||
`offered_region` and `slot_wide` are all gone, 252 lines of `core/` net.
|
||||
What their rounding is worth is cosmetic below a step, and nothing layout
|
||||
decides turns on it.
|
||||
|
||||
Reproduced from the shrinker's ten-widget reduction:
|
||||
**`px` is not stored on `ActiveData`, deliberately.** The plan for this said
|
||||
to keep it there; it cannot be kept. A resize every widget's `Holds` admits
|
||||
redraws nothing, which is the point of `Holds`, so a stored pixel length
|
||||
would then be stale on every widget in the tree with nothing on it to say
|
||||
so -- and the next local redraw of any of them would lay out for the old
|
||||
window. Refreshing it costs a walk down every reused subtree on the resize
|
||||
path; `asked_px` costs a walk up the parent chain only where a widget is
|
||||
already being redrawn, and the mean chain here is 2.8 levels.
|
||||
|
||||
Scroll(X) { Pad as a region node { Span(X) {
|
||||
Branch(threshold 213), Span(X){ wrapped text, one-line text } } } }
|
||||
What it was, kept because it is the shape of the mistake. `offered_region`
|
||||
composed a widget's offer through its ancestors' offers, which is the
|
||||
invariant, except where the parent was a **region node**: there it fell back
|
||||
to `UiRegion::FULL`, and `redraw` resolved that against the node's slot
|
||||
entry, which holds the node's **placed** box. `Scroll` is where placed and
|
||||
offered differ -- it offers its content the viewport and places it in a box
|
||||
as long as the content -- so everything under such a node was re-asked at
|
||||
the content's width, the texts kept the widths that width produced, the
|
||||
content stayed the length those widths make, and the old answer confirmed
|
||||
itself. On the shrinker's reduction of seed 220 at 900x1200, `reorder` left
|
||||
widget 0 290.27px out; marking every widget dirty converged warm onto cold,
|
||||
which is what said the retained path and not the arithmetic was at fault.
|
||||
It is pinned as
|
||||
`unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered`,
|
||||
the shrinker's ten widgets transcribed: with no padding between the node and
|
||||
the span inside it, nothing distinguished the placed box from the offer and
|
||||
the span settled itself. **Both halves had to be in place at once** -- the
|
||||
old chain with the old allowance passes seed 220, and the old chain with the
|
||||
exact `through` is what fails it.
|
||||
|
||||
```sh
|
||||
SHRINK_SEED=220 SHRINK_DEPTH=5 SHRINK_CASE=reorder \
|
||||
cargo test --release --test shrink -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Traced, at a 900x1200 window, with `reorder` rotating both spans:
|
||||
|
||||
- Each order **does** settle, and to a different answer: `[0,1]` gives the
|
||||
pad a 2080.18px box and `[1,0]` gives it 1789.90. So this is not an
|
||||
unsettled tree; both are fixed points.
|
||||
- Cold, rotated: the texts are asked at 900 and 594.06, shape, and report
|
||||
305.94 and 583.97, so their span reports 889.90.
|
||||
- Warm, rotated: the outer span is re-asked in **2080.18**, the pre-rotation
|
||||
content box. Its texts are asked at 305.94 and 977.42, both reuse retained
|
||||
answers without reshaping, and the span reports 1283.36 -- the same as
|
||||
before the rotation, so no answer changed and nothing propagated up to the
|
||||
`Scroll`. Widget 0 lands 290.27px out and the `Branch` picks its other
|
||||
subtree.
|
||||
- Marking every widget dirty converges warm onto cold, which is what says
|
||||
the retained path and not the arithmetic is at fault.
|
||||
|
||||
**The fix is to stop recomputing the offer at all** (agreed with Bryan,
|
||||
2026-09-16). `ActiveData::offer` is in the parent *widget's* coordinates and
|
||||
`ActiveData::region` is in the parent *node's*, so "was this node placed
|
||||
where it was offered" is not a comparison either field can answer alone --
|
||||
and neither answer is needed. By the retained contract a drawing and its
|
||||
answer depend on the box's pixel *lengths* only; checked against every
|
||||
widget, the pixel reads are `px_len` and `px_size` and nothing reads a
|
||||
position. The offer's symbolic position was only ever an input to its pixel
|
||||
size, and that the parent already knew when it asked. So:
|
||||
|
||||
- Thread the placed box's pixel size down beside `offered_px` -- one
|
||||
expression per level, `local.size().to_px(parent_px)` -- and keep both on
|
||||
`ActiveData`. Cold layout computes them the same way, so warm and cold
|
||||
agree by construction rather than by allowance.
|
||||
- `at_offer` becomes `px == offered_px`. Equal lengths at a different
|
||||
position no longer escalate: the widget draws at `region`, which is the
|
||||
`aea878d` fix without the second draw. A region node whose lengths differ
|
||||
still draws twice, and the first box only needs the right length.
|
||||
- `redraw` reads the stored `offered_px`. `offered_region`, `px_of`,
|
||||
`px_region`, `Moves::compose`/`size_of`, `slot_wide` and `wide.rs` go; a
|
||||
narrow `resolve` stays for `window_region`, which is hit testing and not a
|
||||
layout decision.
|
||||
- `remap_subtree`'s `Translate` keeps lengths, so stored sizes stay valid
|
||||
at no cost on the hot path; `Scale` is the resize path and recomputes
|
||||
them down the subtree from the same expression.
|
||||
- `Holds::through` becomes the exact preimage of the truncating map
|
||||
`px + floor(rel * B)`: two integer divisions, no `ROUTES`, no `way_in`,
|
||||
and the whole-of-a-box case is the identity. The allowance existed only
|
||||
because the box arrived by a second route.
|
||||
|
||||
Symbolic regions then exist for the GPU and for remaps alone, and their
|
||||
rounding is cosmetic below a step. That is the separation the fixed-point
|
||||
work kept reaching for. **Do not widen the grid instead**: every failure in
|
||||
this document is one value reached by two expressions, sitting exactly on a
|
||||
boundary because the boundary was defined by the same value coming back the
|
||||
other way, and no precision shrinks a residue that is the whole distance.
|
||||
**Do not widen the grid instead**: every failure in this document is one
|
||||
value reached by two expressions, sitting exactly on a boundary because the
|
||||
boundary was defined by the same value coming back the other way, and no
|
||||
precision shrinks a residue that is the whole distance.
|
||||
|
||||
## The leftover boundary
|
||||
|
||||
@@ -1097,23 +1182,17 @@ Queued from this work, in order:
|
||||
child draws in, or only what the parent reports for it.
|
||||
|
||||
- ~~**Compose in `i64` and narrow only when storing**~~ -- **done in
|
||||
`45a7176`**, and what it bought is below. The rest of this item is kept
|
||||
because the measurement in it is what stopped the work being aimed at the
|
||||
wrong thing.
|
||||
`45a7176`, and then deleted with the composition it was for.** There is
|
||||
nothing left to compose: a box in pixels is threaded down the draw one
|
||||
multiply a level, so `wide.rs` went with `Moves::compose` and
|
||||
`Moves::size_of`. What the widening bought while it was there --
|
||||
`Holds::through`'s allowance from three half steps to two, and the whole
|
||||
of a box mapping back to one step rather than one a level -- is what
|
||||
exactness now gives outright. The rest of this item is kept because the
|
||||
measurement in it is what stopped the work being aimed at the wrong thing.
|
||||
|
||||
The range inside a single operation is already protected -- `Fixed::mul`
|
||||
widens to `i64` for the product, `Holds::through` does all of its
|
||||
arithmetic in `i64` and narrows once at the end, and `div_toward` takes
|
||||
`i64`. What is not protected is *precision across a chain*:
|
||||
`UiSpan::within` composes a box through its parent with four multiplies and
|
||||
every one comes back to the `i32` grid before the next starts, which is the
|
||||
"step per level of nesting" in "Fixed point" and what forces
|
||||
`Holds::through`'s two-route allowance. A `Wide<SHIFT>` held through a
|
||||
composition and narrowed when it is written to `ActiveData` or a primitive
|
||||
would round once instead of once a level, at no cost in stored size.
|
||||
|
||||
**But it cannot make equal shares the same number of screen pixels,
|
||||
because the CPU's grid is not what puts them on the screen.**
|
||||
**Widening the grid cannot make equal shares the same number of screen
|
||||
pixels, because the CPU's grid is not what puts them on the screen.**
|
||||
`prelude.wgsl` decodes the raw `rel`/`px` counts into `f32`, walks the move
|
||||
chain in floats -- with the comment saying so, "what has to hold is that
|
||||
this agrees with itself frame to frame, not that it matches the CPU to the
|
||||
@@ -1126,39 +1205,20 @@ Queued from this work, in order:
|
||||
gaps are exactly ten pixels on both sides of the truncation change. So the
|
||||
screen invariant is already as good as integers allow: a length in pixels
|
||||
is that many pixels, and equal shares differ by at most one whole pixel
|
||||
because three equal integers cannot sum to 1000. Layout's own one-or-two
|
||||
step spread is below what the shader can express, and shows only where it
|
||||
because three equal integers cannot sum to 1000. Layout's own one-step
|
||||
spread is below what the shader can express, and shows only where it
|
||||
pushes a value across the `floor` -- which is what moved `tabs`'s corner
|
||||
arcs by a pixel at `08c9d5a`.
|
||||
|
||||
What it bought is **layout's own decisions**: `Holds::through`'s two-route
|
||||
allowance went from three half steps to two, and the whole of a box now
|
||||
maps back to one step rather than one step per level of nesting.
|
||||
|
||||
**And it made layout faster.** 1,840M instructions and 735M cycles against
|
||||
1,908M and 760M -- 3.6% and 3.3% -- medians of 25 with all twenty-five
|
||||
work counters identical. `5f16617` is where the win is: a draw already
|
||||
descends past every move entry on its way in, so `DrawInfo` carries what
|
||||
the slot composes to and a widget's own box is a select rather than a walk
|
||||
back up a mean of 2.8 levels, eight hundred times a frame. Composing on
|
||||
the fine grid alone cost 1,880M and 755M, so the widening was paid for
|
||||
twice over by not doing the walk.
|
||||
|
||||
**One narrow step is left in that path.** `Painter` still composes a
|
||||
child's region into its own on the grid before asking for it in pixels.
|
||||
Taking it out needs the child's region as its parent stated it, which
|
||||
`draw_inner` is not handed -- it receives that box already composed into
|
||||
the slot's frame, because that is the form `ActiveData` and the GPU want.
|
||||
Worth doing after the region-node offer defect below, which is what pins
|
||||
`Holds::through` at the moment.
|
||||
Two things paid for the widening and are the reason to keep them: a
|
||||
*length* composes on its own in two multiplies a level rather than the
|
||||
four both ends cost, since where the parent sits falls out of the
|
||||
difference; and the pixel term takes its fraction on the ordinary grid, so
|
||||
only the fraction itself -- the part that compounds multiplicatively --
|
||||
needs the room. A third, skipping a parent that spans its own box, cost
|
||||
18M instructions and is not there, the same verdict `UiSpan::within`'s
|
||||
short-circuits got.
|
||||
**And composing it down the draw rather than back up the chain made layout
|
||||
faster**, which is the part that survives: `5f16617` took 1,840M
|
||||
instructions and 735M cycles against 1,908M and 760M -- 3.6% and 3.3%,
|
||||
medians of 25 with all twenty-five work counters identical -- because a
|
||||
draw already descends past every move entry on its way in, so a widget's
|
||||
own box was a select rather than a walk back up a mean of 2.8 levels,
|
||||
eight hundred times a frame. Threading the pixels themselves took the
|
||||
select out too, at no cost to cold layout and 9-13% off the retained
|
||||
paths; the numbers are under "Performance".
|
||||
|
||||
Other queued work, in dependency order:
|
||||
|
||||
|
||||
Reference in new issue
Block a user