Bring the handoff to what the last session shipped
`OrthoSize` was deleted in `9d8415d` and the handoff still described it as restored; the surviving rule is `Painter::ruled`, and the circularity lesson is restated without the enum's names. `HOLDS_EPSILON_PX` went with `39e4ca2`, the clipping `debug_assert` the alignment section asked for landed as `95fb4f9` and is narrower than it asked, the glyph conversion queued under "Next" landed as `11c55bc`, and `LAYOUT.md`'s stale sections are §4, §5 and density rather than §2. Also records why the grid costs 1.5x what floats did, from the disassembly rather than from theory: `RegionRemap::apply_span` is 352 instructions with two 64-bit `idiv`s in it, because a fixed-point rounding is code where a float's is hardware, integer division has no vector form, and saturation breaks the pairing the `f32` `Vec2` had. And the finding that answers the queued clamp item's open half: a `Max` may not take a `leftover`, because a cap must read the report a rule otherwise makes moot, and a share puts the division into the same equation -- the multiple-fixed-point failure seed 13 already punished once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f83be016ba
commit
388a6a060d
1 file changed
+98
-37
+98
-37
@@ -25,13 +25,17 @@ the intersection of the ranges its children induce. This contract is trusted.
|
||||
A widget that declares an incorrect range is a defective widget; Iris does not
|
||||
add defensive work to recover optimizations from a false declaration.
|
||||
|
||||
`f437495` restores explicit `Span::ortho(OrthoSize::{Children, Full})` sizing.
|
||||
`Children` remains the default and preserves the old conservative behavior:
|
||||
the largest fixed orthogonal length is reported, while any relative or
|
||||
`leftover` child makes the span report `leftover`. `Full` reports exactly
|
||||
`Len::rel(1.0)`. It does not read the children's orthogonal sizes, but their
|
||||
`Holds` ranges still propagate through final-box drawing, so a resize
|
||||
repositions them without redrawing when their own contracts permit it.
|
||||
`9d8415d` deletes `OrthoSize`, which `f437495` had restored as an explicit
|
||||
`Span::ortho(OrthoSize::{Children, Full})`. It was the rule beside a widget
|
||||
written a second time: a span across itself is as long as its longest child
|
||||
*unless a rule already says how long it is*, and `Painter::ruled(axis)` is how
|
||||
it asks which case it is in. Under a rule it does not read its children across
|
||||
that axis at all -- the answer is not wanted, and reading one is what would
|
||||
make its size depend on theirs. Not under one, the largest fixed orthogonal
|
||||
length is reported and any relative or `leftover` child makes it report
|
||||
`leftover`, which is the old conservative behavior. Either way the children's
|
||||
`Holds` ranges propagate through final-box drawing, so a resize repositions
|
||||
them without redrawing when their own contracts permit it.
|
||||
|
||||
`71c9c39` replaces the public `Painter::place` distinction with an opt-in
|
||||
widget property. `.region_node()` gives a widget one independently movable
|
||||
@@ -43,11 +47,13 @@ content once as its convenient default; raw `Scroll::new` respects the
|
||||
caller's choice, and the property can be disabled later without breaking
|
||||
scrolling. `Span` and `Align` do not add nodes to their children.
|
||||
|
||||
Do not change `Children` to select the pixel-longest arbitrary `Len` at the
|
||||
span's current width. A fixed child and a relative child can create multiple
|
||||
Do not make a span choose its orthogonal size by comparing children in pixels
|
||||
at its current width. A fixed child and a relative child can create multiple
|
||||
self-sizing fixed points; generated seed 13 settled differently warm and cold
|
||||
under that attempted implementation. A `Holds` interval says where an already
|
||||
chosen answer stays valid, but cannot make that circular choice unique.
|
||||
chosen answer stays valid, but cannot make that circular choice unique. The
|
||||
same circularity is what a `leftover` cap would put into `SizeRule::Max` --
|
||||
see the clamp item under "Next".
|
||||
|
||||
The implementation also fixes three counterexamples found while finishing the
|
||||
rewrite:
|
||||
@@ -87,7 +93,8 @@ pin those rules. Seeds 10 and 86 are now in the ordinary generated set.
|
||||
point; re-run that before quoting it again.
|
||||
- `tabs`, `view`, `minimal`, `text` and `random` render byte-identical at
|
||||
1920x1200 across the whole fixed-point sequence.
|
||||
- `tests/drift.rs` passed its 20,000-move exactness check in release mode.
|
||||
- `tests/cases/drift.rs` passed its 20,000-move exactness check in release
|
||||
mode.
|
||||
- The seeded `random` example, live-resized from 1920x1200 to 1280x800, is
|
||||
byte-identical to a cold 1280x800 render. Both PNGs hash to
|
||||
`1d397c57b9914a2e596fa907029bc6aab4629e4d74deb0715e81325c703bcdb3`.
|
||||
@@ -140,13 +147,39 @@ child ends up on, which puts the scroll phase's counters back exactly where
|
||||
they were -- 4 widget draws, 12 draw requests.
|
||||
|
||||
What is left is per-operation cost, not more work: the `many` phase does 125
|
||||
widget draws against 121 before, and takes 1.5x as long. The arithmetic is
|
||||
the difference -- an `i64` multiply and a rounding branch where there was an
|
||||
`f32` multiply, and an `i64` division in the remap. Three were taken out
|
||||
after measuring (`11c55bc`, `4f5e27c`, and halving the divisions in
|
||||
`Holds::through`); `RegionRemap::apply_span` is still a fifth of the phase,
|
||||
and the rest is spread thin. Measure with `perf stat -e instructions:u`
|
||||
rather than the clock, which varies 2x here.
|
||||
widget draws against 121 before, and takes 1.5x as long. Three roundings and
|
||||
divisions were taken out after measuring (`11c55bc`, `4f5e27c`, and halving
|
||||
the divisions in `Holds::through`); `RegionRemap::apply_span` is still a
|
||||
fifth of the phase, and the rest is spread thin. Measure with `perf stat -e
|
||||
instructions:u` rather than the clock, which varies 2x here.
|
||||
|
||||
**Why the grid is slower than floats, from the disassembly at `4f5e27c`.**
|
||||
`RegionRemap::apply_span` -- two scalars, every branch -- is 352
|
||||
instructions, of which two are 64-bit `idiv`, six `imul`, and about sixty
|
||||
are jumps and `cmov`s over twenty-eight compares. The same arithmetic in
|
||||
`f32` is a couple of dozen SSE instructions. The difference is in three
|
||||
places, and none of them is the grid being a worse idea:
|
||||
|
||||
- **A float's rounding is free and a fixed-point one is code.** `Fixed::mul`
|
||||
widens to `i64`, multiplies, adds a half step, shifts back, and saturates
|
||||
into `i32` -- roughly eight instructions and a sign branch where `mulss`
|
||||
is one instruction with the rounding in hardware.
|
||||
- **Integer division is the slowest instruction on the core**, is not
|
||||
pipelined, and has no vector form; `divss` is pipelined and vectorises.
|
||||
`div_round` also needs the remainder and its sign. This is why removing
|
||||
divisions is where the wins have been, and why the remaining ones are
|
||||
worth structural effort rather than micro-optimisation.
|
||||
- **Saturation costs what floats get from infinities**, and it breaks the
|
||||
pairing. Adding four `i32` lanes with saturation is a dozen SSE ops
|
||||
(`pcmpgtd`/`paddd`/`pandn`/`psrad`/`pxor`/`por`) against one `addps`; and
|
||||
the widening multiply cannot stay in a vector register at all, since there
|
||||
is no packed 64-bit `imul` here, so lanes that an `f32` `Vec2` did at once
|
||||
serialise.
|
||||
|
||||
"Fixed point is faster" is a rule from machines without an FPU. On this one
|
||||
the grid buys exactness, and exactness is what the warm-against-cold oracle
|
||||
demands -- so the cost is the price of the property, not a defect to chase
|
||||
back to 1.0x.
|
||||
|
||||
One thing tried and reverted, recorded so it is not tried again: short-
|
||||
circuiting `apply_scalar` where the fraction is nought or one. Those are not
|
||||
@@ -237,11 +270,11 @@ it.
|
||||
round: `39e4ca2` translates where `from.len() == to.len()` and scales only
|
||||
where it must, which is what made the shrinker's `resize` case agree
|
||||
exactly. This inverts the float-era rule, and the measurements behind that
|
||||
rule are why `tests/drift.rs` exists: in floats, offsetting both ends of a
|
||||
span shortened that fixture's row by 0.071 px over 20,000 moves and 0.712
|
||||
over 200,000, while re-expressing fractions stayed exact. 20,000 moves is
|
||||
five minutes of scrolling at 60Hz. On the grid the drift is gone either
|
||||
way, and `tests/drift.rs` pins that it stays gone.
|
||||
rule are why `tests/cases/drift.rs` exists: in floats, offsetting both ends
|
||||
of a span shortened that fixture's row by 0.071 px over 20,000 moves and
|
||||
0.712 over 200,000, while re-expressing fractions stayed exact. 20,000
|
||||
moves is five minutes of scrolling at 60Hz. On the grid the drift is gone
|
||||
either way, and `tests/cases/drift.rs` pins that it stays gone.
|
||||
|
||||
## Built-in alignment
|
||||
|
||||
@@ -289,7 +322,7 @@ rule, `declared_box` hands the child its aligned box directly -- one draw, no
|
||||
move. Where the size is only known after drawing, the widget is **re-asked in
|
||||
its placed box**, with its alignment forced to the near edge on the second ask
|
||||
so it terminates; that ask goes through `try_reuse`, which moves by
|
||||
recomposing, which `tests/drift.rs` pins as exact. That deletes
|
||||
recomposing, which `tests/cases/drift.rs` pins as exact. That deletes
|
||||
`ActiveData::placed` and `shift_subtree`. The cost is a second ask for a
|
||||
measured widget that is not near-aligned, which is exactly what `Aligned` cost
|
||||
before this work.
|
||||
@@ -329,8 +362,14 @@ length the box takes and the drawing holds for that length alone.
|
||||
`Stack` gives every child the box its sizing child defines, through the new
|
||||
`Painter::box_of`, for the same reason.
|
||||
|
||||
A `debug_assert` that a reported non-leftover size does not exceed the box it
|
||||
drew in would have caught both immediately, and is still worth adding.
|
||||
`95fb4f9` adds the `debug_assert` this asked for, and narrows it to what is
|
||||
actually true: a widget that **set a mask** this draw must report inside the
|
||||
box it drew in. As "a reported size does not exceed its box" it fires on
|
||||
ordinary overflow instead -- measured, a hundred fuzzer trees produce
|
||||
thousands, every one a text too tall for the box it was offered, which is
|
||||
what a text is meant to say. The same commit fixed `Masked`, which was
|
||||
passing its inner's size up; `tests/cases/scroll.rs` has a clipping widget
|
||||
that reports its content, so the assertion is itself covered.
|
||||
|
||||
## Fixed point
|
||||
|
||||
@@ -395,10 +434,14 @@ layouts are stable and the pixels are identical either way, which is why only
|
||||
the warm-against-cold oracle could see it.
|
||||
|
||||
**A structural decision may not be taken where boxes structurally land.** The
|
||||
fix is not a tolerant comparison -- that is what generated seed 16 punished --
|
||||
but a boundary moved by `HOLDS_EPSILON_PX` of room, with the validity range
|
||||
split exactly at the moved boundary. What it gives up is a share of under a
|
||||
twentieth of a pixel. `tests/unsettled.rs`'s
|
||||
fix was not a tolerant comparison -- that is what generated seed 16 punished
|
||||
-- but a boundary moved by `HOLDS_EPSILON_PX` of room, with the validity range
|
||||
split exactly at the moved boundary. **The move and the constant are gone
|
||||
since `39e4ca2`**: on the grid the box a parent hands back and the sum of what
|
||||
the children asked for are whole counts of the same step, and both routes land
|
||||
on the same count, so the boundary needs no margin. What the section still
|
||||
records is why a decision may not be taken on a hair's breadth, and the
|
||||
regression that pins it. `tests/cases/unsettled.rs`'s
|
||||
`a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over` is the
|
||||
six-widget regression, shrunk from 266; it needs the span above the one that
|
||||
divides, because without a box composed through it both trees round the same
|
||||
@@ -478,8 +521,10 @@ instruction totals.
|
||||
|
||||
The next small LAYOUT.md §2 item is `LazySpan`. Region nodes now cover the
|
||||
independently movable-subtree use case; do not restore a separate
|
||||
child-placement API. `docs/LAYOUT.md` §2 is stale: it still describes
|
||||
`Painter::place`, which `71c9c39` replaced.
|
||||
child-placement API. `2d86058` brought `docs/LAYOUT.md` §2 and §3 to what
|
||||
shipped; **§4, §5 and the density section are still stale** -- they name
|
||||
`Painter::place`, `SetSize`, `desired_width`, `apply_rest`, `Len::dp`,
|
||||
`Aligned` and `MaxSize`, none of which exist.
|
||||
|
||||
**Built-in alignment and size goes on #18 rather than after it** (Bryan,
|
||||
2026-09-15: #18 is unreviewed and already large enough that most lines get read
|
||||
@@ -490,11 +535,6 @@ the same `Holds` contract and box chain.
|
||||
|
||||
Queued from this work, in order:
|
||||
|
||||
- `Painter::glyphs` converts four `f32`s to `Px` per glyph, every frame that
|
||||
draws it. A `GlyphEntry` holding `Px` would convert once, when the glyph is
|
||||
rasterised. It is the largest single thing left in the layout profile after
|
||||
`InstanceList::push`.
|
||||
|
||||
- `Scroll` should take a direction rather than one axis: vertical, horizontal,
|
||||
or both. Reporting `LEFTOVER` on both axes is already the right shape for it.
|
||||
- Restore `max_width`/`max_height` as `SizeRule::{Min, Max, Clamp}`. `8220a78`
|
||||
@@ -507,6 +547,27 @@ Queued from this work, in order:
|
||||
longer needs moving off where boxes land -- see "Fixed point" -- but it
|
||||
does need both sides of the comparison to be `Px`.
|
||||
|
||||
**A cap may not contain `leftover`** (found 2026-09-16, answering whether
|
||||
the parent could resolve one). `Exact` works with a share in it because it
|
||||
makes the widget's report moot -- `ruled()` is true, nothing reads the
|
||||
report, and the parent divides a weight that does not depend on what it
|
||||
divides. A cap must read the report, so rule and report are in one
|
||||
equation, and a share puts the division into it too: a child capped at one
|
||||
share contributes its drawn pixels to the row's total while it fits and a
|
||||
weight once it does not, which moves the room, which moves the share. The
|
||||
two assignments are each self-consistent, which is the multiple-fixed-point
|
||||
failure that generated seed 13 punished for orthogonal sizing -- and
|
||||
resolving it the way flexbox does, by freezing violated children and
|
||||
dividing again, costs an ask per round where the retained contract allows
|
||||
one offer per child. `min(report, cap)` is also not a `Len`: `Len` is a sum
|
||||
of the three parts, and the smaller of two of them is not.
|
||||
|
||||
So a cap takes pixels and a fraction and no share, which is `UiScalar`'s
|
||||
shape (`core/src/orientation/pos.rs`) -- either that type or a `Len`
|
||||
without `leftover`. Awaiting Bryan: whether to split the type, and whether
|
||||
a `Max` narrows the box the child draws in or only what the parent reports
|
||||
for it.
|
||||
|
||||
Other queued work, in dependency order:
|
||||
|
||||
- `UiRenderState` behind `Rc<RefCell<_>>`.
|
||||
|
||||
Reference in new issue
Block a user