diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 47387d9..2d945a6 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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>`.