@qxuken/kui 0.0.0-stage → 0.1.0-alpha.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13313 -0
- package/LICENSE +21 -0
- package/README.md +336 -3
- package/docs/adr/0001-accessibility-as-data.md +414 -0
- package/docs/adr/0002-keyboard-focus-as-data.md +452 -0
- package/docs/adr/0003-modal-surfaces.md +248 -0
- package/docs/adr/0004-multi-window.md +626 -0
- package/docs/adr/0005-the-paint-vocabulary.md +601 -0
- package/docs/adr/0006-c-abi-versioning.md +257 -0
- package/docs/adr/0007-composite-keyboard-patterns.md +350 -0
- package/docs/adr/0008-live-regions-and-announcements.md +379 -0
- package/docs/adr/0009-press-drag-release-into-a-popup.md +318 -0
- package/docs/adr/0010-a-segment-primitive.md +410 -0
- package/docs/adr/0011-keys-bubble-to-the-enclosing-sink.md +258 -0
- package/docs/adr/0012-the-exit-budget.md +411 -0
- package/docs/adr/0013-effects-as-data.md +197 -0
- package/docs/adr/0014-slots-an-extension-fills-in-place.md +411 -0
- package/docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md +559 -0
- package/docs/adr/0016-caching-against-the-last-frame.md +351 -0
- package/docs/adr/0017-selection-as-a-scope.md +696 -0
- package/docs/adr/0018-a-menu-bar-the-app-declares.md +264 -0
- package/docs/adr/0019-a-theme-derived-from-appearance-and-accent.md +382 -0
- package/docs/adr/0020-the-surface-the-schema-does-not-cover.md +351 -0
- package/docs/adr/0021-one-subject-per-example.md +729 -0
- package/docs/adr/0022-focus-regions.md +205 -0
- package/docs/adr/0023-layers-stack-in-the-order-they-open.md +376 -0
- package/docs/adr/0024-the-devtools-are-the-cores.md +425 -0
- package/docs/adr/0025-the-image-is-the-canvas.md +507 -0
- package/docs/adr/0026-hit-testing-by-shape.md +197 -0
- package/docs/adr/0027-tokens-beside-the-theme.md +521 -0
- package/docs/adr/0028-derived-tokens.md +572 -0
- package/docs/adr/0029-a-selection-follows-the-pointer-past-the-edge.md +543 -0
- package/docs/adr/0030-the-standard-menus-the-runner-keeps.md +292 -0
- package/docs/adr/0031-a-drop-zone-is-a-row-and-the-files-are-an-event.md +409 -0
- package/docs/adr/0032-a-devtools-tab-mounts-a-slot.md +480 -0
- package/docs/adr/0033-a-table-is-a-column-whose-cells-align.md +377 -0
- package/docs/adr/0034-stock-controls-over-the-roles.md +192 -0
- package/docs/adr/0035-a-rounded-background-is-joined-by-meeting.md +134 -0
- package/docs/adr/0036-an-event-handler-gets-its-window.md +142 -0
- package/docs/adr/0037-a-family-is-named.md +134 -0
- package/docs/adr/0038-a-scroll-gesture-latches-its-target.md +188 -0
- package/docs/adr/0039-a-tutorial-is-a-sequence.md +139 -0
- package/encoder.js +1282 -0
- package/howto.md +1774 -0
- package/index.d.ts +5162 -0
- package/index.js +1414 -0
- package/jsx-dev-runtime.js +1 -0
- package/jsx-runtime.d.ts +905 -0
- package/jsx-runtime.js +45 -0
- package/native.cjs +118 -0
- package/package.json +51 -6
- package/prebuilds/darwin-arm64/kui_node.node +0 -0
- package/prebuilds/darwin-x64/kui_node.node +0 -0
- package/prebuilds/linux-arm64/kui_node.node +0 -0
- package/prebuilds/linux-x64/kui_node.node +0 -0
- package/prebuilds/win32-x64/kui_node.node +0 -0
- package/props.md +636 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: accepted
|
|
3
|
+
date: 2026-09-04
|
|
4
|
+
amended: 2026-09-07
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# The paint vocabulary: group opacity and shadows in, gradients out, exit animations designed
|
|
8
|
+
|
|
9
|
+
> **Amended 2026-09-04, twice.** Exit animations are built, as
|
|
10
|
+
> `crates/kui-core/src/depart.rs` and one `exit` schema row. The design
|
|
11
|
+
> below held; two things changed, both recorded in
|
|
12
|
+
> [Amendment: exit animations, built](#amendment-exit-animations-built) —
|
|
13
|
+
> where the departing subtree is copied *from*, and the answer to the open
|
|
14
|
+
> question about `animating()`. And **rounded clipping is built too**: the
|
|
15
|
+
> consequence below prices it as "a bigger bill than either of these" on an
|
|
16
|
+
> estimate, and
|
|
17
|
+
> [Amendment: rounded clipping, built](#amendment-rounded-clipping-built)
|
|
18
|
+
> replaces the estimate with the measurement, which is about 1% of a frame
|
|
19
|
+
> that never clips. Both amendments are at the end of this document.
|
|
20
|
+
>
|
|
21
|
+
> **Amended again 2026-09-04:** the exit amendment's "No corpus scene, and
|
|
22
|
+
> why" is superseded — there is one, and the two corpus-protocol additions
|
|
23
|
+
> it declined (a clock step, a per-frame builder in Node) were built. See
|
|
24
|
+
> [the superseding note](#no-corpus-scene-and-why--superseded-there-is-one-backlog-b3).
|
|
25
|
+
>
|
|
26
|
+
> **Revisited 2026-09-07** by
|
|
27
|
+
> [ADR 0012](0012-the-exit-budget.md), which is what the placeholder policy
|
|
28
|
+
> below was waiting for. Two of its three answers hold and are now measured
|
|
29
|
+
> rather than assumed: `exit` stays opt-in, and there is still no duration
|
|
30
|
+
> cap. The third — what happens at the budget's edge — does not: the split
|
|
31
|
+
> is positional and its size depends on nodes-per-row, so a frame's
|
|
32
|
+
> departures will animate whole or not at all. `animating()` itself was the
|
|
33
|
+
> wrong thing to have worried about, and 0012 says so with the numbers.
|
|
34
|
+
|
|
35
|
+
`crates/kui-core/src/display.rs` was the whole renderer contract — fill,
|
|
36
|
+
border, four radii, glyph, image — and three things a real UI wants were
|
|
37
|
+
missing from it: **group opacity**, **shadows** and **gradients**. A fourth,
|
|
38
|
+
**exit animations**, was blocked on the first: `Enter` can fade a node's own
|
|
39
|
+
`bg` but not a subtree, so a panel could fade in and never out, and the
|
|
40
|
+
README said so ("a removed node vanishes at once").
|
|
41
|
+
|
|
42
|
+
We shipped **group opacity** as a `VisualStyle::opacity` multiplied into
|
|
43
|
+
every quad a subtree emits, and **drop shadows** as a new
|
|
44
|
+
`QuadKind::Shadow` carrying a blur radius, rendered by one more branch in
|
|
45
|
+
the existing SDF. We are **not** shipping gradients in v0. We are **not**
|
|
46
|
+
building exit animations yet, and the design below is what building them
|
|
47
|
+
would mean, written down so the next person starts from the shape of the
|
|
48
|
+
problem rather than from scratch.
|
|
49
|
+
|
|
50
|
+
## Context
|
|
51
|
+
|
|
52
|
+
- **The quad struct is hot.** `benches/frame.rs` runs 10k-node frames;
|
|
53
|
+
anything added to `Quad` is paid 20,000 times a frame. That argues for
|
|
54
|
+
fields that many quads use, and against fields one kind uses — and it is
|
|
55
|
+
why `opacity` is not on the quad at all.
|
|
56
|
+
- **Every addition costs four bindings.** A plain `PROPS` row is nearly
|
|
57
|
+
free: Lua looks it up by snake\_name, Node parses it by kind, the TS types
|
|
58
|
+
and `docs/props.md` regenerate, and only `KuiSpec` needs a field, which
|
|
59
|
+
`every_schema_prop_has_a_c_counterpart` then forces. A *composite* is
|
|
60
|
+
hand-written four times. Six plain rows beat one composite here.
|
|
61
|
+
- **The frame model is immediate.** `Tree` is rebuilt every frame from what
|
|
62
|
+
the view declares; the only thing that survives a frame is keyed retained
|
|
63
|
+
state (`AnimStore`, `ScrollStore`, `EditStore`), and none of it holds a
|
|
64
|
+
*node*. A node the view stops declaring is gone before `finish_frame`
|
|
65
|
+
runs. That is the whole difficulty of exit animations.
|
|
66
|
+
- **`Enter` already proves the pattern.** A first-sighted node's slots start
|
|
67
|
+
somewhere other than where the view declares them, stated as data. Its
|
|
68
|
+
mirror image — where a *last*-sighted node's slots end — needs the node to
|
|
69
|
+
still exist, which is a different kind of change.
|
|
70
|
+
|
|
71
|
+
## Decision
|
|
72
|
+
|
|
73
|
+
### Group opacity: a spec slot, multiplied at emission
|
|
74
|
+
|
|
75
|
+
`VisualStyle::opacity` (0..=1, default 1) is inherited multiplicatively down
|
|
76
|
+
the tree in `finish_frame`'s existing pass 1, beside the clip propagation
|
|
77
|
+
and gated the same way (`any_opacity`, so a frame that fades nothing pays
|
|
78
|
+
one branch). `emit_node` records where its quads begin and multiplies the
|
|
79
|
+
inherited product into `color.a` and `border_color.a` of everything it
|
|
80
|
+
pushed. Scrollbars and the focus ring fade with the node they belong to.
|
|
81
|
+
|
|
82
|
+
Consequences we accept:
|
|
83
|
+
|
|
84
|
+
- **It is not a composite.** Overlapping pieces of one subtree double-blend
|
|
85
|
+
where a real offscreen composite would not — the classic seam through a
|
|
86
|
+
faded stack of translucent boxes. Compositing means a render target per
|
|
87
|
+
faded subtree, a second pass, and a `DisplayList` that stops being one
|
|
88
|
+
flat list; that is a renderer redesign, not a paint prop. The doc comment
|
|
89
|
+
and `docs/props.md` say this outright rather than letting someone discover
|
|
90
|
+
it.
|
|
91
|
+
- **Nothing but paint changes.** A faded subtree still lays out, still takes
|
|
92
|
+
clicks, and is still read out by assistive technology — CSS's rule for
|
|
93
|
+
`opacity: 0`, and the one that makes a fade-*out* usable at all, since the
|
|
94
|
+
thing being faded out is normally still interactive on the way.
|
|
95
|
+
- **It animates.** `Slot::Opacity` joins the tween slots, `Keyframe::opacity`
|
|
96
|
+
and `Enter::opacity` join their parse tables, so `enter: { opacity: 0 }`
|
|
97
|
+
with a `transition` fades a whole panel in. `KuiEnter` / `KuiKeyframe`
|
|
98
|
+
gain a `set` bit each.
|
|
99
|
+
- **C needs `opacity_set`.** A zeroed `KuiSpec` is the schema default, and
|
|
100
|
+
the default is 1, so 0 cannot double as "unset" the way every color field
|
|
101
|
+
does. The bit is the price of keeping a fully transparent subtree
|
|
102
|
+
expressible from C.
|
|
103
|
+
|
|
104
|
+
### Shadows: a `QuadKind`, not a nine-slice
|
|
105
|
+
|
|
106
|
+
`QuadKind::Shadow` plus one `blur: f32` on `Quad`. The core emits the
|
|
107
|
+
shadow as the node's rect moved by `dx`/`dy`, grown by `spread`, inflated by
|
|
108
|
+
`blur` on every side; the shader insets by `blur` again to recover the
|
|
109
|
+
shape, and ramps the edge over it with the `sd_rounded_box` it already has
|
|
110
|
+
for rounded rects. Five plain schema rows (`shadowColor`, `shadowBlur`,
|
|
111
|
+
`shadowX`, `shadowY`, `shadowSpread`) and two tween slots (the geometry as
|
|
112
|
+
one four-vector, the color as another).
|
|
113
|
+
|
|
114
|
+
Against the nine-slice: it needs a blurred texture per (radius, blur) pair
|
|
115
|
+
in the glyph atlas, an atlas eviction policy for something that is not a
|
|
116
|
+
glyph, nine quads where this needs one, and seams to get right at
|
|
117
|
+
fractional scales — all to avoid about fifteen lines of WGSL. The ramp is a
|
|
118
|
+
smoothstep rather than a true Gaussian, which at UI blur radii does not
|
|
119
|
+
read; if it ever does, the fix is a better curve in one shader function,
|
|
120
|
+
not a different architecture.
|
|
121
|
+
|
|
122
|
+
Scope kept deliberately small: **outer shadows only** (no inset), **one per
|
|
123
|
+
node**, and the shape is **not knocked out of the middle** — CSS clips the
|
|
124
|
+
box out of its own shadow, and a translucent background here shows the
|
|
125
|
+
shadow through itself. Each of those is a note in the doc comment, not a
|
|
126
|
+
silent surprise.
|
|
127
|
+
|
|
128
|
+
### Gradients: out of scope for v0
|
|
129
|
+
|
|
130
|
+
Two colors and a direction sound like one more `PROPS` row and are not. A
|
|
131
|
+
gradient needs a stop list (so: a `Keyframes`-shaped parse, in five
|
|
132
|
+
bindings), a type (linear, radial, conic), a geometry (angle or two points,
|
|
133
|
+
in the node's own space), and a decision about interpolation space that
|
|
134
|
+
looks arbitrary in every choice. On the quad it is at minimum a second
|
|
135
|
+
color, a direction, and a discriminant; via the atlas it is a texture per
|
|
136
|
+
distinct gradient. Neither is a paint prop's worth of work, and the
|
|
137
|
+
workaround — an image, or a stack of solids — exists. The README says v0
|
|
138
|
+
has no gradients rather than leaving a reader to infer it.
|
|
139
|
+
|
|
140
|
+
### Exit animations: the design, not the build
|
|
141
|
+
|
|
142
|
+
Not built. What building it means:
|
|
143
|
+
|
|
144
|
+
1. **A departing node is retained, not re-declared.** `finish_frame` diffs
|
|
145
|
+
this frame's keys against the last frame's. A key that was present, is
|
|
146
|
+
absent now, and whose last spec declared both a `transition` and an
|
|
147
|
+
`exit` becomes a *departing subtree*: its specs, contents and laid-out
|
|
148
|
+
rects are copied out of the old `Tree` into a `DepartStore` keyed by
|
|
149
|
+
`Key`, alongside the frame time it left.
|
|
150
|
+
2. **It is replayed, not re-laid-out.** On every later frame the departing
|
|
151
|
+
subtree is appended after the live tree with its rects frozen at the
|
|
152
|
+
moment it left, its `exit` slots eased from where they were toward what
|
|
153
|
+
`exit` declares (`Enter` in reverse — `dx`/`dy`, size, `bg`, `radius`,
|
|
154
|
+
`opacity`), and emitted like a float: on top, escaping ancestor clips,
|
|
155
|
+
because its ancestors may be gone. Freezing is the key simplification —
|
|
156
|
+
a dying node must not fight the live layout for space, and CSS's own
|
|
157
|
+
exit transitions work the same way (the element is out of flow the
|
|
158
|
+
moment it is removed).
|
|
159
|
+
3. **It is inert.** No hit region, no place in the Tab ring, no access row —
|
|
160
|
+
it is a picture of a node, not a node. A departing subtree that held
|
|
161
|
+
focus has already given it up, by the same path a removed modal does.
|
|
162
|
+
4. **It is dropped when its transition ends**, or when the same key comes
|
|
163
|
+
back (the live node wins immediately and the ghost is discarded, so a
|
|
164
|
+
toast dismissed and re-shown does not double), or after a frame budget —
|
|
165
|
+
`AnimStore`'s existing `EVICT_AFTER_FRAMES` sweep is the model, and the
|
|
166
|
+
store must be bounded, since a view that removes a thousand rows at once
|
|
167
|
+
must not retain a thousand subtrees indefinitely.
|
|
168
|
+
5. **What it costs.** `Tree` grows a way to be sliced and copied, which it
|
|
169
|
+
does not have today; `finish_frame` grows a key diff over the previous
|
|
170
|
+
frame (a `FxHashSet` of the last frame's keys, or a generation stamp per
|
|
171
|
+
key in the existing map); and every emission pass has to tolerate nodes
|
|
172
|
+
whose parents do not exist. The spec grows one `exit: Option<Enter>`
|
|
173
|
+
field, which needs no new parse code — an exit *is* an `Enter`, read the
|
|
174
|
+
other way — so the four bindings cost one row.
|
|
175
|
+
|
|
176
|
+
The open question, and the reason this is written down rather than
|
|
177
|
+
built: **what a departing subtree does to `animating()`**. The driver
|
|
178
|
+
schedules frames while anything is mid-flight; a ghost keeps that true for
|
|
179
|
+
its whole duration, which is correct but means removing a node now costs
|
|
180
|
+
frames. That is fine for a dialog and wrong for a list that drops a
|
|
181
|
+
thousand rows, and the policy — cap the ghosts, cap the duration, or make
|
|
182
|
+
`exit` opt-in per node as designed here — should be decided with a real
|
|
183
|
+
view in front of us. **Answered in the amendment below: all three of the
|
|
184
|
+
first and none of the second.**
|
|
185
|
+
|
|
186
|
+
## Considered options
|
|
187
|
+
|
|
188
|
+
- **Opacity as a `Quad` field** rather than pre-multiplied at emission.
|
|
189
|
+
Rejected: four bytes on the hot struct and a multiply in every backend, to
|
|
190
|
+
express something the alpha channel already expresses. Pre-multiplying
|
|
191
|
+
costs one pass over the quads a *faded* node emitted, and nothing at all
|
|
192
|
+
otherwise.
|
|
193
|
+
- **Opacity as a true composite** (render target per faded subtree).
|
|
194
|
+
Rejected for v0: it is the correct rendering and the wrong amount of
|
|
195
|
+
work, and it would make `DisplayList` a tree instead of a list, which is
|
|
196
|
+
the one thing the renderer boundary promises it is not.
|
|
197
|
+
- **Shadows as a nine-slice** out of the glyph atlas. Rejected above.
|
|
198
|
+
- **Shadows as a composite prop** (`shadow = { color, blur, x, y, spread }`).
|
|
199
|
+
Rejected: five plain rows are free in three of four bindings; one
|
|
200
|
+
composite is hand-written in all four and needs a conformance scene to
|
|
201
|
+
keep them honest. The Rust builder still offers `shadow(Shadow { .. })`
|
|
202
|
+
for the whole thing at once.
|
|
203
|
+
- **Exit animations by keeping the whole previous `Tree`** and diffing
|
|
204
|
+
frame to frame. Rejected as the starting point: it doubles the retained
|
|
205
|
+
frame state to serve the rare case, where a keyed `DepartStore` pays only
|
|
206
|
+
for nodes that actually declared an `exit`.
|
|
207
|
+
|
|
208
|
+
## Consequences
|
|
209
|
+
|
|
210
|
+
- `Quad` grows one `f32` (104 → 108 bytes) and `QuadKind` one variant. Both
|
|
211
|
+
are ABI: `KuiQuad` mirrors the layout field for field, so the C example's
|
|
212
|
+
digest mirror and the JS decoder shifted with it. Measured on the 10k-node
|
|
213
|
+
benches, the growth is not visible above run-to-run noise, and a 10k-node
|
|
214
|
+
frame where *every* node casts a shadow under a faded root
|
|
215
|
+
(`frame_10k_rects_with_shadows_and_opacity`, 20k quads) costs about what
|
|
216
|
+
the plain 10k-quad frame costs.
|
|
217
|
+
- The `kinds` line of the conformance report grew a sixth column, and
|
|
218
|
+
`Expect` a `shadows` count; the `layout` scene's card now carries both new
|
|
219
|
+
props, so all four bindings reproduce them or fail.
|
|
220
|
+
- `docs/props.md` gains six rows and `KuiSpec` seven fields (appended, so
|
|
221
|
+
the C ABI stays additive).
|
|
222
|
+
- Rounded clipping is not addressed here: `Quad::clip` is a rect, so a
|
|
223
|
+
rounded scroll container does not round its children. Four more floats on
|
|
224
|
+
the hot struct plus a second SDF per fragment looked like a bigger bill
|
|
225
|
+
than either of these, and it was left in the backlog with that price on
|
|
226
|
+
it. **Superseded by
|
|
227
|
+
[Amendment: rounded clipping, built](#amendment-rounded-clipping-built)**,
|
|
228
|
+
which measured the bill instead of estimating it: the four floats are
|
|
229
|
+
invisible, the plumbing around them is about 1% of a frame that never
|
|
230
|
+
clips, and it is built.
|
|
231
|
+
|
|
232
|
+
## Amendment: exit animations, built
|
|
233
|
+
|
|
234
|
+
Built as designed, with two corrections and one answer. The view they were
|
|
235
|
+
decided against is `examples/rust/toasts.rs`, which now has an `exit`
|
|
236
|
+
on every toast and on the panel, and a "clear" button that drops the whole
|
|
237
|
+
stack in one frame.
|
|
238
|
+
|
|
239
|
+
### The answer: `exit` is opt-in, the store is bounded, and the duration is the view's
|
|
240
|
+
|
|
241
|
+
> **Partly superseded by [ADR 0012](0012-the-exit-budget.md)
|
|
242
|
+
> (2026-09-07)**, which measured this policy against a view that reaches
|
|
243
|
+
> the boundary — something no view in this repo had ever done. The first
|
|
244
|
+
> and third bullets stand. The second does not: "what is refused vanishes
|
|
245
|
+
> at once, which is *exactly* what a node with no `exit` does" is true of a
|
|
246
|
+
> subtree and false of a view, because a 1000-row list gets its first 512
|
|
247
|
+
> rows animated and the rest dropped, which is a third behaviour neither
|
|
248
|
+
> policy produces. The claim in the paragraph below it — that the 1000-row
|
|
249
|
+
> list "animates its first 512 nodes' worth of rows out and drops the rest
|
|
250
|
+
> at once, having said so" — is exactly right, and is the defect.
|
|
251
|
+
|
|
252
|
+
The open question was what a ghost does to `animating()`, with three
|
|
253
|
+
candidate policies. The answer is that two of them are needed and the third
|
|
254
|
+
is not:
|
|
255
|
+
|
|
256
|
+
- **`exit` is opt-in per node, and needs a `transition` as well.** This is
|
|
257
|
+
the primary control and it costs nothing: a node that declares neither is
|
|
258
|
+
never a candidate, is never copied, and never keeps a frame owed. It also
|
|
259
|
+
puts the decision where the knowledge is — a toast wants to leave
|
|
260
|
+
visibly, a table row does not, and only the view knows which it is
|
|
261
|
+
looking at.
|
|
262
|
+
- **The store is bounded, over nodes.** `depart::MAX_NODES` is 512;
|
|
263
|
+
a departure that would take the store past it is refused, and what is
|
|
264
|
+
refused vanishes at once — which is *exactly* what a node with no `exit`
|
|
265
|
+
does, so the failure mode is the old behaviour rather than a wrong one.
|
|
266
|
+
The budget is over nodes rather than subtrees because a node is what a
|
|
267
|
+
replayed frame pays for: one dialog of forty nodes and forty toasts of
|
|
268
|
+
one are the same bill.
|
|
269
|
+
Silently, though, "only some of my rows animated" reads as a bug, so the
|
|
270
|
+
first refusal of a frame raises an `exit-budget` warning through the
|
|
271
|
+
ordinary diagnostics path, with the sentence that fixes it (put `exit` on
|
|
272
|
+
the list, not on every row).
|
|
273
|
+
- **The duration is not capped.** A ghost runs for its own node's
|
|
274
|
+
`transition`, and a view that declares a ten-second transition gets ten
|
|
275
|
+
seconds of frames — which is already true of every live transition, so a
|
|
276
|
+
cap here would be a second, inconsistent rule for the same declaration.
|
|
277
|
+
What *is* bounded is the store's patience with a driver that stops
|
|
278
|
+
advancing its clock: an unreplayed ghost is swept after
|
|
279
|
+
`EVICT_AFTER_FRAMES`, the same backstop `AnimStore` keeps. And a driver
|
|
280
|
+
with no clock at all gets no ghosts: every transition snaps without one,
|
|
281
|
+
and an exit that snaps is the plain disappearance it always was.
|
|
282
|
+
|
|
283
|
+
So `animating()` stays honestly true while a ghost is in flight, and the
|
|
284
|
+
frames that costs are bounded by 512 nodes times whatever the view asked
|
|
285
|
+
for. The 1000-row list of the original worry animates its first 512 nodes'
|
|
286
|
+
worth of rows out and drops the rest at once, having said so.
|
|
287
|
+
|
|
288
|
+
### Correction: the previous frame is kept, not re-copied
|
|
289
|
+
|
|
290
|
+
Step 1 above says `finish_frame` copies the departing subtree "out of the
|
|
291
|
+
old `Tree`". There is no old `Tree` at that point: `begin_frame` clears it
|
|
292
|
+
before the view builds, so by the time a frame can notice a node missing,
|
|
293
|
+
the node is gone. The "Considered options" list rejected *keeping the whole
|
|
294
|
+
previous `Tree`* as the starting point, on the grounds that it doubles the
|
|
295
|
+
retained frame state to serve the rare case.
|
|
296
|
+
|
|
297
|
+
It does not have to be doubled unconditionally. `Core` holds two tree
|
|
298
|
+
buffers and `begin_frame` **swaps** them instead of clearing one — but only
|
|
299
|
+
when the frame that just ended declared an `exit` somewhere (`any_exit`,
|
|
300
|
+
alongside `any_clip`, `any_float` and the rest). A frame with no exits
|
|
301
|
+
clears the spare, so a stale tree can never be diffed against, and the cost
|
|
302
|
+
of the feature to an app that does not use it is one bool. To an app that
|
|
303
|
+
does, it is an allocation that already existed, reused a frame later
|
|
304
|
+
instead of immediately.
|
|
305
|
+
|
|
306
|
+
The text list goes with it. A text node holds a `TextId` into the frame's
|
|
307
|
+
text list, which is rebuilt every frame, so a subtree copied out of the
|
|
308
|
+
previous tree carries ids that only the previous list can resolve —
|
|
309
|
+
resolving them against the current one draws whatever text happens to sit at
|
|
310
|
+
that index. `TextSystem` keeps its previous list on the same condition and
|
|
311
|
+
in the same way, and the ghost stores the shaped buffer's cache key rather
|
|
312
|
+
than the id, so what it replays is its own text for as long as the text
|
|
313
|
+
cache still has it (and nothing, rather than someone else's, once it does
|
|
314
|
+
not).
|
|
315
|
+
|
|
316
|
+
The diff itself is then two flat `Vec<Key>` compared. Almost every frame
|
|
317
|
+
declares the same nodes in the same order, so that comparison is the whole
|
|
318
|
+
diff; only a frame that actually changed shape pays for the set of watched
|
|
319
|
+
keys (the previous frame's exit-declaring roots, plus the roots already
|
|
320
|
+
departing) and the per-node walk, which is one AND against a 64-bit
|
|
321
|
+
membership mask and a hash lookup only for the few keys that collide with
|
|
322
|
+
it.
|
|
323
|
+
|
|
324
|
+
### Correction: a ghost eases itself
|
|
325
|
+
|
|
326
|
+
Step 2 says the exit slots are "eased from where they were", which reads
|
|
327
|
+
like an `AnimStore` tween. It is one lerp instead. Nothing can retarget a
|
|
328
|
+
ghost — the view has stopped talking about it, so its target cannot change
|
|
329
|
+
— and a retained tween exists to survive retargets. The ghost keeps the
|
|
330
|
+
clock reading it left at and the duration and easing its last spec
|
|
331
|
+
declared, and interpolates. Spring easings sample as ease-out, the
|
|
332
|
+
substitution `AnimStore::sample` already makes for a keyframed slot, for
|
|
333
|
+
the same reason: there is no leg to carry momentum across.
|
|
334
|
+
|
|
335
|
+
One limit falls out of freezing: `exit`'s `width` / `height` resize the
|
|
336
|
+
departing *root's* own box, and nothing inside it moves, because re-laying
|
|
337
|
+
out a frozen subtree is the one thing the design rules out. It is a note in
|
|
338
|
+
the doc comment and in `docs/props.md` rather than a surprise.
|
|
339
|
+
|
|
340
|
+
### Correction: a ghost keeps its place (2026-09-05)
|
|
341
|
+
|
|
342
|
+
Step 2 says a ghost is "emitted like a float: on top", and the build did
|
|
343
|
+
that — every ghost after every live quad. It looked wrong the first time a
|
|
344
|
+
float left from under another float: the toasts example's side panel is
|
|
345
|
+
declared before its latency HUD, so the HUD is painted over the panel, and
|
|
346
|
+
for the 420ms of its exit the panel's ghost jumped *over* the HUD. There is
|
|
347
|
+
no z-index in kui — floats stack in tree order — and a picture of a node
|
|
348
|
+
that changes layer for its last few frames is not a picture of that node.
|
|
349
|
+
|
|
350
|
+
A ghost now keeps the place its node painted in. The departure records a
|
|
351
|
+
`Place`: which pass the root was painted in (in flow, or among the floats)
|
|
352
|
+
and the key of the first node painted after it in that pass that the frame
|
|
353
|
+
noticing it gone still declares. The passes paint each ghost just under
|
|
354
|
+
that node when they reach it, and a ghost whose node is gone ends its pass
|
|
355
|
+
— after the in-flow content it was among, still under every float. When
|
|
356
|
+
the node a ghost sits under departs too, the ghost takes the place that
|
|
357
|
+
node's ghost takes, behind it, so neighbours leaving one after the other
|
|
358
|
+
keep their order. Unclipped stays: the ancestors may still be gone.
|
|
359
|
+
|
|
360
|
+
What it needs is a fragment of the previous frame's paint order, built
|
|
361
|
+
once on a frame that has a departure: the in-float bit per node and, per
|
|
362
|
+
pass, the next still-declared node at or after each index. That is two
|
|
363
|
+
linear walks over the previous tree and one hash set of this frame's keys,
|
|
364
|
+
on frames that already changed shape and paid for a layout. The diff moved
|
|
365
|
+
from after the passes to before them for it, which nothing else noticed.
|
|
366
|
+
What it does not do is pin the *other* direction: a node declared later
|
|
367
|
+
than the ghost's neighbour in the new frame paints over the ghost, as it
|
|
368
|
+
would have painted over the node.
|
|
369
|
+
|
|
370
|
+
Benched the same way as the table below, on the same day's machine:
|
|
371
|
+
`drop_1k_rows_declaring_exit` 260 → 253 µs fastest and
|
|
372
|
+
`replay_a_full_depart_store` 12.9 → 12.8 µs, both within noise — but only
|
|
373
|
+
once the hand-on walk (a departure passing its place to the ghosts under
|
|
374
|
+
it) was gated by a membership mask over the ghosts' `before` keys. Ungated,
|
|
375
|
+
a mass removal walked the whole store per row and the same bench read
|
|
376
|
+
305 µs: the one quadratic corner the design has, and it is 64 bits of mask.
|
|
377
|
+
|
|
378
|
+
### What it cost
|
|
379
|
+
|
|
380
|
+
Measured on `crates/kui-core/benches/frame.rs`, fastest of 100 samples on one
|
|
381
|
+
machine (the medians drift 15% with thermal state; the fastest does not),
|
|
382
|
+
against the same benches at the commit before.
|
|
383
|
+
|
|
384
|
+
**What everybody pays**, whether or not they use it:
|
|
385
|
+
|
|
386
|
+
| frame | before | after |
|
|
387
|
+
|---|---|---|
|
|
388
|
+
| `frame_10k_rects` (no `exit` anywhere) | 1.20 ms | 1.26 ms |
|
|
389
|
+
| `frame_1k_typical` | 157 µs | 164 µs |
|
|
390
|
+
|
|
391
|
+
That ~5% is not the diff, which never runs there — it is `NodeSpec` growing
|
|
392
|
+
an `Option<Enter>`, 60 bytes on a struct the tree holds one of per node
|
|
393
|
+
(668 → 728 bytes, so 600 KB more tree at 10k nodes). It is the one cost here
|
|
394
|
+
that lands on frames that declare nothing, and it is worth naming what would
|
|
395
|
+
buy it back if `NodeSpec` grows another of these: boxing `enter` and `exit`
|
|
396
|
+
together behind one pointer, which recovers about half of it and puts an
|
|
397
|
+
allocation per declaring node per frame on the views that use them. Measured,
|
|
398
|
+
not guessed — it was tried. Not worth it for one slot; the next slot is where
|
|
399
|
+
it becomes worth it.
|
|
400
|
+
|
|
401
|
+
**What the feature itself costs**, from the new benches:
|
|
402
|
+
|
|
403
|
+
| frame | fastest |
|
|
404
|
+
|---|---|
|
|
405
|
+
| `frame_10k_rects_one_exit` — one dialog with an `exit` in a 10k-node frame | 1.28 ms (vs 1.26 ms with none) |
|
|
406
|
+
| `frame_10k_rects_all_transitioning` — 10k nodes with a `transition` | 2.65 ms |
|
|
407
|
+
| `frame_10k_rects_all_declaring_exit` — the same 10k, each also with an `exit` | 3.2 ms |
|
|
408
|
+
| `drop_1k_rows_plain` — 1000 rows built, then dropped | 96 µs |
|
|
409
|
+
| `drop_1k_rows_declaring_exit` — the same, every row with an `exit` | 274 µs |
|
|
410
|
+
| `replay_a_full_depart_store` — a full 512-node store replayed over an empty frame | 12 µs |
|
|
411
|
+
|
|
412
|
+
The realistic shape — a dialog with an `exit` inside a big app — costs under
|
|
413
|
+
2% of the frame it is in, even though keeping the previous frame is per frame
|
|
414
|
+
rather than per exit. The pathological one — `exit` on all 10k cells of a grid
|
|
415
|
+
— costs about 21% over the same grid's transitions, and is the declaration
|
|
416
|
+
the `exit-budget` warning exists to argue with. A mass removal costs about
|
|
417
|
+
180 µs over the plain one for the budget's worth of subtree copies, once, on
|
|
418
|
+
the frame it happens; replaying them afterwards is 12 µs a frame, which is
|
|
419
|
+
what makes "keep drawing until the exit finishes" affordable at all.
|
|
420
|
+
|
|
421
|
+
### No corpus scene, and why — **superseded: there is one (backlog B3)**
|
|
422
|
+
|
|
423
|
+
The original argument, kept because half of it still holds:
|
|
424
|
+
|
|
425
|
+
> `exit` is a plain `PROPS` row of the kind three of the four bindings lower
|
|
426
|
+
> by table lookup: Lua reads it by snake\_name, Node's encoder takes its id
|
|
427
|
+
> straight off the protocol, and C — the one that maps by hand — is forced by
|
|
428
|
+
> `every_schema_prop_has_a_c_counterpart`, which fails the build for a row
|
|
429
|
+
> with no `KuiSpec` field. Node's own sweep ("every generic schema prop
|
|
430
|
+
> reaches the stream and lowers") covers the wire. What is left is the ghost,
|
|
431
|
+
> and that is entirely `kui-core`: there is no per-binding behaviour for a
|
|
432
|
+
> scene to catch four of.
|
|
433
|
+
>
|
|
434
|
+
> Pinning it in the corpus anyway would need two changes to the corpus
|
|
435
|
+
> protocol that serve nothing else. A **clock step**, because without one
|
|
436
|
+
> every transition snaps and there is no ghost to see, in all four adapters.
|
|
437
|
+
> And a **per-frame builder** in the Node adapter, which builds its scene tree
|
|
438
|
+
> once and reuses it for every frame — a departure is a tree that changed, so
|
|
439
|
+
> a scene that never changes its tree cannot express one. Both are
|
|
440
|
+
> worthwhile if a second cross-binding behaviour ever wants a clock; neither
|
|
441
|
+
> is worth doing for a row that is already forced twice.
|
|
442
|
+
|
|
443
|
+
What the argument got wrong is the sentence "there is no per-binding
|
|
444
|
+
behaviour for a scene to catch". The corpus does not only compare
|
|
445
|
+
*lowerings*; it compares quads, access rows and events over driven steps,
|
|
446
|
+
and `exit` is the first feature where those three disagree with each other
|
|
447
|
+
on purpose — the ghost draws, and is at the same time absent from the hit
|
|
448
|
+
regions, the Tab ring and the access tree. Nothing but a scene checks that
|
|
449
|
+
the three stay out of step in the same way in four languages, and "this
|
|
450
|
+
row lowers, therefore this row behaves" is exactly the inference this
|
|
451
|
+
corpus exists because the project stopped making.
|
|
452
|
+
|
|
453
|
+
The two protocol changes were built rather than avoided, and both are
|
|
454
|
+
smaller than they read: `Step::Phase(n)` leaves a number the builder is a
|
|
455
|
+
function of (every other scene ignores it), `Step::Time(ms)` sets the frame
|
|
456
|
+
clock, and neither is an input, so each adapter grew one arm that does not
|
|
457
|
+
call `handle_input`. The Node adapter now builds its tree per frame, with
|
|
458
|
+
the corpus fixtures registered once on the first build that asks for them,
|
|
459
|
+
so its handles are still the ones `conformance::fixtures` hands out.
|
|
460
|
+
|
|
461
|
+
The `exit` scene ends its steps 80 ms into a 400 ms exit, because a report
|
|
462
|
+
keeps one frame and that is the only frame where all four claims differ at
|
|
463
|
+
once — the same lesson the `modal` scene wrote down about the Tab ring.
|
|
464
|
+
`fade` is mid-flight (it draws, with the text from the *previous* frame's
|
|
465
|
+
text list, which is the other half of the buffer swap); `blink` ran 50 ms
|
|
466
|
+
and is over; `flash` left and came back while its own exit was running, so
|
|
467
|
+
the frame holds one picture of it and not two; and `bulk` is one node past
|
|
468
|
+
the budget, so it is refused whole with an `exit-budget` warning. The
|
|
469
|
+
inertness is three separate observations in that frame: a press on ground
|
|
470
|
+
covered by both where the node was and where its ghost now is emits nothing,
|
|
471
|
+
where the earlier press on the live node emitted `hit`; two Tabs walk from
|
|
472
|
+
`A` to `B`; and the access tree lists neither.
|
|
473
|
+
|
|
474
|
+
Honest outcome: **it caught nothing**. All four adapters agreed on the first
|
|
475
|
+
run that compiled. That is the expected result for a mechanically lowered
|
|
476
|
+
row, and it is not the reason to keep the scene — the reason is that the
|
|
477
|
+
next change to `depart.rs` now has four readers instead of one.
|
|
478
|
+
`crates/kui-core/tests/exit.rs` still holds the finer-grained behaviour,
|
|
479
|
+
including the toast-stack shape the policy was decided against.
|
|
480
|
+
|
|
481
|
+
## Amendment: rounded clipping, built
|
|
482
|
+
|
|
483
|
+
The consequence above left rounded clipping in the backlog with a price on
|
|
484
|
+
it: "four more floats on the hot struct plus a second SDF per fragment is a
|
|
485
|
+
bigger bill than either of these". The price was an estimate. It has now
|
|
486
|
+
been measured, and it is smaller than the estimate implied — smaller, in
|
|
487
|
+
particular, than the 5% every frame already pays for `NodeSpec::exit` — so
|
|
488
|
+
it is built.
|
|
489
|
+
|
|
490
|
+
`Quad` grows `clip_radius: [f32; 4]` (108 → 124 bytes) and `display::Clip`
|
|
491
|
+
is the rect-plus-radii pair that the clip propagation, `Paint`, and the text
|
|
492
|
+
and editor emitters now carry instead of a bare `Rect`. A node that clips
|
|
493
|
+
(`clip`, `scroll_x`, `scroll_y`) and has a `radius` rounds the clip its
|
|
494
|
+
descendants inherit — no new prop, in any of the four bindings, because the
|
|
495
|
+
radius is the clipping node's own and the rule is CSS's.
|
|
496
|
+
|
|
497
|
+
### What it cost
|
|
498
|
+
|
|
499
|
+
Measured on `crates/kui-core/benches/frame.rs`, fastest of 100 samples over
|
|
500
|
+
14 interleaved rounds on one machine, against the same benches built from
|
|
501
|
+
the commit before — the *same bench file* on both sides, including the two
|
|
502
|
+
benches added for this, so the ~5% a bench binary moves by gaining functions
|
|
503
|
+
is not being read as a result. Two copies of the baseline binary ran in the
|
|
504
|
+
same rounds; they differ by −0.6% to +0.4%, which is the method's noise floor
|
|
505
|
+
and the number every row below has to beat to mean anything.
|
|
506
|
+
|
|
507
|
+
| frame | before | after | |
|
|
508
|
+
|---|---|---|---|
|
|
509
|
+
| `frame_10k_rects` (nothing clips) | 1.269 ms | 1.281 ms | +0.9% |
|
|
510
|
+
| `frame_10k_rects_with_text_and_hits` | 1.663 ms | 1.682 ms | +1.1% |
|
|
511
|
+
| `frame_10k_rects_with_shadows_and_opacity` | 1.384 ms | 1.398 ms | +1.0% |
|
|
512
|
+
| `frame_1k_typical` | 163 µs | 166 µs | +1.5% |
|
|
513
|
+
| `list_10k_rows_naive` (a real scroll container) | 4.93 ms | 4.98 ms | +1.0% |
|
|
514
|
+
| `frame_10k_rects_square_clip` (100 clipping rows) | 1.289 ms | 1.334 ms | +3.5% |
|
|
515
|
+
| `frame_10k_rects_rounded_clip` (the same, rounded) | 1.284 ms | 1.346 ms | +4.8% |
|
|
516
|
+
|
|
517
|
+
Read it as three numbers. **About 1% is what everybody pays**, whether or
|
|
518
|
+
not anything clips: sixteen more bytes on `Quad` and one more field written
|
|
519
|
+
per quad, 20,000 times a frame. **About 3.5% is what a frame that clips
|
|
520
|
+
pays**, because a `Clip` is 32 bytes where a `Rect` was 16 and it travels
|
|
521
|
+
through the propagation pass, `Paint`, and every emitter. And **the rounding
|
|
522
|
+
itself is the last 1.3 points** — inside the new build, the rounded frame is
|
|
523
|
+
0.9% slower than the square one (1.346 vs 1.334 ms), which is the per-corner
|
|
524
|
+
[`Clip::intersect`] running on all 10k nodes in the shape a real view never
|
|
525
|
+
declares: it rounds the card, not each of its rows.
|
|
526
|
+
|
|
527
|
+
The four floats, on their own, were measured first and separately, against a
|
|
528
|
+
one-float `clip_radius` that could not express a per-corner radius: at
|
|
529
|
+
108 → 112 vs 108 → 124 bytes the two were indistinguishable from each other
|
|
530
|
+
and from the baseline. The generality is free; it was the plumbing around it
|
|
531
|
+
that cost the 1%.
|
|
532
|
+
|
|
533
|
+
The shader side is one more `sd_rounded_box` per fragment, taken **instead
|
|
534
|
+
of** the rect test rather than after it, and only on the branch where
|
|
535
|
+
`clip_radius` is not all zero — which it is on every quad of every frame
|
|
536
|
+
that has no rounded clipper. The value comes off the instance, so the branch
|
|
537
|
+
is uniform across a quad's fragments. That is not measured on a GPU here;
|
|
538
|
+
what is checked is that both preprocessed variants of the shader still
|
|
539
|
+
validate (`shader_variants_validate`) and that the shape the shader cuts is
|
|
540
|
+
the shape the core described, by mirroring `shade`'s clip block on the CPU
|
|
541
|
+
and evaluating it over quads a real `Core` emitted
|
|
542
|
+
(`crates/kui-wgpu/tests/rounded_clip_coverage.rs`, beside the shadow work's
|
|
543
|
+
`shadow_coverage.rs` — both now share one transcription of the WGSL in
|
|
544
|
+
`tests/wgsl`).
|
|
545
|
+
|
|
546
|
+
### One rounded rect cannot name the intersection of two
|
|
547
|
+
|
|
548
|
+
The clip a node inherits is one rect and four radii, and clippers nest, so
|
|
549
|
+
the intersection of two rounded rects has to collapse into that shape.
|
|
550
|
+
`Clip::intersect` decides **per corner**: a corner takes whichever of the
|
|
551
|
+
two shapes rounds it more — the intersection of two rounded corners is the
|
|
552
|
+
tighter one, so that is exact — and keeps a radius at all only while that
|
|
553
|
+
corner of the result is still the same point as that corner of the shape it
|
|
554
|
+
came from. A corner an ancestor's straight edge has already cut away is
|
|
555
|
+
square, which is what the ancestor made it, so that is exact too.
|
|
556
|
+
|
|
557
|
+
The one case it approximates: an ancestor edge that cuts *partway* into a
|
|
558
|
+
rounded corner moves that corner, so the radius drops to zero there and a
|
|
559
|
+
sliver at the very corner goes unclipped. It takes two rounded clippers
|
|
560
|
+
offset from each other to build. The alternative is carrying the rounding
|
|
561
|
+
shape's own rect alongside the intersection — eight more floats rather than
|
|
562
|
+
four, to serve a shape nobody has drawn yet.
|
|
563
|
+
|
|
564
|
+
### What is deliberately not rounded
|
|
565
|
+
|
|
566
|
+
- **Hit testing.** `HitRegion::clip` stays a rect: a click in the corner of
|
|
567
|
+
a rounded scroll container still reaches the row under it. Browsers do the
|
|
568
|
+
same for descendants of a rounded `overflow: hidden`, and the alternative
|
|
569
|
+
is a per-corner test in the hot input path for a few pixels.
|
|
570
|
+
- **Culling.** The "entirely clipped away" test in `finish_frame` is still
|
|
571
|
+
the rect intersection, so a node that survives only inside a corner's arc
|
|
572
|
+
is emitted and clipped rather than dropped. Cheaper, and never wrong in
|
|
573
|
+
the visible direction.
|
|
574
|
+
- **A clipper's own quads.** `clips[i]` is ancestors only, as it always was,
|
|
575
|
+
so a rounded card is not clipped by itself — it draws its own rounded
|
|
576
|
+
shape through `radius`, which is the same shape.
|
|
577
|
+
- **Scrollbars**, for the same reason: a container does not round away its
|
|
578
|
+
own bar.
|
|
579
|
+
|
|
580
|
+
### The option not taken, and what would buy it back
|
|
581
|
+
|
|
582
|
+
The cheapest version of this feature makes `Quad` *smaller*: replace
|
|
583
|
+
`clip: Rect` with a `clip_id: u32` into a `DisplayList::clips` side table
|
|
584
|
+
(there is one entry per clipping node, not per quad), which is 96 bytes with
|
|
585
|
+
full rounded clipping instead of 124 without. It was not taken because it
|
|
586
|
+
breaks what the renderer boundary promises: a quad stops being
|
|
587
|
+
self-contained, every backend grows a second array to bind, and a C host
|
|
588
|
+
reading `KuiDrawData.quads` has to resolve an index. If `Quad` ever has to
|
|
589
|
+
grow again, that is the trade to reopen — and it would pay for the growth
|
|
590
|
+
twice over.
|
|
591
|
+
|
|
592
|
+
### ABI
|
|
593
|
+
|
|
594
|
+
`KuiQuad` gained the field and `KUI_ABI_VERSION` went 1 → 2, which is
|
|
595
|
+
exactly the rule ADR 0006 wrote down: `KuiQuad` is a **[lib]** struct, the
|
|
596
|
+
host strides the array with its own `sizeof`, and there is no in-band
|
|
597
|
+
handshake to catch a mismatch. `conformance::quad_digest` hashes the new
|
|
598
|
+
words (0..=18 and 23..=30 of a now 31-word struct), so a binding whose
|
|
599
|
+
mirror of `KuiQuad` missed the field fails every scene rather than none —
|
|
600
|
+
and the corpus's `overflow` scene now rounds its scrolling list, so those
|
|
601
|
+
words are not all zero.
|