Record the agreed plan after #18: opt-in slots, a rewalk stretch, Span saying Scale

And the open question beside it: a slot that carries a box rather than a
translation, which would make a move and a stretch of a Scale subtree
one write each. To be measured with chain_cost before it is chosen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Fable 5.1 committed 2026-09-14 11:39:45 -04:00
1 parent c683e62e4f
commit 5959d58e44
1 file changed
+67 -29
+67 -29
View File
@@ -44,35 +44,72 @@ resolve(slot)`, so anything that rewrites a region owes that slot a zero --
draws. And a stretch is only expressible out of a box with a relative extent;
a fixed length holds its parts as offsets from its start and keeps no fraction.
### The open question on #18, and the numbers for it
### The plan after #18, agreed with the owner on 2026-09-14
The owner proposed, and I agree with, **opt-in chaining that `Scroll` would
choose** plus **recalculating rather than repositioning** when a region cannot
be moved. Neither is implemented. What is settled and what is not:
Three changes, done together as one slice on top of #18, because each decides
the shape of the others.
- **Recalculating rather than repositioning is in**, as the `Remap`
retirement above. It costs the per-axis carry: a box that changed length on
one axis and not the other is now redrawn rather than remapped. Six of
`tabs`'s fourteen relocations and five of `text`'s sixteen, and one extra
redraw per frame on `replace_cost` -- 354,310,889 instructions against
354,272,387, noise.
- **Opt-in chaining is not**, and the design question is *who may opt in*. A
widget can only move its subtree if its descendants chain through it, so
opting in has to be done by whoever performs the move. My recommendation on
the PR: any container that re-places a child after drawing it (`Span`,
`Aligned`, `Scroll` all do), not only `Scroll`. That keeps the chain 2-4
deep instead of full tree depth, and keeps the slot write for ordinary
re-placement.
- Giving **every** widget a slot, which is what #18 does and what I read §2 to
say, is the thing to change: it puts a primitive's walk at full tree depth
for no benefit, since almost every slot is zero.
- **Slots are opt-in, created by the container that re-places children.** A
draw call asks for one; a widget that did not asks nothing and composes into
its nearest ancestor's slot. #18 gives every widget a slot, which puts a
primitive's walk at full tree depth for no benefit, since almost every slot
is zero. `Span`, `Scroll` and `Aligned` re-place children after drawing
them and are the ones that ask. That keeps the chain 2-4 deep, which
`chain_cost` measured as free.
- **A stretch is a rewalk from the widget whose box changed, not a flat pass.**
#18's `stretch` takes the top widget's old and new window boxes and rewrites
every descendant against that one pair, which works only while a composite
fraction of the top box is recoverable from a window rel -- and a
fixed-length box destroys that for everything beneath it, whatever is
stored. Instead: keep the parent-relative region -- the argument
`widget_within`, `primitive_within` and `set_mask` already receive -- on
each widget, primitive and mask, and recompose it against the new box. For
each child, compute its new window box from its local; the same length on
both axes is one slot write where it has a slot and a recomposition where it
does not, and a changed length asks that child's `on_resize` and recurses or
redraws. Nothing is inverted, so `stretchable`, `UiScalar::stretch`'s
division and its debug assertion all go. It also fixes two things #18 gets
away with only because nothing but leaves says `Scale`: `try_reuse` asks
`on_resize` of the top widget alone, and `stretch` never rewrites
`Mask::region`.
- **Containers say `Scale`.** `Span` on both axes: it hands every child
`UiSpan::FULL` on the orthogonal axis, and its aligned-axis placement is
written in fractions and offsets of its own box, so the children's local
regions do not change when the box does. A child's *length* can, if one
without a hint reports differently after a redraw, and that reaches the
span through `size_deps` as it does today. The general rule: a widget is
`Scale` on an axis unless its draw reads the pixel length of its box on that
axis, which is text. The default stays `Redraw`.
**Open, and the owner's to decide: a slot that carries a box instead of a
translation.** Everything under a slot would store its region relative to
that box, and the shader would compose the chain with `within` instead of
adding deltas. A move and a stretch of a `Scale` subtree are then both one
slot write: no rewalk over primitives, no stored locals, and `Translate` comes
free, since a glyph at an abs offset from its row's start stays there when
the row grows. The CPU work on a resize becomes asking `on_resize` down the
widgets and redrawing the ones that say so -- one ask for a transcript row
against a few hundred glyph rewrites. It replaces #18's chain rather than
extending it, and it contradicts LAYOUT.md §2's line that slots carry
translation only. Before choosing it, run `chain_cost` with a region per slot
in place of a `Vec2`: the per-level cost was measured as the dependent load
rather than the arithmetic, so the extra lerps should be free, but that is an
assumption until the rig says so.
What is settled either way: recalculating rather than repositioning is in, as
the `Remap` retirement above. It costs the per-axis carry: a box that changed
length on one axis and not the other is redrawn rather than remapped. Six of
`tabs`'s fourteen relocations and five of `text`'s sixteen, all single-
primitive leaves, and one extra redraw per frame on `replace_cost` --
354,310,889 instructions against 354,272,387, noise. The rewalk restores that
carry, and generalises it to a subtree.
Measured, so the next attempt is compared rather than argued:
| rig | what it says |
| --- | --- |
| `tests/chain_cost.rs` | GPU pass time by chain depth at 200k instances: free to depth 8 (+5%), then ~3 us per level -- +42.6% at 16, +221% at 64. Each step is a storage load addressed by the previous one, so it is the chaining that costs, not the arithmetic at a level; a slot carrying a whole region would measure the same. |
| `tests/replace_cost.rs` | Instructions per frame re-placing 200 rows: 1.98M writing each row's slot, 2.38M rewriting its regions, 7.13M redrawing it. A load for `perf`, not a check. |
| `tests/chain_cost.rs` | GPU pass time by chain depth at 200k instances: free to depth 8 (+5%), then ~3 us per level -- +42.6% at 16, +221% at 64. Each step is a storage load addressed by the previous one, so it is the chaining that costs, not the arithmetic at a level; a slot carrying a whole region should measure the same, but has not been. |
| `tests/replace_cost.rs` | Instructions per frame re-placing 200 rows: 1.98M writing each row's slot, 2.38M rewriting its regions, 7.13M redrawing it. A load for `perf`, not a check. Five primitives per row; the regime that decides whether the chain is worth it is a transcript row of a few hundred glyphs, so re-run it with 200 characters of text per row before concluding. |
| `tests/draw_cost.rs` | Pre-existing: what recording a frame costs on the CPU by layer count. |
Irrelevant at an example's couple of hundred primitives; a transcript's glyphs
@@ -151,10 +188,11 @@ Other standing instructions from the owner:
## The next slice
**Nothing, until the owner answers the opt-in question on #18** -- the shape of
the slot set decides what `set_child_offset` even is, so building it first
risks writing it twice. After that: `set_child_offset` and `LazySpan` to finish
LAYOUT.md §2, then built-in alignment.
**The plan above, as one slice on #18**: opt-in slots, the rewalk stretch
over stored locals, and `Span` saying `Scale` -- after the owner has answered
whether a slot carries a box, since that decides whether the locals are stored
beside window regions or *are* the regions. Then `set_child_offset` and
`LazySpan` to finish LAYOUT.md §2, then built-in alignment.
The archive is not a patch here: it writes `Widget::draw` against
`painter.set_size`, which #16 replaced with a returned `Size`, and it writes
@@ -163,9 +201,9 @@ Recreate on today's `Len` and let the dp slice follow.
Still in the target, roughly in dependency order:
- **The rest of the position chain** (LAYOUT.md §2), on top of #18:
`set_child_offset` for a container that moves its children as a group, and
`LazySpan`.
- **The rest of the position chain** (LAYOUT.md §2), on top of the slice
above: `set_child_offset` for a container that moves its children as a
group, and `LazySpan`.
- **Built-in alignment, and probably size**, after the chain rather than
before it: the owner reordered the two on 2026-09-14. Reproduced in the
harness -- `.width(rel(0.5))` inside a `Dir::DOWN` span reports 200 of 400