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:
iris-aiandClaude Opus 5 committed 2026-09-17 00:21:29 -04:00
1 parent 9a26862bce
commit 4e75019b9a
1 file changed
+247 -187
+247 -187
View File
@@ -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: