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; 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. 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 Three changes, done together as one slice on top of #18, because each decides
choose** plus **recalculating rather than repositioning** when a region cannot the shape of the others.
be moved. Neither is implemented. What is settled and what is not:
- **Recalculating rather than repositioning is in**, as the `Remap` - **Slots are opt-in, created by the container that re-places children.** A
retirement above. It costs the per-axis carry: a box that changed length on draw call asks for one; a widget that did not asks nothing and composes into
one axis and not the other is now redrawn rather than remapped. Six of its nearest ancestor's slot. #18 gives every widget a slot, which puts a
`tabs`'s fourteen relocations and five of `text`'s sixteen, and one extra primitive's walk at full tree depth for no benefit, since almost every slot
redraw per frame on `replace_cost` -- 354,310,889 instructions against is zero. `Span`, `Scroll` and `Aligned` re-place children after drawing
354,272,387, noise. them and are the ones that ask. That keeps the chain 2-4 deep, which
- **Opt-in chaining is not**, and the design question is *who may opt in*. A `chain_cost` measured as free.
widget can only move its subtree if its descendants chain through it, so - **A stretch is a rewalk from the widget whose box changed, not a flat pass.**
opting in has to be done by whoever performs the move. My recommendation on #18's `stretch` takes the top widget's old and new window boxes and rewrites
the PR: any container that re-places a child after drawing it (`Span`, every descendant against that one pair, which works only while a composite
`Aligned`, `Scroll` all do), not only `Scroll`. That keeps the chain 2-4 fraction of the top box is recoverable from a window rel -- and a
deep instead of full tree depth, and keeps the slot write for ordinary fixed-length box destroys that for everything beneath it, whatever is
re-placement. stored. Instead: keep the parent-relative region -- the argument
- Giving **every** widget a slot, which is what #18 does and what I read §2 to `widget_within`, `primitive_within` and `set_mask` already receive -- on
say, is the thing to change: it puts a primitive's walk at full tree depth each widget, primitive and mask, and recompose it against the new box. For
for no benefit, since almost every slot is zero. 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: Measured, so the next attempt is compared rather than argued:
| rig | what it says | | 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/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. | | `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. | | `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 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 ## The next slice
**Nothing, until the owner answers the opt-in question on #18** -- the shape of **The plan above, as one slice on #18**: opt-in slots, the rewalk stretch
the slot set decides what `set_child_offset` even is, so building it first over stored locals, and `Span` saying `Scale` -- after the owner has answered
risks writing it twice. After that: `set_child_offset` and `LazySpan` to finish whether a slot carries a box, since that decides whether the locals are stored
LAYOUT.md §2, then built-in alignment. 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 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 `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: Still in the target, roughly in dependency order:
- **The rest of the position chain** (LAYOUT.md §2), on top of #18: - **The rest of the position chain** (LAYOUT.md §2), on top of the slice
`set_child_offset` for a container that moves its children as a group, and above: `set_child_offset` for a container that moves its children as a
`LazySpan`. group, and `LazySpan`.
- **Built-in alignment, and probably size**, after the chain rather than - **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 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 harness -- `.width(rel(0.5))` inside a `Dir::DOWN` span reports 200 of 400