@qxuken/kui 0.1.0-alpha.36 → 0.1.0-alpha.37
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 +254 -0
- package/docs/adr/0005-the-paint-vocabulary.md +5 -0
- package/docs/adr/0010-a-segment-primitive.md +46 -0
- package/docs/adr/0040-a-path-is-a-mask-in-the-atlas.md +44 -2
- package/docs/adr/0042-a-gradient-is-an-image-the-core-paints.md +455 -0
- package/encoder.js +26 -4
- package/howto.md +54 -1
- package/index.d.ts +20 -0
- package/jsx-runtime.d.ts +56 -3
- package/package.json +1 -1
- 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 +7 -4
package/CHANGELOG.md
CHANGED
|
@@ -21,6 +21,260 @@ listed under both (backlog F61, from the alpha.12 field reports: the list
|
|
|
21
21
|
is what the release knows it broke, and a fix it did not think of as one
|
|
22
22
|
was the first bare bump to break an app in five releases).
|
|
23
23
|
|
|
24
|
+
## 0.1.0-alpha.37 (2026-10-05)
|
|
25
|
+
|
|
26
|
+
**What breaks.**
|
|
27
|
+
|
|
28
|
+
- Rust: `HitShape::Path` gains a `stroke` field and `Content` a
|
|
29
|
+
`PathFlat` variant (under Fixed), so a pattern over either needs
|
|
30
|
+
them; `HitShapes::path` takes the stroke's width after the rule.
|
|
31
|
+
- A `path` with a stroke over a fill is hit out to the stroke's edge,
|
|
32
|
+
where the half of the stroke outside the outline fell through; and a
|
|
33
|
+
stroked path with no `bg` but a `hover_bg`, `pressed_bg` or
|
|
34
|
+
`focus_bg` is hit inside, where it was hit along its stroke only
|
|
35
|
+
(under Fixed).
|
|
36
|
+
- Lua: `path { ops = }` whose numbers are not the flat form draws
|
|
37
|
+
nothing with a `path-malformed` warning, where it was an error that
|
|
38
|
+
failed the view (under Fixed).
|
|
39
|
+
- A `path` that animated and then held still for 120 frames draws from
|
|
40
|
+
the atlas again — a `GlyphMask` quad where it stayed a `Texture`
|
|
41
|
+
(under Fixed). Nothing an app sees; a host that counts quads by kind
|
|
42
|
+
does.
|
|
43
|
+
- C: `kui_polyline`, `kui_path` and `kui_path_d` take a `dash` before
|
|
44
|
+
`spec` — five floats, or NULL for the solid stroke they drew (under
|
|
45
|
+
Added). `KUI_ABI_VERSION` is 24; pass NULL and recompile. The same
|
|
46
|
+
step appends `gradient` to `KuiSpec` (64-bit size 696).
|
|
47
|
+
- Rust: `schema::Kind`, `Apply` and `Parsed` gain a `Gradient` variant
|
|
48
|
+
and `InteractSpec` a `gradient` field (under Added).
|
|
49
|
+
- Rust: `Stroke` gains a `dash` field, so a struct literal needs it
|
|
50
|
+
(`Stroke::new` does not); `path::MaskPaint` gains `Dashed`.
|
|
51
|
+
- A cell grid's glyph for a character its family lacks is drawn from a
|
|
52
|
+
monospaced face where one has it, in the middle of its cell, and no
|
|
53
|
+
wider than it (F120, under Fixed), where it was the platform's first
|
|
54
|
+
fallback at its own width from the cell's left edge.
|
|
55
|
+
- Rust: `Tree` gains an `any_gradient` field.
|
|
56
|
+
|
|
57
|
+
The Node wire moves to v21 for the dash's five floats on a line and a
|
|
58
|
+
path, which an encoder and addon of one release never see apart.
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- **`gradient` on a box, in every binding**
|
|
63
|
+
([ADR 0042](docs/adr/0042-a-gradient-is-an-image-the-core-paints.md),
|
|
64
|
+
which supersedes ADR 0005's "no gradients"): `gradient={{ to:
|
|
65
|
+
'bottom', stops: ['#1e2030', '#14161e'] }}`, `{ angle: 0.125, stops }`
|
|
66
|
+
in turns clockwise from east, `{ radial: true, at: [0.5, 0], stops }`;
|
|
67
|
+
the same table in Lua; `NodeSpec::gradient(Gradient::to(Side::Bottom,
|
|
68
|
+
[...]))` with `Gradient::angle` and `Gradient::radial_at`; and a
|
|
69
|
+
`const KuiGradient *` on `KuiSpec`. A stop is a colour — a `$token`
|
|
70
|
+
too — or a colour and a position.
|
|
71
|
+
- **Over `bg`, under the border and the children.** `bg` stays a
|
|
72
|
+
colour and keeps its tweens, tokens and state backgrounds; a
|
|
73
|
+
transparent stop shows it through.
|
|
74
|
+
- **An image the core paints.** Each distinct gradient is rasterized
|
|
75
|
+
once into the glyph atlas — a 256-texel strip along an axis, a
|
|
76
|
+
128-texel square otherwise, each with a gutter of the gradient
|
|
77
|
+
carried a texel past its edges, since a stretched quad samples
|
|
78
|
+
there — and drawn as one `Image` quad, so no
|
|
79
|
+
renderer changes and a host that draws an image draws a gradient.
|
|
80
|
+
The key is the gradient and not the box: a resize, another scale
|
|
81
|
+
and a thousand boxes sharing one rasterize nothing.
|
|
82
|
+
- **On the box's unit square.** A side or a corner is CSS's; any
|
|
83
|
+
other `angle` runs corner to corner at an eighth of a turn at any
|
|
84
|
+
aspect, where CSS's pixel-measured `45deg` does not. Stops mix in
|
|
85
|
+
straight sRGB with the alpha premultiplied.
|
|
86
|
+
- **What it does not do.** It does not tween and the state
|
|
87
|
+
backgrounds do not replace it; a hard stop is as soft as the raster
|
|
88
|
+
stretched (a 256th of the box along a strip); there is no conic
|
|
89
|
+
one, and none on a `path`, a `line`, a border or a text. Those, and
|
|
90
|
+
anything animated, stay a `fragment`'s.
|
|
91
|
+
- Measured (the ADR's *Measured*): a linear gradient is within half
|
|
92
|
+
an 8-bit level of the mix computed per pixel at every pixel of the
|
|
93
|
+
box, a radial within 1.2; a gradient box costs about 52 ns over a
|
|
94
|
+
flat one, half of it what any `hoverBg` pays; a raster is 4 µs for
|
|
95
|
+
a strip and 25 to 42 for a square.
|
|
96
|
+
- The corpus gains a `gradients` scene; `examples/rust/apps/loaders.rs`
|
|
97
|
+
gains a rainbow — one gradient two tracks long slid under a clip,
|
|
98
|
+
one strip in the atlas for good.
|
|
99
|
+
|
|
100
|
+
- **`dash` on a `line` and on a `path`'s stroke, in every binding**
|
|
101
|
+
(backlog V2, the amendment in
|
|
102
|
+
[ADR 0010](docs/adr/0010-a-segment-primitive.md)): `<line dash={[6, 4]}
|
|
103
|
+
dashOffset/>`, `line { dash = {6, 4}, dash_offset = }`,
|
|
104
|
+
`Stroke::new(w, c).dash(6.0, 4.0).dash_offset(n)` (`Dash::of` for a
|
|
105
|
+
pattern read from data), and a `const float *dash` on `kui_polyline`,
|
|
106
|
+
`kui_path` and `kui_path_d`. One length is marks and gaps alike, two
|
|
107
|
+
are a mark and a gap, four a dash-dot.
|
|
108
|
+
- **The lengths are the ones seen.** Every mark is a short stroke
|
|
109
|
+
with the stroke's own round caps, so `6, 4` is 6 px of ink and 4 px
|
|
110
|
+
of nothing at any width and a mark no longer than the stroke is
|
|
111
|
+
wide is a dot. SVG's `stroke-dasharray` measures the centre line,
|
|
112
|
+
which with round caps makes `4 4` at a width of 4 a solid line;
|
|
113
|
+
this pattern is SVG's `mark − width, gap + width`.
|
|
114
|
+
- **The pattern keeps its phase** along the whole stroke — round the
|
|
115
|
+
corners of a polyline and across the pieces of a curve, which is
|
|
116
|
+
what ADR 0010 would not ship without — and restarts at each subpath
|
|
117
|
+
of a `path`, as SVG's does. `dashOffset` starts that far into it;
|
|
118
|
+
growing it moves the marks towards the first point, a marquee's
|
|
119
|
+
marching ants. Neither tweens.
|
|
120
|
+
- **No renderer changes.** A dashed line is one `Segment` quad per
|
|
121
|
+
mark per piece the mark lies on, cut in the core, so a host that
|
|
122
|
+
draws a stroke draws a dashed one; a dashed path stroke is cut by
|
|
123
|
+
the rasterizer into the mask it already was. A path whose offset
|
|
124
|
+
changes every frame is a shape that changes every frame, and
|
|
125
|
+
leaves the atlas for a texture of its own while it marches.
|
|
126
|
+
- A pattern with no gap, a mark and gap under a physical pixel
|
|
127
|
+
together, or more than 16384 marks draws solid. A dashed stroke is
|
|
128
|
+
hit along its whole length, gaps included.
|
|
129
|
+
- The corpus's `lines` and `paths` scenes carry one each, so the
|
|
130
|
+
segment count pins the cut in four bindings;
|
|
131
|
+
`examples/rust/widgets/line.rs` hangs its planned cards off dashed
|
|
132
|
+
links that march under the pointer.
|
|
133
|
+
|
|
134
|
+
- **The fallback fonts are the app's to name** (backlog F121, from
|
|
135
|
+
kawoosh). `Core::set_fallback_fonts(&[FontId])` — C
|
|
136
|
+
`kui_font_set_fallback`, Node `ctx.setFallbackFonts(ids)` — lists the
|
|
137
|
+
fonts asked, in order, for a character the text's own family has no
|
|
138
|
+
glyph for, before the platform's list, whose first choice on macOS is
|
|
139
|
+
the system's proportional face. For every text in the session — an
|
|
140
|
+
editor already open and a cell grid too — and kept across
|
|
141
|
+
`reload_system_fonts`; an empty list is the platform's alone.
|
|
142
|
+
`Core::fallback_fonts` reads the families back.
|
|
143
|
+
|
|
144
|
+
### Fixed
|
|
145
|
+
|
|
146
|
+
- **A cell grid's fallback glyph stays in its cell** (backlog F120, from
|
|
147
|
+
kawoosh: Russian text in a terminal whose family has no Cyrillic, `Ю`
|
|
148
|
+
drawn across the letter after it). A character the grid's family has
|
|
149
|
+
no glyph for is asked of a monospaced face before the platform's
|
|
150
|
+
fallback list — on macOS that list opens with the system's
|
|
151
|
+
proportional face; a glyph still wider than its cells, two under
|
|
152
|
+
`wide`, is shaped at the size it fits at; and the room a narrower one
|
|
153
|
+
leaves is shared either side. The family's own glyphs and the private
|
|
154
|
+
use area's icons draw as they did.
|
|
155
|
+
|
|
156
|
+
- **What the `path` reviews left** (backlog RG112, the second review in
|
|
157
|
+
[ADR 0040](docs/adr/0040-a-path-is-a-mask-in-the-atlas.md)):
|
|
158
|
+
- A window closed while it showed an animating or a big path left the
|
|
159
|
+
path's texture with the renderer for the life of the process; the
|
|
160
|
+
core now hands it to the session as it goes, and the next frame any
|
|
161
|
+
window draws drops it. A frame built and never read keeps its
|
|
162
|
+
`dropped_textures` and `dropped_fragments` for the next one.
|
|
163
|
+
- A path that stopped moving stops costing a texture and a draw of
|
|
164
|
+
its own: after 120 still frames its mask is the atlas's again. A
|
|
165
|
+
row of icons keyed by position, whose neighbours came and went
|
|
166
|
+
twice within eight frames, read as animating and stayed so for
|
|
167
|
+
good.
|
|
168
|
+
- A stroke over a fill is hit as far as it is painted
|
|
169
|
+
(`HitShape::Path`'s `stroke`).
|
|
170
|
+
- Ops that are not the flat form raise `path-malformed` under the
|
|
171
|
+
node's key in every binding (`Core::path_flat_node`,
|
|
172
|
+
`Content::PathFlat`); C and Node drew nothing in silence.
|
|
173
|
+
- JSX types `width` on `line` and `path` as a number or a `$length`,
|
|
174
|
+
as the encoder reads it; `Core::image_pixels` answers for the
|
|
175
|
+
handle a path's `Texture` quad names.
|
|
176
|
+
|
|
177
|
+
- **What this release's own pre-tag pass found** (backlog RG114–RG117;
|
|
178
|
+
RG118 holds what it read and left), none of it in a release before
|
|
179
|
+
this one:
|
|
180
|
+
- An editor open when `set_fallback_fonts` was called kept the faces
|
|
181
|
+
its lines were shaped in until they were edited (RG114).
|
|
182
|
+
- The root's `gradient` was dropped while the devtools panel was
|
|
183
|
+
docked (RG115).
|
|
184
|
+
- A dashed `line` whose points coincide drew nothing and was still
|
|
185
|
+
hit; it is the dot its solid one is (RG116).
|
|
186
|
+
- A gradient's `radial` that is not a boolean is an error, where it
|
|
187
|
+
read as linear; its row says that fewer than two stops fail the
|
|
188
|
+
view in JSX and Lua and draw nothing in Rust and C; `kui.h` says
|
|
189
|
+
what a zeroed `KuiGradient` is (RG117).
|
|
190
|
+
|
|
191
|
+
**What you can delete.** A family chosen only because it covers a
|
|
192
|
+
script the preferred one lacks, which can give way to the preferred
|
|
193
|
+
one with the fallback list named; the key an app gave a still icon only so a
|
|
194
|
+
neighbour's coming and going would not move it to a texture; the
|
|
195
|
+
invisible wider path laid under a thick-stroked one to catch the press
|
|
196
|
+
on its edge; the WGSL a card's two-colour fade was written in, and the
|
|
197
|
+
`fragment` that held its children; the loop that walked a connector's points and declared a
|
|
198
|
+
`line` per dash, and the arithmetic that kept its phase round a corner.
|
|
199
|
+
|
|
200
|
+
### Native verification
|
|
201
|
+
|
|
202
|
+
The by-hand round alpha.6 introduced (backlog R4), on 2026-10-05 — the
|
|
203
|
+
nine commits after the alpha.36 tag: `dash`, the `gradient` row, F120,
|
|
204
|
+
F121 and RG112 — with a regression pass over them first: three
|
|
205
|
+
read-only reviews (the dash, the gradient, the fonts with this
|
|
206
|
+
release's docs), each claim probed with a test before anything
|
|
207
|
+
changed. RG114–RG117 came of it and are in this release; RG118 holds
|
|
208
|
+
what was read and left. This round ran on the Mac alone; Windows and
|
|
209
|
+
Linux did not run it for this tag.
|
|
210
|
+
|
|
211
|
+
**macOS 27.0.1 on an M3 Pro MacBook Pro, rustc 1.99.0 (the toolchain
|
|
212
|
+
CI runs), Node 26.10.0, nu 0.116.0**, on the release commit's tree,
|
|
213
|
+
the workspace's own artifacts pruned and rebuilt. `cargo fmt --all
|
|
214
|
+
--check` and `cargo clippy --workspace --all-targets -- -D warnings`
|
|
215
|
+
are clean. `scripts/test.nu`, the workspace's tests with the
|
|
216
|
+
conformance feature: **1802 tests over 135 suites, 0 failed** (4
|
|
217
|
+
ignored). The C round, `cbuild --run`, passes its five checks; the
|
|
218
|
+
corpus passes its **57 scenes** in four adapters, `gradients` the new
|
|
219
|
+
one; the ABI is **24**. Node's `node --test test.mjs` under
|
|
220
|
+
`KUI_CONFORMANCE_REQUIRED=1`: **209 of 209**. `npm run gen` leaves no
|
|
221
|
+
diff, the examples typecheck and their lockfile installs, the headless
|
|
222
|
+
round passes all **35 drives**, the book builds and
|
|
223
|
+
`scripts/book-examples.nu --check` passes.
|
|
224
|
+
|
|
225
|
+
**The windowed round**, `smoke -- --node`, three times over: **51 Rust
|
|
226
|
+
examples and the eleven Node examples, each on both bases, 120 frames
|
|
227
|
+
each, every one exiting 0** — 124 windows, eight at a time, in 28 to
|
|
228
|
+
32 s — and `counter`, `host`, `c_panel` and `lua_panel` by hand under
|
|
229
|
+
`KUI_SMOKE_FRAMES=120`, each exiting 0 with nothing on stderr: **128
|
|
230
|
+
windows over five hosts.** In each of the three runs one window of the
|
|
231
|
+
first eight took the whole round to draw its frames — `cells`, then
|
|
232
|
+
`audio`, then `accessibility`, each a second and a half when run
|
|
233
|
+
alone — which reads as a window covered by the seven launched over it
|
|
234
|
+
and not drawn until they had gone; not compared against alpha.36's
|
|
235
|
+
build. The AX audit: **106/106**, the audited window raised to the
|
|
236
|
+
front by its pid first, and no warning on the fixture's stderr.
|
|
237
|
+
|
|
238
|
+
**The bench guard** against the alpha.36 tag, on the release commit's
|
|
239
|
+
tree: **green**, none of the 8 guarded rows more than 10% slower —
|
|
240
|
+
every one between −0.9% and +2.8% (the worst guarded run-to-run spread
|
|
241
|
+
2.1%). It was not so at first, twice over, and both are fixed in this
|
|
242
|
+
tree:
|
|
243
|
+
|
|
244
|
+
- `frame_10k_segments` read **+7.7%** (863 → 929 µs, ±1.0%) on solid
|
|
245
|
+
lines, and that was the dash (backlog C52): bisected to V2's commit
|
|
246
|
+
by building the bench at each one. The `Stroke`, twenty bytes wider
|
|
247
|
+
with its `Dash`, was copied at each call from `Ui::line` down to the
|
|
248
|
+
line store, and the dash's cut ran its whole test on every line. The
|
|
249
|
+
stroke is lent and a solid pattern is told by its zeroes; the row
|
|
250
|
+
reads +2.5% in the guard and 875 → 880 µs with the two binaries
|
|
251
|
+
alternated.
|
|
252
|
+
- `frame_10k_rects` read +9.3% and every row built on the bench's grid
|
|
253
|
+
5 to 9% slower, and that was the bench: bisected to the commit that
|
|
254
|
+
gave its grid the gradient rows' arms in one `match` per cell, with
|
|
255
|
+
no line of the core between it and the commit before. The gradient
|
|
256
|
+
cell is a function of its own now, the plain rows read as
|
|
257
|
+
alpha.36's, and the gradient rows were taken again — 52 ns a node
|
|
258
|
+
over a flat box where the first reading said 55
|
|
259
|
+
([ADR 0042](docs/adr/0042-a-gradient-is-an-image-the-core-paints.md),
|
|
260
|
+
*Measured*; [docs/performance.md](docs/performance.md) carries the
|
|
261
|
+
four rows from that run, the rest of its table kept as it was).
|
|
262
|
+
|
|
263
|
+
**This tag was cut twice.** The first, at `c7e9793`, published
|
|
264
|
+
nothing: both hosts' `check` failed on F121's own test, which asserted
|
|
265
|
+
that the platform's list draws `字` differently from the app's — and
|
|
266
|
+
on an image with DejaVu alone, both pipelines', the fixture is the one
|
|
267
|
+
face that has it, list or no list. Forgejo's `check` had been red on
|
|
268
|
+
it since F121 was merged, which this round did not read before
|
|
269
|
+
tagging; cargo stops at the first failing suite, so the suites after
|
|
270
|
+
`fonts` had not run there either. The test now tells which machine it
|
|
271
|
+
is on and asserts the platform's half only where there is one, and
|
|
272
|
+
`cargo test -p kui-core` passes its 105 suites in `rust:1-bookworm`
|
|
273
|
+
with `fonts-dejavu-core` alone (the whole workspace would not fit that
|
|
274
|
+
container). With no crate and no package carrying the version, the tag
|
|
275
|
+
was moved to the commit that has the fix and C52's; the mechanical
|
|
276
|
+
round, the windowed round and the guard above are of that commit.
|
|
277
|
+
|
|
24
278
|
## 0.1.0-alpha.36 (2026-10-05)
|
|
25
279
|
|
|
26
280
|
**What breaks.**
|
|
@@ -127,6 +127,11 @@ silent surprise.
|
|
|
127
127
|
|
|
128
128
|
### Gradients: out of scope for v0
|
|
129
129
|
|
|
130
|
+
> **Superseded by [ADR 0042](0042-a-gradient-is-an-image-the-core-paints.md)
|
|
131
|
+
> (2026-10-05):** a box takes a `gradient`, linear or radial, rasterized
|
|
132
|
+
> once into the atlas and drawn as an image quad. What follows is why it
|
|
133
|
+
> was not in v0.
|
|
134
|
+
|
|
130
135
|
Two colors and a direction sound like one more `PROPS` row and are not. A
|
|
131
136
|
gradient needs a stop list (so: a `Keyframes`-shaped parse, in five
|
|
132
137
|
bindings), a type (linear, radial, conic), a geometry (angle or two points,
|
|
@@ -408,3 +408,49 @@ binding: two nodes on a clipping canvas are panned half past its top edge,
|
|
|
408
408
|
one clipped and one not. A press over the toolbar where the clipped node's
|
|
409
409
|
cut half would be reaches the toolbar, and the same press on the other
|
|
410
410
|
node reaches that node.
|
|
411
|
+
|
|
412
|
+
## Amendment: a dash is cut in the core
|
|
413
|
+
|
|
414
|
+
*2026-10-05, backlog V2.* Decision 9 left dashes out, and the options
|
|
415
|
+
said why: a `dash` that restarted at every join of a curve would read as
|
|
416
|
+
a bug. A view asked, so it is built, and not the way the option sketched.
|
|
417
|
+
|
|
418
|
+
**The sketch was a phase per quad.** `params.w` would carry how far
|
|
419
|
+
along the stroke each segment starts, and the backend would cut the
|
|
420
|
+
capsule by the pattern. That is one quad per piece whatever the pattern,
|
|
421
|
+
and it is a change to every renderer: the wgpu shader, and each host
|
|
422
|
+
that draws the list itself, which would draw a dashed stroke solid until
|
|
423
|
+
it caught up — with nothing to tell it so.
|
|
424
|
+
|
|
425
|
+
**What is built is a cut in the core.** A dashed stroke's run is walked
|
|
426
|
+
along its arc length (`line::Cut::marks`) and each mark is emitted as
|
|
427
|
+
what it is, a short round-capped stroke: one `Segment` quad per mark per
|
|
428
|
+
piece the mark lies on, two meeting at the corner for a mark that turns
|
|
429
|
+
one. No backend changed, the corpus pins the cut by its segment count,
|
|
430
|
+
and the phase is kept by construction, since the walk does not know
|
|
431
|
+
where the pieces end. It costs quads in proportion to the marks rather
|
|
432
|
+
than the pieces — a 1000 px line at a 10 px period is 100 — and a
|
|
433
|
+
stroke that would be more than 16384 marks, or whose mark and gap come
|
|
434
|
+
to under a physical pixel, draws solid.
|
|
435
|
+
|
|
436
|
+
**The lengths are the ones seen.** Caps are round (decision 2), so a
|
|
437
|
+
mark `on` long is a capsule with a centre line of `on − width`, and the
|
|
438
|
+
gap's centre line takes the difference; a mark no longer than the stroke
|
|
439
|
+
is wide is a dot. SVG's `stroke-dasharray` measures the centre line,
|
|
440
|
+
and with a round cap its `4 4` at a width of 4 is a solid line, which
|
|
441
|
+
is the first thing anyone writes. The first mark's cap sits where the
|
|
442
|
+
solid stroke's would.
|
|
443
|
+
|
|
444
|
+
**The same pattern on a `path`.** A path's stroke is a mask
|
|
445
|
+
([ADR 0040](0040-a-path-is-a-mask-in-the-atlas.md), whose decision 3
|
|
446
|
+
left `dash` to V2), so the centre lengths go to the rasterizer and the
|
|
447
|
+
mask is keyed by them. The pattern restarts at each subpath, as SVG's
|
|
448
|
+
does. An offset that changes every frame is read as the path's shape
|
|
449
|
+
changing, so a marching outline takes a texture of its own instead of
|
|
450
|
+
an atlas slot a frame.
|
|
451
|
+
|
|
452
|
+
**What it does not do.** Neither `dash` nor `dashOffset` tweens; a
|
|
453
|
+
translucent dashed polyline double-blends where two halves of one mark
|
|
454
|
+
meet at a corner, as a solid one does at every join; and the hit region
|
|
455
|
+
is the whole stroke, gaps included — the target is the stroke, not the
|
|
456
|
+
ink.
|
|
@@ -127,7 +127,9 @@ date: 2026-10-04
|
|
|
127
127
|
wedge; the stroke's colour tweens as a line's does. `fillRule` is
|
|
128
128
|
`nonzero` (SVG's default; a self-intersecting outline fills its
|
|
129
129
|
overlaps) or `evenodd` (the `polygon`'s rule, kept there unchanged).
|
|
130
|
-
Joins and caps are round, as a `line`'s are; `dash` stays V2's
|
|
130
|
+
Joins and caps are round, as a `line`'s are; `dash` stays V2's
|
|
131
|
+
(built 2026-10-05: the stroke's mask is cut by the pattern, see
|
|
132
|
+
[ADR 0010's amendment](0010-a-segment-primitive.md#amendment-a-dash-is-cut-in-the-core)).
|
|
131
133
|
4. **Rasterized on the CPU, through zeno, at the node's physical scale.**
|
|
132
134
|
The ops are transformed to physical px and rasterized into an 8-bit
|
|
133
135
|
coverage mask the size of the outline's bounding box plus a pixel on
|
|
@@ -181,7 +183,8 @@ date: 2026-10-04
|
|
|
181
183
|
that is different each frame; a shape that must move cheaply every
|
|
182
184
|
frame at any size stays a `polygon`, whose SDF costs the quad and
|
|
183
185
|
nothing else. A path still for two frames after animating stays
|
|
184
|
-
texture-backed; the rule is one-way, as the image's is.
|
|
186
|
+
texture-backed; the rule is one-way, as the image's is. *(Amended:
|
|
187
|
+
it returns after 120 still frames — see the second review below.)*
|
|
185
188
|
9. **Hit by its outline, nonzero or even-odd as it fills.** Emission
|
|
186
189
|
flattens the ops once — the same flattening the rasterizer gets — into
|
|
187
190
|
`Interaction::shape_points`, and `HitShape::Polygon` gains the fill
|
|
@@ -399,6 +402,45 @@ allocation, not the core, was the difference, and the bench now builds
|
|
|
399
402
|
its geometry once, as the polygon bench's points are. An app keeps its
|
|
400
403
|
geometry too.
|
|
401
404
|
|
|
405
|
+
### Second review (2026-10-05, before the alpha.36 tag and after it)
|
|
406
|
+
|
|
407
|
+
The pre-tag regression pass read the built element again (backlog
|
|
408
|
+
RG107–RG112). What it changed in the decisions above:
|
|
409
|
+
|
|
410
|
+
- **Decision 8 is not one-way.** An animating key whose shape has held
|
|
411
|
+
for 120 frames (`path::SETTLED_AFTER`, a second at 120 Hz) is a shape
|
|
412
|
+
again: its mask goes back to the atlas and its texture is dropped.
|
|
413
|
+
The latch was for what moves; held for good it also caught a key from
|
|
414
|
+
the tree position whose siblings came and went twice within the
|
|
415
|
+
window, and a spinner that had stopped — each a texture, a bind group
|
|
416
|
+
and a draw of its own for the life of the node. A pause shorter than
|
|
417
|
+
that stays out, so a spinner that stutters does not churn the page,
|
|
418
|
+
and moving again takes two changes, as the first time. The image's
|
|
419
|
+
rule is unchanged: an image's backing is declared by its updates, a
|
|
420
|
+
path's is inferred.
|
|
421
|
+
- **Decision 9, with a stroke over a fill.** A path that may paint a
|
|
422
|
+
fill — a `bg`, or one a hover, a press or the focus brings — and has
|
|
423
|
+
a stroke is hit by the fill *or* within half the stroke's width of
|
|
424
|
+
the stroke's pieces (`HitShape::Path`'s `stroke`), so the outer half
|
|
425
|
+
of a thick stroke is hit where it is painted. A stroke with no fill
|
|
426
|
+
at all is still `HitShape::Segments`, with its minimum grab.
|
|
427
|
+
- **A texture of its own is the session's to drop when its window
|
|
428
|
+
goes.** A core's `PathTextures` hands what it still holds to the
|
|
429
|
+
session as it is dropped, so the next list any window builds carries
|
|
430
|
+
the drop, as a removed image's does; and a frame whose list nobody
|
|
431
|
+
read (`Core::output`) hands its drops to the next, since an animating
|
|
432
|
+
path sweeps a texture every frame and a frame built twice before a
|
|
433
|
+
render lost one each time.
|
|
434
|
+
- **The flat form is checked in the core.** `Core::path_flat_node`
|
|
435
|
+
(and `Content::PathFlat` for a binding that lowers props) reads the
|
|
436
|
+
floats and raises `path-malformed` under the node's key when they are
|
|
437
|
+
not the form — what C and Node drew nothing for in silence and Lua
|
|
438
|
+
failed the view for. A number that is not finite, in either form or
|
|
439
|
+
in the turn, is `path-malformed` too (RG107).
|
|
440
|
+
- **The limit is the device's.** `kui-wgpu` opens its device with
|
|
441
|
+
wgpu's default limits, so 8192 is what every device it draws on
|
|
442
|
+
holds; the gate counts the mask's own margin (RG111).
|
|
443
|
+
|
|
402
444
|
## Action items — all done 2026-10-05
|
|
403
445
|
|
|
404
446
|
- [x] `crates/kui-core`: `path.rs` (ops, store, `Path` builder,
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: accepted
|
|
3
|
+
date: 2026-10-05
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# A gradient is an image the core paints: `gradient` on a box, rasterized once into the atlas
|
|
7
|
+
|
|
8
|
+
> **Accepted and built 2026-10-05**, the day it was proposed; the
|
|
9
|
+
> *Amendment* at the end records what the building changed — a malformed
|
|
10
|
+
> gradient is an error where it is declared and not a warning, the
|
|
11
|
+
> atlas's texels are straight alpha, the slot needs a gutter, and the
|
|
12
|
+
> ABI did not bump a second time — and *Measured* what the table of
|
|
13
|
+
> measurements read: the square's size holds, and the bound on a
|
|
14
|
+
> gradient box's cost did not. Asked the
|
|
15
|
+
> day `dash` was (backlog V2): "the next would be gradient backgrounds".
|
|
16
|
+
> [ADR 0005](0005-the-paint-vocabulary.md) declined gradients for v0 and
|
|
17
|
+
> wrote down why — a stop list to parse in five bindings, a type, a
|
|
18
|
+
> geometry, an interpolation space "that looks arbitrary in every
|
|
19
|
+
> choice", and on the wire either a second colour, a direction and a
|
|
20
|
+
> discriminant on the quad or a texture per distinct gradient. Since
|
|
21
|
+
> then [ADR 0015](0015-a-fragment-element-and-the-painter-it-is-not.md)
|
|
22
|
+
> gave the escape hatch (a `fragment` holding children is a gradient
|
|
23
|
+
> card today), [ADR 0025](0025-the-image-is-the-canvas.md) made an image
|
|
24
|
+
> a thing the core stretches over a box with linear sampling, and
|
|
25
|
+
> [ADR 0040](0040-a-path-is-a-mask-in-the-atlas.md) put a shape the core
|
|
26
|
+
> rasterizes into the atlas beside the glyphs. This document takes the
|
|
27
|
+
> second of ADR 0005's two wire shapes and argues it is no longer
|
|
28
|
+
> expensive: the gradient is rasterized on the CPU, once per distinct
|
|
29
|
+
> gradient, into a small image in the atlas, and the box draws it with
|
|
30
|
+
> the `Image` quad every backend already has. No quad kind, no shader
|
|
31
|
+
> change. It supersedes ADR 0005's *Gradients: out of scope for v0* when
|
|
32
|
+
> accepted, and nothing else of that document.
|
|
33
|
+
|
|
34
|
+
## Context
|
|
35
|
+
|
|
36
|
+
- **What a gradient costs an app today.** `add_fragment(wgsl)` and a
|
|
37
|
+
`<fragment>` box: the app writes WGSL, the stops ride in sixteen
|
|
38
|
+
`params`, and on the wire it is a `Fragment` quad — one pipeline
|
|
39
|
+
switch per run of them, and nothing at all in a host whose renderer
|
|
40
|
+
has no WGSL. That is the right price for a ring, noise or a shimmer,
|
|
41
|
+
which nothing else can draw. It is a high one for "this card fades
|
|
42
|
+
from one colour to another", which is what was asked for, and it is
|
|
43
|
+
why `docs/status.md` has to say "there are no gradient props".
|
|
44
|
+
- **An `Image` quad already does the drawing.** It samples atlas RGBA
|
|
45
|
+
over its `rect`, linearly unless told `nearest`, tinted by `color`
|
|
46
|
+
(which is where group opacity goes) and rounded by `radius` "like a
|
|
47
|
+
solid". A horizontal two-stop gradient *is* a strip of texels
|
|
48
|
+
stretched over a box. Nothing about the quad or the backend needs to
|
|
49
|
+
know the texels were computed and not decoded.
|
|
50
|
+
- **The atlas is `Rgba8Unorm` and filtered linearly**, so what the
|
|
51
|
+
sampler interpolates between two texels is the straight sRGB mix —
|
|
52
|
+
the interpolation `Color::lerp` does for a colour transition and the
|
|
53
|
+
one CSS does by default. A stretched ramp and a tween agree.
|
|
54
|
+
- **The core already rasterizes for the atlas.** Paths are masks keyed
|
|
55
|
+
by a hash, made on first sight, shared by every node that draws the
|
|
56
|
+
same one, under the page's reset-and-copy policy. A gradient is the
|
|
57
|
+
same arrangement with four channels.
|
|
58
|
+
- **`bg` is a colour everywhere.** `VisualStyle::bg` is a `Color` on a
|
|
59
|
+
struct every node copies; it is what `transition`, `enter`, `exit`
|
|
60
|
+
and `keyframes` ease, what `hoverBg`, `pressedBg`, `focusBg` and
|
|
61
|
+
`dropBg` replace, what a `$token` resolves into, and a `uint32_t` in
|
|
62
|
+
`KuiSpec`. Widening it to "a colour or a paint" touches all of that
|
|
63
|
+
and the hot struct the frame benches guard.
|
|
64
|
+
- **Stops are a list, and one list-shaped row exists.** `keyframes` is
|
|
65
|
+
a list of stops parsed once in the core from the shared `Value`
|
|
66
|
+
(`keyframes::parse`), spelled as an array in JSX, a table in Lua and
|
|
67
|
+
a struct array in C. ADR 0005 named exactly this as the cost of a
|
|
68
|
+
stop list; it has been paid once.
|
|
69
|
+
|
|
70
|
+
## Decisions
|
|
71
|
+
|
|
72
|
+
1. **`gradient` is a row of its own, on anything that paints a `bg`
|
|
73
|
+
box.** It is not a value of `bg`. The gradient is painted **over**
|
|
74
|
+
`bg` and under the border and the children: `bg` stays a colour,
|
|
75
|
+
shows through transparent stops, and keeps everything it has —
|
|
76
|
+
tweens, state backgrounds, tokens. A box with only a `gradient` is
|
|
77
|
+
one quad; with a visible `bg` under it, two; with a border, one more
|
|
78
|
+
for the ring. `hoverBg` and its siblings replace `bg` as they do
|
|
79
|
+
now and do not touch the gradient: a gradient button's hover is a
|
|
80
|
+
second `gradient` chosen by the view, or a translucent `hoverBg`
|
|
81
|
+
under translucent stops.
|
|
82
|
+
2. **Linear and radial.** A gradient is a type, a geometry and two or
|
|
83
|
+
more stops:
|
|
84
|
+
- linear — a direction, as `to` (`right`, `bottom`, `bottom right`,
|
|
85
|
+
the eight sides and corners) or as `angle` in turns, 0 pointing
|
|
86
|
+
east and running clockwise, the convention `Path::sector` and
|
|
87
|
+
`rotate` already have;
|
|
88
|
+
- radial — a centre `at` (fractions of the box, default the middle)
|
|
89
|
+
and an ellipse that reaches the farthest corner, CSS's default
|
|
90
|
+
`ellipse farthest-corner`.
|
|
91
|
+
|
|
92
|
+
Conic is not in this document (see *Considered options*).
|
|
93
|
+
3. **Stops are colours with optional positions.** `[colour, at]` with
|
|
94
|
+
`at` in 0–1; a stop without one is spaced evenly between its
|
|
95
|
+
neighbours that have, the first defaulting to 0 and the last to 1,
|
|
96
|
+
as CSS spaces them. A position less than the one before it is
|
|
97
|
+
raised to it. A colour is whatever the binding's `bg` takes,
|
|
98
|
+
`$tokens` included, resolved where the row is lowered — so a token
|
|
99
|
+
stop follows the theme because the view is rebuilt when it changes,
|
|
100
|
+
like every other token.
|
|
101
|
+
4. **Geometry is in the box's unit square.** The gradient is defined
|
|
102
|
+
on (0,0)–(1,1) and stretched to the box. For `to right` and
|
|
103
|
+
`to bottom` that is CSS exactly. For a corner it is CSS's corner
|
|
104
|
+
keyword exactly (`to bottom right` puts the 50% line through the
|
|
105
|
+
other two corners, which is what a diagonal in the unit square
|
|
106
|
+
stretched to the box does). For any other `angle` it is **not**
|
|
107
|
+
CSS's `<angle>`, which is measured in pixels and so depends on the
|
|
108
|
+
box's aspect: a kui `angle` of an eighth of a turn runs corner to
|
|
109
|
+
corner at every aspect, where CSS's `45deg` does not. This is the
|
|
110
|
+
decision that makes the rest cheap — see 6 — and the one place the
|
|
111
|
+
spelling means something a web developer has to be told.
|
|
112
|
+
5. **Rasterized on the CPU, into the atlas, as an RGBA image.** A
|
|
113
|
+
linear gradient along an axis is a strip, 256 × 1 or 1 × 256. Every
|
|
114
|
+
other one — an angle, a corner, a radial — is a square, 128 × 128
|
|
115
|
+
(the sizes are the measurement's to confirm). The box draws it with
|
|
116
|
+
one `Image` quad: the box's rect, its radii, linear sampling, the
|
|
117
|
+
group opacity in `color`. No `QuadKind`, no field with a new
|
|
118
|
+
meaning, nothing for `kui-wgpu` or a C host's renderer to learn — a
|
|
119
|
+
host that draws an image draws a gradient.
|
|
120
|
+
6. **Keyed by what it is, not where it is.** The key is a hash of the
|
|
121
|
+
type, the geometry and the resolved stops. Because of 4 the box's
|
|
122
|
+
size, aspect and scale are not in it: a gradient is rasterized once
|
|
123
|
+
and then every node that declares it, at any size, on any monitor,
|
|
124
|
+
through any resize or layout animation, draws the same slot. A
|
|
125
|
+
thousand rows with one gradient are one slot; a heat-mapped list
|
|
126
|
+
with a thousand different ones is a thousand strips, 256 texels
|
|
127
|
+
each.
|
|
128
|
+
7. **Stops interpolate in straight sRGB, alpha premultiplied.** The
|
|
129
|
+
same mix as `Color::lerp`, and CSS's default. The raster computes
|
|
130
|
+
each texel from the stops; the sampler's linear filter between
|
|
131
|
+
texels is the same mix, so the two agree. Alpha is interpolated
|
|
132
|
+
premultiplied, as CSS does, so `transparent` to a colour does not
|
|
133
|
+
pass through grey. Because the raster is the core's and not a
|
|
134
|
+
shader's, another space (`oklab`) is later a value on the row and
|
|
135
|
+
thirty lines in one function, not a branch in every backend — ADR
|
|
136
|
+
0005's "arbitrary in every choice" stops being a wire decision.
|
|
137
|
+
8. **It does not tween.** `transition` eases `bg` under it and
|
|
138
|
+
`opacity` fades it with the box; a `gradient` that differs from last
|
|
139
|
+
frame's is simply a different gradient, drawn at once. One that
|
|
140
|
+
changes every frame is a raster a frame (256 texels, or 16k) and a
|
|
141
|
+
slot a frame, which the page's reset policy absorbs and which is not
|
|
142
|
+
what this is for: a shimmer, a moving sheen and anything driven by
|
|
143
|
+
time stay a `fragment`'s, which reads `time` and re-rasterizes
|
|
144
|
+
nothing.
|
|
145
|
+
9. **Spelled like `keyframes`, parsed once in the core.**
|
|
146
|
+
- JSX: `gradient={{ to: 'bottom', stops: ['#1e2030', '#14161e'] }}`,
|
|
147
|
+
`gradient={{ angle: 0.125, stops: [['$accent', 0], ['#0000', 0.8]] }}`,
|
|
148
|
+
`gradient={{ radial: true, at: [0.5, 0], stops: [...] }}`.
|
|
149
|
+
- Lua: the same table, `at` and stops as `{colour, position}`.
|
|
150
|
+
- Rust: `NodeSpec::gradient(Gradient::linear_to(Side::Bottom, &[...]))`,
|
|
151
|
+
`Gradient::angle(turns, ...)`, `Gradient::radial(...)`, each stop a
|
|
152
|
+
`Color` or `(Color, f32)`.
|
|
153
|
+
- C: `const KuiGradient *gradient` on `KuiSpec` — a type, an angle,
|
|
154
|
+
a centre and a `KuiGradientStop` array, as `keyframes` is a
|
|
155
|
+
`KuiKeyframe` array — NULL for none.
|
|
156
|
+
|
|
157
|
+
A list with fewer than two stops, a `to` nobody spells or a number
|
|
158
|
+
that is not finite draws no gradient and raises
|
|
159
|
+
`gradient-malformed` once per key, as a malformed `d` does.
|
|
160
|
+
10. **Boxes only.** `gradient` is honoured wherever a `bg` paints a
|
|
161
|
+
box. It is not a `path`'s fill, a `polygon`'s, a `line`'s stroke, a
|
|
162
|
+
border's colour or a text's: a path's fill is a one-channel mask
|
|
163
|
+
tinted by one colour, and a gradient under a mask is two textures
|
|
164
|
+
in one quad, which is a different document. Each is listed under
|
|
165
|
+
*Open questions* with what would build it.
|
|
166
|
+
11. **Ghosts, hit-testing, access.** A departing box's ghost carries
|
|
167
|
+
its gradient's key and draws the same slot. The hit region and the
|
|
168
|
+
access row are the box's and do not change. The devtools' node
|
|
169
|
+
inspector shows the row as declared.
|
|
170
|
+
|
|
171
|
+
## Considered options
|
|
172
|
+
|
|
173
|
+
- **Leave it: a gradient is a fragment.** What is built, and still the
|
|
174
|
+
answer for anything animated or procedural. Kept beside this, not
|
|
175
|
+
replaced by it. Rejected as the *only* answer because the commonest
|
|
176
|
+
paint after a flat colour should not need a shader language, a
|
|
177
|
+
pipeline switch per run, or a renderer that compiles WGSL.
|
|
178
|
+
- **A stock gradient fragment**, as `polygon` is a stock fragment: the
|
|
179
|
+
stops in the sixteen params, the WGSL registered by the core. No new
|
|
180
|
+
quad kind, exact at any angle and any stop. But three stops fill the
|
|
181
|
+
params (four floats each, plus geometry), it is still a pipeline
|
|
182
|
+
switch per run — a list of gradient rows interleaved with text is a
|
|
183
|
+
switch per row — and a host without WGSL draws nothing. Not taken.
|
|
184
|
+
- **A `Gradient` quad kind, shaded by the backend.** A ramp strip in
|
|
185
|
+
the atlas, its texels in `uv`, the geometry in the slots a solid does
|
|
186
|
+
not use, and the fragment stage computes `t` per pixel: linear,
|
|
187
|
+
radial and conic are three branches, hard stops are exact, and a CSS
|
|
188
|
+
`<angle>` in pixels is free. This is the better picture. It is also a
|
|
189
|
+
new branch in every renderer of the list, a kind an older host
|
|
190
|
+
silently does not draw, and a second texture read on a path it turns
|
|
191
|
+
hot. Not taken now; it is what *Measurements to take* is deciding
|
|
192
|
+
against, and the row's spelling is the same either way, so it can
|
|
193
|
+
replace decision 5 without changing an app.
|
|
194
|
+
- **A second colour and an angle on the solid quad.** ADR 0005's first
|
|
195
|
+
shape: two stops only, in `blur` and a word of `uv`. Free in quads
|
|
196
|
+
and exact — and three stops are a different mechanism, which is two
|
|
197
|
+
mechanisms. Rejected.
|
|
198
|
+
- **`bg` takes a gradient.** `bg="linear-gradient(…)"`, CSS's own
|
|
199
|
+
spelling. One row instead of two and nothing to learn. But `bg` is a
|
|
200
|
+
`Color` in the style every node carries, the slot five other rows
|
|
201
|
+
replace and four animations ease, and a `uint32_t` in C; every one of
|
|
202
|
+
those then has to say what it does with a paint that is not a colour.
|
|
203
|
+
Rejected for decision 1, which costs a row and leaves them alone.
|
|
204
|
+
- **A CSS string, parsed in the core** as `d` and the size expressions
|
|
205
|
+
are. Attractive for paste-from-a-design-tool, and `$tokens` and the
|
|
206
|
+
four bindings' own colour spellings would each need an answer inside
|
|
207
|
+
the string. Not taken; a `Gradient::parse_css` is an addition the day
|
|
208
|
+
someone pastes one, and does not change the row.
|
|
209
|
+
- **A registered resource**, `addGradient(spec) → id` and
|
|
210
|
+
`gradient={id}`, like a sound or an image. One `u64` a frame and no
|
|
211
|
+
parse. But a gradient is five numbers and two colours, not a megabyte
|
|
212
|
+
of pixels; a handle makes the view name something it could have
|
|
213
|
+
said, makes token stops stale at the next theme change, and makes a
|
|
214
|
+
data-driven list register and remove a thousand of them. Rejected;
|
|
215
|
+
the hash in decision 6 is the handle, and the core holds it.
|
|
216
|
+
- **Rasterize at the box's size**, as a path's mask is, keyed by size
|
|
217
|
+
and scale. Exact hard stops and CSS's pixel angles — and megabytes
|
|
218
|
+
for a window-sized background, a raster on every frame of a resize,
|
|
219
|
+
and the atlas's thrash rule one hero banner away. Rejected for 4
|
|
220
|
+
and 6.
|
|
221
|
+
- **Conic.** A conic gradient's seam is a hard edge at an angle, which
|
|
222
|
+
a 128-texel raster stretched to a box draws as a soft staircase; and
|
|
223
|
+
a conic stretched to a non-square box is not what anyone means. Its
|
|
224
|
+
uses — a colour wheel, a pie, a spinner — are a `path`'s sectors, a
|
|
225
|
+
`rotate` or a fragment already. Not built here; it is the first thing
|
|
226
|
+
the quad-kind option would be reopened for.
|
|
227
|
+
|
|
228
|
+
## Consequences
|
|
229
|
+
|
|
230
|
+
- **A gradient costs an image quad.** No pipeline switch, no texture of
|
|
231
|
+
its own, the same batch as the glyphs around it. The raster is paid
|
|
232
|
+
the first frame a gradient is seen and after an atlas reset.
|
|
233
|
+
- **No renderer changes and no new quad meaning.** `kui-wgpu` is
|
|
234
|
+
untouched; a C host draws gradients the day it links the new
|
|
235
|
+
library. The wire grows a row, not a kind.
|
|
236
|
+
- **Hard stops are soft.** Two stops at one position are an edge in the
|
|
237
|
+
raster, and the raster is stretched: along a strip the edge is the
|
|
238
|
+
box's length over 256 wide (4 px on a 1000 px box), on a square it
|
|
239
|
+
is a 128th of the box each way. A smooth gradient cannot show this;
|
|
240
|
+
stripes can. Stripes are boxes, or a fragment, and the row's doc
|
|
241
|
+
says so.
|
|
242
|
+
- **`angle` is not CSS's.** Decision 4. The doc comment, `props.md` and
|
|
243
|
+
the how-to each say it in one sentence, with the corner case as the
|
|
244
|
+
example.
|
|
245
|
+
- **8-bit banding is what it is everywhere.** A ramp of 8 levels over
|
|
246
|
+
1000 px bands in a browser and bands here; the atlas holds what the
|
|
247
|
+
framebuffer holds. No dither.
|
|
248
|
+
- **Up to three quads for a bordered gradient box over a `bg`.** The
|
|
249
|
+
ring is a solid with a transparent fill, drawn after the image so
|
|
250
|
+
the gradient does not cover the border's inner edge. A `quadCount`
|
|
251
|
+
budget should expect it.
|
|
252
|
+
- **A second row to explain beside `bg`.** "The gradient paints over
|
|
253
|
+
the background" is the sentence; the state backgrounds not replacing
|
|
254
|
+
it is the surprise, and gets a how-to entry.
|
|
255
|
+
- **ABI and wire.** `KuiSpec` gains a pointer and two structs are new,
|
|
256
|
+
so `KUI_ABI_VERSION` bumps; the Node wire bumps for the row's value
|
|
257
|
+
kind. No quad or draw-data layout changes.
|
|
258
|
+
- **ADR 0005 is partly superseded**, and `docs/status.md` loses "no
|
|
259
|
+
gradient props" for "linear and radial on a box; conic, animated and
|
|
260
|
+
anything on a shape are a fragment's".
|
|
261
|
+
|
|
262
|
+
## Open questions
|
|
263
|
+
|
|
264
|
+
- **The atlas's alpha convention.** Decision 7 wants premultiplied
|
|
265
|
+
interpolation; whether the image slots hold straight or premultiplied
|
|
266
|
+
RGBA, and so what the sampler's own filter does between a transparent
|
|
267
|
+
and an opaque texel, is to be read in `atlas.rs` and the shader before
|
|
268
|
+
building, and decides whether the raster stores premultiplied texels
|
|
269
|
+
or straight ones with the colour bled into the transparent side.
|
|
270
|
+
- **The half-texel at each end.** An `Image` quad maps its rect to the
|
|
271
|
+
slot's whole texels, so the outer half-texel of a ramp is flat: a
|
|
272
|
+
512th of the length on a strip, a 256th on a square. Probably
|
|
273
|
+
invisible; if not, the raster extrapolates its end texels by half a
|
|
274
|
+
step rather than the quad learning fractional `uv`.
|
|
275
|
+
- **Does the slot bleed.** Slots are padded a texel; a stretched image
|
|
276
|
+
samples at its edge. Images already do this, so it should hold — to
|
|
277
|
+
confirm with a coverage test, not by reading.
|
|
278
|
+
- **A gradient under a mask**: a `path`'s fill, and with it a
|
|
279
|
+
polygon's, a text's and a stroke's. One quad sampling a mask and a
|
|
280
|
+
ramp is the quad-kind option again. **Condition:** a view that wants
|
|
281
|
+
a gradient-filled shape and cannot stack a fragment — a chart's area
|
|
282
|
+
fill is the likely first.
|
|
283
|
+
- **`gradient` in `keyframes`, `enter` and `exit`.** Not eased
|
|
284
|
+
(decision 8). A crossfade between two slots is two quads and an
|
|
285
|
+
alpha, cheap and not a true interpolation of stops; worth doing only
|
|
286
|
+
if a view asks for a gradient that changes on hover without popping.
|
|
287
|
+
- **`oklab`.** Decision 7 leaves the door; whether it should have been
|
|
288
|
+
the default is a taste question the default-is-CSS answer sidesteps.
|
|
289
|
+
- **Repeating gradients and `bgImage`.** A box whose background is any
|
|
290
|
+
image, with `fit` — CSS's `background-image` — falls out of the same
|
|
291
|
+
three-quad stack and is a row this document deliberately does not
|
|
292
|
+
add; `repeating-linear-gradient` would want it to tile.
|
|
293
|
+
|
|
294
|
+
## Measurements to take
|
|
295
|
+
|
|
296
|
+
| bench or test | what | held to |
|
|
297
|
+
|---|---|---|
|
|
298
|
+
| `frame_10k_rects_with_gradient` | the plain 10k grid, every cell declaring one shared gradient | within 10% of `frame_10k_rects`: the row's parse, a hash and an image quad for a solid one |
|
|
299
|
+
| `frame_1k_distinct_gradients` | 1k rows, each its own strip, steady state | within noise of the above per node: all hits |
|
|
300
|
+
| `raster_gradient_strip`, `raster_gradient_square` | one 256 × 1 and one 128 × 128 raster, three stops | recorded; the square is the number that says what a changing gradient costs a frame |
|
|
301
|
+
| `frame_10k_rects` and the other guarded rows | unchanged trees | unchanged: the row must cost nothing where it is not declared (`NodeSpec` and `emit_node` are the risk, as C41 and C48 found) |
|
|
302
|
+
| `kui-wgpu/tests/gradient_coverage.rs`, two stops | a strip stretched to 1000 px against the mix computed per pixel | every pixel within one 8-bit level |
|
|
303
|
+
| the same, a corner and a radial on a 3:1 box | the 128² square against the per-pixel reference | largest difference recorded; proposed bound two levels for a smooth ramp — the number that confirms or reopens the square's size |
|
|
304
|
+
| the same, a hard stop | the width of the soft edge | recorded, and quoted in the row's doc |
|
|
305
|
+
| the same, transparent to opaque | the midpoint's colour | the premultiplied mix, not grey |
|
|
306
|
+
|
|
307
|
+
If the corner and radial rows cannot be held with a square the atlas
|
|
308
|
+
can afford, the answer is the `Gradient` quad kind under *Considered
|
|
309
|
+
options*, and decisions 1–4 and 6–11 stand as written.
|
|
310
|
+
|
|
311
|
+
## Amendment: what the building changed (2026-10-05)
|
|
312
|
+
|
|
313
|
+
- **A malformed gradient is an error, not a warning.** Decision 9
|
|
314
|
+
proposed `gradient-malformed`, once per key. The row is carried and
|
|
315
|
+
parsed exactly as `keyframes` is, and a `keyframes` list that does not
|
|
316
|
+
parse fails the view in Node and Lua where it is declared; a second
|
|
317
|
+
convention for the row beside it would be the surprise. So: a `to`
|
|
318
|
+
nobody spells, an unknown field, fewer than two stops in the list or
|
|
319
|
+
a number that is not one is an error from `gradient::parse_with`,
|
|
320
|
+
with the field named. What still draws nothing in silence is a
|
|
321
|
+
gradient that *parsed* and has nothing to paint — every stop a token
|
|
322
|
+
that missed (each raised as `unknown-token`), or one built in Rust or
|
|
323
|
+
C with one stop or a NaN. No warning code was added.
|
|
324
|
+
- **The atlas holds straight alpha** (open question 1). An `Image`
|
|
325
|
+
quad's shader multiplies the texel's rgb by the tint and its alpha by
|
|
326
|
+
the coverage separately, so the texels are straight. The raster mixes
|
|
327
|
+
the stops premultiplied, as decision 7 says, and stores the result
|
|
328
|
+
un-premultiplied; a texel with no alpha keeps the straight mix of its
|
|
329
|
+
neighbours' colours, so the sampler does not darken the texel beside
|
|
330
|
+
it. `a_fade_to_transparent_keeps_its_colour` pins the mix.
|
|
331
|
+
- **The slot has a gutter, and it is not empty** (open questions 2
|
|
332
|
+
and 3; see *Measured*). The raster is the strip or the square with
|
|
333
|
+
one texel more all round, each the gradient carried on past the edge,
|
|
334
|
+
and the quad's `uv` is the rect inside. As first built there was no
|
|
335
|
+
gutter, and the coverage test's arithmetic said why there must be
|
|
336
|
+
before the test was written: a stretched quad's sampler reads half a
|
|
337
|
+
texel past the rect it is given, and a strip one texel high, drawn
|
|
338
|
+
over a box forty pixels high, was the gradient only along its middle
|
|
339
|
+
row and faded into whatever the atlas held above and below it.
|
|
340
|
+
- **One table in the atlas, not two.** A gradient's slot lives in the
|
|
341
|
+
keyed table a path's mask does (`get_or_insert_gradient` beside
|
|
342
|
+
`get_or_insert_path`), copied across a reset the same way. The keys
|
|
343
|
+
are hashes of different things; nothing else tells them apart, and
|
|
344
|
+
nothing needs to.
|
|
345
|
+
- **Where the quad goes.** `paint_box` is unchanged — it is on every
|
|
346
|
+
node's path and the frame benches guard it. A cold `paint_gradient`
|
|
347
|
+
runs after it, on frames whose tree has a gradient at all
|
|
348
|
+
(`Tree::any_gradient`), and puts the image where it belongs among the
|
|
349
|
+
quads the box just pushed: after the shadow and the background,
|
|
350
|
+
before the content, with the border lifted off the background's solid
|
|
351
|
+
into a transparent-filled ring above. A ghost reads the same row off
|
|
352
|
+
the spec it kept.
|
|
353
|
+
- **C's stops place themselves with a negative `at`.** `KuiGradientStop`
|
|
354
|
+
is a colour and an `at`; less than zero is "spaced between its
|
|
355
|
+
neighbours", since 0 is a position.
|
|
356
|
+
- **The ABI is 24, not 25.** `dash` had already bumped it in the same
|
|
357
|
+
unreleased section; `gradient` on `KuiSpec` (64-bit size 696) and the
|
|
358
|
+
two structs are in the same step. The Node wire stays v21: the row
|
|
359
|
+
rides as a string reference like `keyframes`, and a new row is not a
|
|
360
|
+
new layout.
|
|
361
|
+
- **`gradient` on a stroke or a fill is ignored**, in the live pass and
|
|
362
|
+
the ghost's: a `line`, a `polygon` and a `path` paint no box.
|
|
363
|
+
|
|
364
|
+
### Measured (2026-10-05, an M3 Pro; backlog V9)
|
|
365
|
+
|
|
366
|
+
`kui-wgpu/tests/gradient_coverage.rs` mirrors the shader's image branch
|
|
367
|
+
on the CPU — the atlas sampled linearly, nothing clamped to the slot —
|
|
368
|
+
over the quads a core emitted, with other gradients in the atlas on
|
|
369
|
+
either side, and compares every pixel of the box with the gradient
|
|
370
|
+
computed at that pixel, in 8-bit levels on the worst channel:
|
|
371
|
+
|
|
372
|
+
| what | box | worst pixel |
|
|
373
|
+
|---|---|---|
|
|
374
|
+
| a strip, each of the four sides | 1000 × 40 | 0.49 |
|
|
375
|
+
| a strip, black to white | 1000 × 40 | 0.50 |
|
|
376
|
+
| the square, a corner | 600 × 200 | 0.48 |
|
|
377
|
+
| the square, `angle` 0.07 | 600 × 200 | 0.52 |
|
|
378
|
+
| the square, a corner, black to white | 600 × 200 | 0.50 |
|
|
379
|
+
| the square, radial from the middle | 600 × 200 | 1.20 |
|
|
380
|
+
| the square, radial from the top edge | 600 × 200 | 0.75 |
|
|
381
|
+
| three stops, strip and corner | as above | 0.50 |
|
|
382
|
+
|
|
383
|
+
Half a level is the rounding of the texels themselves, so a linear
|
|
384
|
+
gradient through 128 texels is exact — a linear ramp is what a linear
|
|
385
|
+
filter reproduces — and **the square's size holds**: the proposed bound
|
|
386
|
+
was two levels, and the worst case, a radial's curvature between
|
|
387
|
+
texels, is 1.2. A hard stop on a strip over 1000 px is 4 px wide. A
|
|
388
|
+
fade to transparent is its colour at every alpha. Without the gutter
|
|
389
|
+
the same tests read 127 and 169 levels at the edges, which is the bug
|
|
390
|
+
the amendment above records.
|
|
391
|
+
|
|
392
|
+
`benches/frame.rs`, medians:
|
|
393
|
+
|
|
394
|
+
| bench | what | median |
|
|
395
|
+
|---|---|---|
|
|
396
|
+
| `frame_10k_rects` | the plain grid | 770 µs |
|
|
397
|
+
| `frame_10k_rects_with_gradient` | every cell's solid a gradient instead, all the same | 1.29 ms |
|
|
398
|
+
| `frame_1k_rects` | 32 × 32, plain | 78.3 µs |
|
|
399
|
+
| `frame_1k_shared_gradient` | the same, one gradient | 131 µs |
|
|
400
|
+
| `frame_1k_distinct_gradients` | the same, a gradient each | 134 µs |
|
|
401
|
+
| `raster_gradient_strip` | 258 × 3 texels, three stops | 4.0 µs |
|
|
402
|
+
| `raster_gradient_square` | 130 × 130, a corner | 24.8 µs |
|
|
403
|
+
| `raster_gradient_radial` | 130 × 130 | 41.9 µs |
|
|
404
|
+
|
|
405
|
+
(These five were taken again by the alpha.37 pre-tag pass, the same
|
|
406
|
+
day. As first measured they read 831 µs, 1.38 ms, 84.1, 140 and 143 µs:
|
|
407
|
+
the bench's grid chose a cell's paint in one `match` with the gradient
|
|
408
|
+
arms in it, and that cost every row built on the grid about 6 ns a
|
|
409
|
+
cell — `frame_10k_rects` 8% over alpha.36's with no line of the core
|
|
410
|
+
between them. The gradient cell is a function of its own now and the
|
|
411
|
+
plain rows are what they were.)
|
|
412
|
+
|
|
413
|
+
**The proposed bound on the first row was not held, and was the wrong
|
|
414
|
+
bound.** "Within 10% of `frame_10k_rects`" assumed a gradient box costs
|
|
415
|
+
what a solid one does. It costs about **52 ns more a node**: half of it
|
|
416
|
+
is the boxed group of rare rows the gradient lives in, which a
|
|
417
|
+
`hoverBg` pays as well (28 ns, measured by giving the plain grid a row
|
|
418
|
+
from that group), and the rest is the stops built and hashed by the
|
|
419
|
+
view every frame, the atlas lookup and the call out of `emit_node`. As
|
|
420
|
+
first built it was 105 ns; the direction is now read off a table for
|
|
421
|
+
the sides and corners instead of a cosine and a sine three times a
|
|
422
|
+
node, and up to four stops are held in place instead of in two lists.
|
|
423
|
+
The rasters were 90 µs and are 25: a square's texels read a ramp mixed
|
|
424
|
+
once instead of each mixing its own.
|
|
425
|
+
|
|
426
|
+
What it means: a card, a header and a row of buttons are microseconds,
|
|
427
|
+
and ten thousand gradient boxes are half a millisecond over ten
|
|
428
|
+
thousand flat ones — not free, and not what the row is for. Distinct
|
|
429
|
+
gradients cost 3 ns a node over a shared one. None of it is the wire
|
|
430
|
+
shape's doing: the quad-kind option would build, hash and box the same
|
|
431
|
+
row. What would lower it is the registered handle *Considered options*
|
|
432
|
+
set aside, or a `Gradient` an app builds once and clones; neither is
|
|
433
|
+
built, and the condition is a view that declares thousands.
|
|
434
|
+
|
|
435
|
+
The guarded rows are within noise of alpha.36 (`bench-check`): a tree
|
|
436
|
+
with no gradient pays one flag test a node.
|
|
437
|
+
|
|
438
|
+
## Action items — all done 2026-10-05
|
|
439
|
+
|
|
440
|
+
1. Read the atlas's alpha convention and its image sampling at a slot's
|
|
441
|
+
edge; settle the first three open questions.
|
|
442
|
+
2. `gradient.rs` in the core: the type, `parse(&Value)`, the stop
|
|
443
|
+
spacing, the two rasters, the hash; unit tests against a per-pixel
|
|
444
|
+
reference.
|
|
445
|
+
3. The atlas slot, the `Image` quad and the three-quad order in
|
|
446
|
+
`emit`; the ghost.
|
|
447
|
+
4. The row in `schema::PROPS` and its four doors; `KuiGradient` and the
|
|
448
|
+
ABI bump; the Node wire bump; `gradient-malformed`.
|
|
449
|
+
5. A `gradients` scene in the corpus; the benches and coverage tests
|
|
450
|
+
above.
|
|
451
|
+
6. The rainbow in `examples/rust/apps/loaders.rs` — a gradient two
|
|
452
|
+
tracks long slid under a clip, one strip for good — in place of the
|
|
453
|
+
`features/gradient.rs` first proposed; the how-to
|
|
454
|
+
entry rewritten ("a gradient: the row; a ring, noise or a shimmer:
|
|
455
|
+
a fragment"); `status.md`, ADR 0005's note, the changelog.
|
package/encoder.js
CHANGED
|
@@ -87,6 +87,23 @@ export function createEncoder(P) {
|
|
|
87
87
|
// The wire index of `$name` in the `kind` space, or `undefined` for a
|
|
88
88
|
// name that resolved to nothing or to the other kind — reported like an
|
|
89
89
|
// unknown prop, and the slot is left out so the core keeps its default.
|
|
90
|
+
// A stroke's `dash` and `dashOffset` as the five floats the stream
|
|
91
|
+
// carries — a mark, a gap, a mark, a gap, the offset — or null for a
|
|
92
|
+
// solid stroke. `dash` is one length (marks and gaps alike), a
|
|
93
|
+
// [mark, gap] pair, or four lengths for a dash-dot (backlog V2).
|
|
94
|
+
function dashOf(p, tag) {
|
|
95
|
+
const d = p.dash;
|
|
96
|
+
const finite = (n) => typeof n === 'number' && Number.isFinite(n);
|
|
97
|
+
if (p.dashOffset !== undefined && !finite(p.dashOffset)) throw new Error(`bad dashOffset ${JSON.stringify(p.dashOffset)} for <${tag}> (px, a number)`);
|
|
98
|
+
if (d == null) return null;
|
|
99
|
+
const l = typeof d === 'number' ? [d] : d;
|
|
100
|
+
if (!Array.isArray(l) || !(l.length === 1 || l.length === 2 || l.length === 4) || !l.every(finite)) {
|
|
101
|
+
throw new Error(`bad dash ${JSON.stringify(d)} for <${tag}> (a length, [mark, gap] or [mark, gap, mark, gap], in px)`);
|
|
102
|
+
}
|
|
103
|
+
const [a, b = a, c = a, e = b] = l;
|
|
104
|
+
return [a, b, c, e, p.dashOffset ?? 0];
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
function tokenRef(v, kind) {
|
|
91
108
|
const name = v.slice(1);
|
|
92
109
|
const hit = ROLE_TOKENS.get(name) ?? tokens?.get(name);
|
|
@@ -597,6 +614,7 @@ export function createEncoder(P) {
|
|
|
597
614
|
case 'tag':
|
|
598
615
|
case 'keyframes':
|
|
599
616
|
case 'enter':
|
|
617
|
+
case 'gradient':
|
|
600
618
|
strRef(JSON.stringify(v));
|
|
601
619
|
break;
|
|
602
620
|
case 'str':
|
|
@@ -1106,16 +1124,18 @@ export function createEncoder(P) {
|
|
|
1106
1124
|
if (p.rotate !== undefined && (typeof p.rotate !== 'number' || !Number.isFinite(p.rotate))) throw new Error(`bad rotate ${JSON.stringify(p.rotate)} for <path> (turns, a number)`);
|
|
1107
1125
|
const pivot = p.pivot;
|
|
1108
1126
|
if (pivot !== undefined && !(Array.isArray(pivot) && pivot.length === 2 && pivot.every((n) => typeof n === 'number' && Number.isFinite(n)))) throw new Error(`bad pivot ${JSON.stringify(pivot)} for <path> ([x, y])`);
|
|
1109
|
-
|
|
1127
|
+
const pathDash = dashOf(p, 'path');
|
|
1128
|
+
reserve(15 + (flat ? flat.length : 0));
|
|
1110
1129
|
f[fi++] = OP.path;
|
|
1111
1130
|
strRef(flat ? null : d);
|
|
1112
1131
|
f[fi++] = flat ? flat.length : 0;
|
|
1113
1132
|
if (flat) for (const n of flat) f[fi++] = n;
|
|
1114
1133
|
f[fi++] = widthRef !== undefined ? widthRef : typeof p.width === 'number' ? p.width : 0;
|
|
1115
|
-
f[fi++] = (widthRef !== undefined ? 1 : 0) | (p.fillRule === 'evenodd' ? 2 : 0) | (p.rotate !== undefined ? 4 : 0) | (pivot !== undefined ? 8 : 0);
|
|
1134
|
+
f[fi++] = (widthRef !== undefined ? 1 : 0) | (p.fillRule === 'evenodd' ? 2 : 0) | (p.rotate !== undefined ? 4 : 0) | (pivot !== undefined ? 8 : 0) | (pathDash ? 16 : 0);
|
|
1116
1135
|
f[fi++] = p.rotate ?? 0;
|
|
1117
1136
|
f[fi++] = pivot ? pivot[0] : 0;
|
|
1118
1137
|
f[fi++] = pivot ? pivot[1] : 0;
|
|
1138
|
+
if (pathDash) for (const n of pathDash) f[fi++] = n;
|
|
1119
1139
|
props(p, el.key, false);
|
|
1120
1140
|
return;
|
|
1121
1141
|
}
|
|
@@ -1135,7 +1155,8 @@ export function createEncoder(P) {
|
|
|
1135
1155
|
// one that does not resolve is left out, the default stroke.
|
|
1136
1156
|
const widthRef = isRef(p.width) ? tokenRef(p.width, 'length') : undefined;
|
|
1137
1157
|
if (p.width !== undefined && typeof p.width !== 'number' && !isRef(p.width)) throw new Error(`bad width ${JSON.stringify(p.width)} for <line> (a stroke width in px, or a "$length")`);
|
|
1138
|
-
|
|
1158
|
+
const lineDash = dashOf(p, 'line');
|
|
1159
|
+
reserve(11 + pts.length * 2);
|
|
1139
1160
|
f[fi++] = OP.line;
|
|
1140
1161
|
f[fi++] = pts.length;
|
|
1141
1162
|
for (const pt of pts) {
|
|
@@ -1146,7 +1167,8 @@ export function createEncoder(P) {
|
|
|
1146
1167
|
f[fi++] = pt[1];
|
|
1147
1168
|
}
|
|
1148
1169
|
f[fi++] = widthRef !== undefined ? widthRef : typeof p.width === 'number' ? p.width : 1;
|
|
1149
|
-
f[fi++] = (p.curve ? 1 : 0) | (widthRef !== undefined ? 2 : 0);
|
|
1170
|
+
f[fi++] = (p.curve ? 1 : 0) | (widthRef !== undefined ? 2 : 0) | (lineDash ? 4 : 0);
|
|
1171
|
+
if (lineDash) for (const n of lineDash) f[fi++] = n;
|
|
1150
1172
|
props(p, el.key, false);
|
|
1151
1173
|
return;
|
|
1152
1174
|
}
|
package/howto.md
CHANGED
|
@@ -52,6 +52,21 @@ a nine-point curve over ~50 px spans is ~60 quads.
|
|
|
52
52
|
[ADR 0010](docs/adr/0010-a-segment-primitive.md) ·
|
|
53
53
|
[alpha.7](CHANGELOG.md#010-alpha7-2026-09-06)
|
|
54
54
|
|
|
55
|
+
### How do I draw a dashed line, a dotted one, or a marquee's marching ants?
|
|
56
|
+
|
|
57
|
+
`dash` on a `<line>` or on a `<path>`'s stroke: `dash={[6, 4]}` is 6 px
|
|
58
|
+
marks with 4 px gaps, `dash={4}` the same length for both, and four
|
|
59
|
+
lengths are a dash-dot. The lengths are what you see — every mark has the
|
|
60
|
+
stroke's round caps — so a mark no longer than the stroke is wide is a
|
|
61
|
+
dot: `width={3} dash={[3, 5]}` is a dotted line. The pattern runs along
|
|
62
|
+
the whole stroke, round corners and along a `curve`. For marching ants,
|
|
63
|
+
grow `dashOffset` from a tick; the marks move towards the first point.
|
|
64
|
+
A dashed line costs a quad per mark, and is still hit in its gaps.
|
|
65
|
+
|
|
66
|
+
[`line` element](props.md#elements) ·
|
|
67
|
+
[ADR 0010's amendment](docs/adr/0010-a-segment-primitive.md#amendment-a-dash-is-cut-in-the-core) ·
|
|
68
|
+
`cargo run --example line`
|
|
69
|
+
|
|
55
70
|
### How do I make a tab bar whose tabs stop shrinking at their labels?
|
|
56
71
|
|
|
57
72
|
Give every tab `width="grow"` and `minWidth="fit"`: they split the bar evenly
|
|
@@ -277,6 +292,23 @@ is the names alone.
|
|
|
277
292
|
[Doors](props.md#doors) ·
|
|
278
293
|
[alpha.21 `### Added`](CHANGELOG.md#010-alpha21-2026-09-26)
|
|
279
294
|
|
|
295
|
+
### How do I choose what stands in for a character my font lacks?
|
|
296
|
+
|
|
297
|
+
Name the fonts, in the order they should be asked:
|
|
298
|
+
`ctx.setFallbackFonts([icons, shipped, mono])` (Rust
|
|
299
|
+
`Core::set_fallback_fonts(&[..])`, C `kui_font_set_fallback`), each an id
|
|
300
|
+
from `addFont`, `addSystemFont` or `loadFontFile`. A character the
|
|
301
|
+
text's own family has no glyph for goes to the first of them that has
|
|
302
|
+
it, and only then to the platform's list — whose first choice on macOS
|
|
303
|
+
is the system's interface face, so Cyrillic in a Latin-only monospaced
|
|
304
|
+
family comes out proportional. The list is the session's, for every
|
|
305
|
+
family and every kind of text; `[]` is the platform's alone. Set it
|
|
306
|
+
when the choice changes, not every frame with a new list: a new list
|
|
307
|
+
shapes every text again (the same one twice costs nothing). A cell grid
|
|
308
|
+
goes one step further by itself: with no fallback of yours that has the
|
|
309
|
+
character it asks a monospaced face before the platform's, and fits and
|
|
310
|
+
centres whatever it got in the cell.
|
|
311
|
+
|
|
280
312
|
### How do I see a font the user installed while the app runs?
|
|
281
313
|
|
|
282
314
|
On macOS and Windows a window app does nothing: the runner hears the OS
|
|
@@ -1326,7 +1358,28 @@ them sharing an edge show a hairline of the background through it.
|
|
|
1326
1358
|
[ADR 0040](docs/adr/0040-a-path-is-a-mask-in-the-atlas.md) ·
|
|
1327
1359
|
`cargo run --example path` · `cargo run --example polygon`
|
|
1328
1360
|
|
|
1329
|
-
### How do I
|
|
1361
|
+
### How do I give a box a gradient background?
|
|
1362
|
+
|
|
1363
|
+
`gradient` on the box: `gradient={{ to: 'bottom', stops: ['#1e2030',
|
|
1364
|
+
'#14161e'] }}` runs to a side or a corner, `{ angle: 0.125, stops }` along
|
|
1365
|
+
a direction in turns clockwise from east, and `{ radial: true, at: [0.5,
|
|
1366
|
+
0], stops }` out from a centre. A stop is a colour or `[colour, position]`.
|
|
1367
|
+
It paints over `bg` and under the border and the children, so a scrim is
|
|
1368
|
+
a gradient with a transparent stop over whatever is beneath. The geometry
|
|
1369
|
+
is the box's unit square stretched to the box — a corner is CSS's corner,
|
|
1370
|
+
and an `angle` runs corner to corner at an eighth of a turn whatever the
|
|
1371
|
+
aspect, which CSS's `45deg` does not.
|
|
1372
|
+
|
|
1373
|
+
It costs one image quad and is rasterized once per distinct gradient, so
|
|
1374
|
+
it does not tween and `hoverBg` does not replace it: to move a gradient,
|
|
1375
|
+
move the box that has it (the rainbow in `loaders` is one gradient slid
|
|
1376
|
+
under a clip); to animate its colours, write a fragment.
|
|
1377
|
+
|
|
1378
|
+
[`gradient` row](props.md#container-props) ·
|
|
1379
|
+
[ADR 0042](docs/adr/0042-a-gradient-is-an-image-the-core-paints.md) ·
|
|
1380
|
+
`cargo run --example loaders`
|
|
1381
|
+
|
|
1382
|
+
### How do I draw a ring, noise, a shimmer, or anything the paint props cannot?
|
|
1330
1383
|
|
|
1331
1384
|
Write a fragment. `add_fragment(wgsl)` validates one WGSL function and hands
|
|
1332
1385
|
back a handle; `<fragment src={id} params={[…]} animate>` is a box that
|
package/index.d.ts
CHANGED
|
@@ -2441,6 +2441,16 @@ export declare class Ctx {
|
|
|
2441
2441
|
* every frame.
|
|
2442
2442
|
*/
|
|
2443
2443
|
reloadSystemFonts(): number
|
|
2444
|
+
/**
|
|
2445
|
+
* The fonts asked, in order, for a character the text's own
|
|
2446
|
+
* family has no glyph for, before the platform's fallback list
|
|
2447
|
+
* — whose first choice on macOS is the system's proportional
|
|
2448
|
+
* face. Ids from `addFont` / `addSystemFont` / `loadFontFile`;
|
|
2449
|
+
* one that names no font is left out, and `[]` is the
|
|
2450
|
+
* platform's list alone. A new list shapes every text again;
|
|
2451
|
+
* the same list twice is nothing.
|
|
2452
|
+
*/
|
|
2453
|
+
setFallbackFonts(ids: Array<string>): void
|
|
2444
2454
|
removeFont(id: string): void
|
|
2445
2455
|
/**
|
|
2446
2456
|
* Family names of every font the core can see, installed or
|
|
@@ -3613,6 +3623,16 @@ export declare class KuiWindow {
|
|
|
3613
3623
|
* every frame.
|
|
3614
3624
|
*/
|
|
3615
3625
|
reloadSystemFonts(): number
|
|
3626
|
+
/**
|
|
3627
|
+
* The fonts asked, in order, for a character the text's own
|
|
3628
|
+
* family has no glyph for, before the platform's fallback list
|
|
3629
|
+
* — whose first choice on macOS is the system's proportional
|
|
3630
|
+
* face. Ids from `addFont` / `addSystemFont` / `loadFontFile`;
|
|
3631
|
+
* one that names no font is left out, and `[]` is the
|
|
3632
|
+
* platform's list alone. A new list shapes every text again;
|
|
3633
|
+
* the same list twice is nothing.
|
|
3634
|
+
*/
|
|
3635
|
+
setFallbackFonts(ids: Array<string>): void
|
|
3616
3636
|
removeFont(id: string): void
|
|
3617
3637
|
/**
|
|
3618
3638
|
* Family names of every font the core can see, installed or
|
package/jsx-runtime.d.ts
CHANGED
|
@@ -143,6 +143,31 @@ export interface EnterProp {
|
|
|
143
143
|
opacity?: number;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/** A gradient painted over a box's `bg`, under its border and children
|
|
147
|
+
* (docs/adr/0042-a-gradient-is-an-image-the-core-paints.md). Linear
|
|
148
|
+
* `to` a side or a corner (the default is `'bottom'`) or along `angle`,
|
|
149
|
+
* or `radial` from `at`. Defined on the box's unit square and stretched
|
|
150
|
+
* to it: a side or a corner is CSS's, and any other `angle` runs corner
|
|
151
|
+
* to corner at an eighth of a turn whatever the box's aspect, where
|
|
152
|
+
* CSS's `45deg` does not. Rasterized once per distinct gradient and
|
|
153
|
+
* drawn as one image quad; it does not tween. */
|
|
154
|
+
export interface GradientProp {
|
|
155
|
+
/** The side or corner a linear gradient runs to. */
|
|
156
|
+
to?: 'right' | 'bottom right' | 'bottom' | 'bottom left' | 'left' | 'top left' | 'top' | 'top right';
|
|
157
|
+
/** A linear gradient's direction in turns, clockwise from east: 0.25
|
|
158
|
+
* runs downwards. */
|
|
159
|
+
angle?: number;
|
|
160
|
+
/** Out from `at` to the box's farthest corner instead of along a line. */
|
|
161
|
+
radial?: boolean;
|
|
162
|
+
/** A radial gradient's centre as fractions of the box; the middle,
|
|
163
|
+
* `[0.5, 0.5]`, without one. */
|
|
164
|
+
at?: [number, number];
|
|
165
|
+
/** Two or more colours, each alone or as `[colour, position]` with the
|
|
166
|
+
* position 0 to 1. Stops without one are spaced evenly between those
|
|
167
|
+
* with; two at one position are a hard edge. */
|
|
168
|
+
stops: (ColorProp | [ColorProp, number])[];
|
|
169
|
+
}
|
|
170
|
+
|
|
146
171
|
export interface FloatProp {
|
|
147
172
|
/** The preset to start from; every key below overrides one of its
|
|
148
173
|
* values and leaving one out keeps the preset's own, so
|
|
@@ -276,6 +301,8 @@ export interface GeneratedSpecProps {
|
|
|
276
301
|
focusable?: boolean;
|
|
277
302
|
/** Space between children along the main axis. */
|
|
278
303
|
gap?: LengthProp;
|
|
304
|
+
/** A gradient painted over the node's `bg` and under its border and its children (`docs/adr/0042-a-gradient-is-an-image-the-core-paints.md`): `{ to: 'bottom', stops: [...] }` towards a side or a corner (`right`, `bottom left`, …; the default is `bottom`), `{ angle: 0.125, stops }` in turns clockwise from east, or `{ radial: true, at: [0.5, 0], stops }` out from a centre (fractions of the box, the middle by default) to its farthest corner. A stop is a colour — a `$token` too — or `[colour, position]` with the position 0 to 1; stops without one are spaced evenly between those with. Two stops at one position are a hard edge. The gradient is defined on the box's unit square and stretched to it, so a side or a corner is CSS's and any other `angle` runs corner to corner at an eighth of a turn whatever the box's aspect, where CSS's pixel-measured `45deg` does not. Stops mix in straight sRGB with the alpha premultiplied, as CSS's do. What it costs is one image quad: the core rasterizes each distinct gradient once into the glyph atlas — a 256-texel strip along an axis, a 128-texel square otherwise, within half an 8-bit level of the gradient computed per pixel for a linear one and 1.2 for a radial — keyed by the gradient and not the box, so a box that resizes and a thousand boxes that share one rasterize nothing, and a host that draws an image draws it; a gradient box costs about 55 ns over a flat one, so ten thousand of them are half a millisecond. A hard edge is as soft as the raster stretched to the box (a 256th of its length along a strip); stripes are boxes. It does not tween — `transition` eases the `bg` under it and `opacity` fades it — and `hoverBg` and the other state backgrounds replace `bg`, not the gradient; one that changes every frame is a raster a frame, and a shimmer is a `fragment`'s. Ignored on a `line`, a `polygon` and a `path`. Fewer than two stops are an error in JSX and Lua, as a malformed `keyframes` is, and draw nothing in Rust and C. */
|
|
305
|
+
gradient?: GradientProp;
|
|
279
306
|
/** Vertical size: px | "fit" | "grow" | "N%" | a size expression (see `width`). */
|
|
280
307
|
height?: SizingProp;
|
|
281
308
|
/** Background while hovered (or while any node in its hoverGroup is); implies hover tracking, eases with `transition`. */
|
|
@@ -727,7 +754,7 @@ export declare namespace JSX {
|
|
|
727
754
|
};
|
|
728
755
|
/** A box a registered WGSL function paints
|
|
729
756
|
* (docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md):
|
|
730
|
-
*
|
|
757
|
+
* a conic or a moving gradient, rings, noise, shimmer — anything the paint vocabulary has
|
|
731
758
|
* no prop for. An ordinary node otherwise: it lays out, rounds, clips,
|
|
732
759
|
* fades, takes input and holds children, which paint over it. It has
|
|
733
760
|
* **no intrinsic size**, so give it a `width`/`height` or `fill`.
|
|
@@ -812,7 +839,20 @@ export declare namespace JSX {
|
|
|
812
839
|
to?: [number, number];
|
|
813
840
|
points?: [number, number][];
|
|
814
841
|
curve?: boolean;
|
|
815
|
-
|
|
842
|
+
/** Cuts the stroke into marks and gaps (backlog V2): one length
|
|
843
|
+
* (marks and gaps alike), `[mark, gap]`, or `[mark, gap, mark,
|
|
844
|
+
* gap]` for a dash-dot, in px **as seen** — every mark is
|
|
845
|
+
* round-capped, so a mark no longer than the stroke is wide is a
|
|
846
|
+
* dot (SVG's `stroke-dasharray` measures the centre line; this is
|
|
847
|
+
* its `mark − width, gap + width`). The pattern runs along the
|
|
848
|
+
* whole stroke, corners and curves included. A pattern with no
|
|
849
|
+
* gap, or finer than a pixel, draws solid. */
|
|
850
|
+
dash?: number | [number] | [number, number] | [number, number, number, number];
|
|
851
|
+
/** How far into the pattern the stroke starts, in px: growing it
|
|
852
|
+
* moves the marks towards the first point — a marquee's marching
|
|
853
|
+
* ants. It wraps; it does not tween. */
|
|
854
|
+
dashOffset?: number;
|
|
855
|
+
width?: LengthProp;
|
|
816
856
|
color?: ColorProp;
|
|
817
857
|
float?: 'parent' | 'viewport';
|
|
818
858
|
};
|
|
@@ -889,8 +929,21 @@ export declare namespace JSX {
|
|
|
889
929
|
* the centre of its box without one. A path with `rotate` or
|
|
890
930
|
* `pivot` is boxed by the square the turn sweeps. */
|
|
891
931
|
pivot?: [number, number];
|
|
932
|
+
/** Cuts the stroke into marks and gaps (backlog V2): one length
|
|
933
|
+
* (marks and gaps alike), `[mark, gap]`, or `[mark, gap, mark,
|
|
934
|
+
* gap]` for a dash-dot, in px **as seen** — every mark is
|
|
935
|
+
* round-capped, so a mark no longer than the stroke is wide is a
|
|
936
|
+
* dot (SVG's `stroke-dasharray` measures the centre line; this is
|
|
937
|
+
* its `mark − width, gap + width`). The pattern runs along the
|
|
938
|
+
* outline, restarting at every subpath as SVG's does. A pattern with no
|
|
939
|
+
* gap, or finer than a pixel, draws solid. */
|
|
940
|
+
dash?: number | [number] | [number, number] | [number, number, number, number];
|
|
941
|
+
/** How far into the pattern the stroke starts, in px: growing it
|
|
942
|
+
* moves the marks towards the first point — a marquee's marching
|
|
943
|
+
* ants. It wraps; it does not tween. */
|
|
944
|
+
dashOffset?: number;
|
|
892
945
|
bg?: ColorProp;
|
|
893
|
-
width?:
|
|
946
|
+
width?: LengthProp;
|
|
894
947
|
color?: ColorProp;
|
|
895
948
|
float?: 'parent' | 'viewport';
|
|
896
949
|
};
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/props.md
CHANGED
|
@@ -40,6 +40,7 @@ where they make sense); text props apply to `<text>` and `<edit>`.
|
|
|
40
40
|
| `focusRegion` | `focus_region` | `focus_region` | boolean | Makes this node's subtree a focus region: a Tab ring of its own that the ring outside never enters and that never leaves — a devtools dock, an inspector beside the app (`docs/adr/0022-focus-regions.md`). Entered on purpose: `focusRegion(name)` (`Ui::focus_region`, `env.focus_region`, `kui_focus_region`) moves focus in — to the focus the region last held, else its `initialFocus`, else its first stop — and `focusRegion(null)` moves it back to the main ring the same way; a press inside the region, or an explicit focus on a node in it, enters it too. Tab then walks that ring alone, wrapping inside it; with nothing focused, Tab enters the ring of the region in effect (`region()`). A region that stops being declared hands focus back to what the main ring last held. Only the ring is scoped: keys still bubble through the boundary to the sink above (a region that wants its own keymap is an `onKey` sink), the pointer and assistive technology see a plain node, and a `modal` in effect is the ring wherever it sits. Nested regions are skipped by the outer ring the way the main ring skips them. |
|
|
41
41
|
| `focusable` | `focusable` | `focusable` | boolean | Reachable by Tab (and focused by a click) without a click payload or a control role — a row that opens on Enter. Editors, key sinks, `onClick` boxes and the control roles are focusable already. |
|
|
42
42
|
| `gap` | `gap` | `gap` | number, or a `"$length"` token | Space between children along the main axis. |
|
|
43
|
+
| `gradient` | `gradient` | `gradient` (`const KuiGradient *`) | gradient (`{ to? \\| angle? \\| radial?, at?, stops: [color \\| [color, at], …] }`) | A gradient painted over the node's `bg` and under its border and its children (`docs/adr/0042-a-gradient-is-an-image-the-core-paints.md`): `{ to: 'bottom', stops: [...] }` towards a side or a corner (`right`, `bottom left`, …; the default is `bottom`), `{ angle: 0.125, stops }` in turns clockwise from east, or `{ radial: true, at: [0.5, 0], stops }` out from a centre (fractions of the box, the middle by default) to its farthest corner. A stop is a colour — a `$token` too — or `[colour, position]` with the position 0 to 1; stops without one are spaced evenly between those with. Two stops at one position are a hard edge. The gradient is defined on the box's unit square and stretched to it, so a side or a corner is CSS's and any other `angle` runs corner to corner at an eighth of a turn whatever the box's aspect, where CSS's pixel-measured `45deg` does not. Stops mix in straight sRGB with the alpha premultiplied, as CSS's do. What it costs is one image quad: the core rasterizes each distinct gradient once into the glyph atlas — a 256-texel strip along an axis, a 128-texel square otherwise, within half an 8-bit level of the gradient computed per pixel for a linear one and 1.2 for a radial — keyed by the gradient and not the box, so a box that resizes and a thousand boxes that share one rasterize nothing, and a host that draws an image draws it; a gradient box costs about 55 ns over a flat one, so ten thousand of them are half a millisecond. A hard edge is as soft as the raster stretched to the box (a 256th of its length along a strip); stripes are boxes. It does not tween — `transition` eases the `bg` under it and `opacity` fades it — and `hoverBg` and the other state backgrounds replace `bg`, not the gradient; one that changes every frame is a raster a frame, and a shimmer is a `fragment`'s. Ignored on a `line`, a `polygon` and a `path`. Fewer than two stops are an error in JSX and Lua, as a malformed `keyframes` is, and draw nothing in Rust and C. |
|
|
43
44
|
| `height` | `height` | `height` (KuiSizing) | sizing (`number` \\| `"fit"` \\| `"grow"` \\| `"N%"` \\| a size expression \\| `"$length"`) | Vertical size: px \| "fit" \| "grow" \| "N%" \| a size expression (see `width`). |
|
|
44
45
|
| `hoverBg` | `hover_bg` | `hover_bg` | color (`#hex` or `0xRRGGBBAA`), or a `"$color"` token | Background while hovered (or while any node in its hoverGroup is); implies hover tracking, eases with `transition`. |
|
|
45
46
|
| `hoverGroup` | `hover_group` | `hover_group` (KuiStr) | string | Nodes sharing a group name show hoverBg/pressedBg together (a split button, a multi-piece shape). |
|
|
@@ -163,10 +164,10 @@ where they make sense); text props apply to `<text>` and `<edit>`.
|
|
|
163
164
|
| `<slider label valueNow valueMin valueMax valueStep valueText onChange width description tooltip disabled/>` | `slider { label=, value_now=, value_min=, value_max=, value_step=, value_text=, on_change=, width=, … }` | `kui_slider` | The stock slider (`widgets::slider_with`, ADR 0034): a track, a fill to `valueNow` and a thumb, as wide as a menu (`width` sizes it), keyed by its `label`, which is also its accessible name. With `onChange` the core does the arithmetic: a press proposes the value under the pointer, a drag each new step, the arrows one `valueStep`, PageUp / PageDown ten, Home / End the ends, all clamped to `valueMin`..`valueMax` (0..100 unset) and snapped to the step, as `{kind:"change", value, phase:"move"\|"end", tag}`. The value is proposed, never applied: the view stores it and declares it as `valueNow`. Its look is its spec, so the rows it reads are the value rows, the access rows and its width. |
|
|
164
165
|
| `<image src={id} sampling fit>` | `image { id=, sampling=, fit= }` | `kui_image`, `kui_image_with` | A registered RGBA image. Sizing: `width="fit"` takes the pixel size, a fit height against a resolved width keeps the aspect, `radius` rounds it. Two rows say how the pixels meet the box (`docs/adr/0025-the-image-is-the-canvas.md`): `sampling` is `linear` (the default) or `nearest` — pixel art, an emulator, a data grid that must stay square under zoom; `fit` is `fill` (the default: the pixels stretch to the box), `contain` (the largest rect of the image's aspect that fits, centred, the rest of the box showing what is behind) or `cover` (the box filled and the pixels that do not fit cropped, centred). The box — its layout, its hit region, its access rect — is the same in every mode. The pixels come from the atlas, or from a texture of the image's own once `updateImage` has replaced them or when no atlas page could hold them; the node cannot tell and need not. |
|
|
165
166
|
| `<polygon points={[[x,y],…]} bg/>` | `polygon { points={{x,y},…}, bg= }` | `kui_polygon` | A filled polygon through up to eight `points`, the fill in `bg` (`docs/adr/0025-the-image-is-the-canvas.md`, decision 6): an arrowhead, a pie slice, the area under a curve. Placed as a `line` is — always a float in its parent's box space (`float="viewport"` for viewport space), sized to its own bounding box a pixel out on each side, so it takes no room in a row or column. A polygon in its parent's box space is held by the parent's clip as a child is, its hit region with it, so it is cut at a scroller's edge with the row it is drawn in; a declared float is held that way only when it declares `clip` with a parent anchor, and a `float="viewport"` polygon escapes (backlog F78, `docs/adr/0010-a-segment-primitive.md` decision 5). `transition` eases the fill and, with `slide`, its position. The outline may be concave; a self-intersecting one fills even-odd, its overlaps unfilled. Hit by its outline (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press inside the outline hits it and one in its box past the outline falls through, so a pie's wedges need no hit boxes; with none it takes no input and has no access row, and with input it derives one as a box would (a clickable wedge is a button), so name it. A ninth point and later are dropped with `polygon-points-truncated`; fewer than three draw nothing; no `bg`, no fill. On the wire it is one `fragment` quad painted by a WGSL function the core registers itself, so a host that draws the list gets its source from `kui_fragment_source` like any other; what it costs is that quad and one pipeline switch per run of polygons. A stroked outline is a closed `line` over it. |
|
|
166
|
-
| `<path d="M … Z" bg width color fillRule rotate pivot/>` | `path { d = "M … Z", bg=, width=, color=, fill_rule=, rotate=, pivot= }` | `kui_path` | Any outline — SVG's `d`, a pie wedge with a round arc, a map's region, an icon — filled with `bg` by `fillRule` (`nonzero`, the default, or `evenodd`) and stroked `width` wide in `color` when `width` is given, the stroke over the fill (`docs/adr/0040-a-path-is-a-mask-in-the-atlas.md`). `d` is SVG path data (`M L H V C S Q T A Z`, absolute or relative), parsed by one parser in the core, so every binding draws the same shape; one that does not parse raises `path-malformed` and draws nothing. JSX also takes `d` as a flat number array of op codes and operands, Lua the same as `ops`, and C only that form (`kui_path_parse` turns a string into it). Placed as a `line` is — always a float in its parent's box space (`float="viewport"` for viewport space), sized to its own bounding box two pixels out on each side (half the stroke's width further), so it takes no room in a row or column, held by the parent's clip as a child is. `transition` eases the fill and, with `slide`, its position; the stroke's colour does not tween, as a box's border does not. Hit by its outline under the fill rule (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press inside hits it and one in its box past the outline falls through, so a pie's wedges need no hit boxes; a stroke with no fill is hit by its stroke as a line is; with input it derives an access row as a box would (a clickable wedge is a button), so name it. On the wire it is one glyph-mask quad per paint, fill and stroke: the outline is rasterized once per shape, scale and quarter-pixel position into the glyph atlas and tinted like a glyph, so a host that draws text draws paths, and nothing is re-rasterized for a colour tween, a hover or a slide. The fill bleeds half a pixel, so two paths sharing an edge meet without the background showing through; a chart that wants separators gaps its own geometry. A mask a quarter of the biggest atlas page or more, or a path whose ops change twice within a few frames, draws from a texture of its own instead (a `texture` quad), and one past 8192 px on a side draws nothing, with `path-too-large`. `rotate` turns the path, in turns clockwise, about `pivot` — a point in the path's own coordinates, the centre of its box without one (`docs/adr/0041-a-mask-turns-about-its-centre.md`): the turn is the quad's and not the mask's, so a path that only turns — a spinner's arc about its circle's centre — is rasterized once and stays in the atlas at every angle. A path with `rotate` or `pivot` is boxed by the square the turn sweeps, its mask centred on the pivot on a whole pixel, and it is hit where it is drawn; `rotate` does not tween. |
|
|
167
|
-
| `<fragment src={id} image={id} params={[…]} animate>` | `fragment { id=, image=, params={…}, animate= }` | `kui_fragment`, `kui_fragment_with` | A box a registered WGSL function paints (`docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md`):
|
|
168
|
-
| `<cells rows cols cells={Uint32Array} cursorAt={[row, col]} cursorShape cursorColor size family lineHeight/>` | `cells { rows=, cols=, lines={"row text", …}, runs={{row, col, len, fg, bg, flags}, …}, cursor_at={row, col}, cursor_shape=, cursor_color=, size=, family= }` | `kui_cells` | A terminal's screen as one node (backlog C20): `rows × cols` cells, each a character, a foreground and background as `0xRRGGBBAA` (0 = no background), and attribute bits — 1 bold, 2 italic, 4 underline, 8 strikethrough, 16 wide (the glyph spans this cell and the next, which the app leaves blank), 32 the underline is a wave (a terminal's undercurl, SGR 4:3) and 64 dotted (SGR 4:4), either implying it — plus, optionally, the underline's own colour (SGR 58), 0 for the foreground (backlog K4). A glyph is shaped once per character and style variant and thereafter placed at `col × cell_w` without shaping, so a screen whose every cell is new each frame costs what a still one costs (~60 µs for 200 × 50). The cell width is `M`'s advance in the style's font snapped to whole pixels, the height its `lineHeight`; a cell is a cell, so ligatures never form. Box drawing and block elements (U+2500–U+259F) and the Powerline separators (U+E0B0–U+E0BF: the arrows, and the Powerline Extra half circles and wedges) are not shaped at all but drawn from the cell box — a font's are its own line box tall, a cell is `lineHeight` tall, and through the font every `│` was a dash with a gap under it (backlog F66) and a rounded cap a fallback font's squiggle (F112) — so a TUI's frames and rounded rows are seamless in any font, and bold does not thicken a light line (the set has its heavy variants). JSX passes the cells as a `Uint32Array` (or number array) of four entries per cell — codepoint, fg, bg, flags — or five, with the underline colour, in row-major order; Lua a string per row in `lines` plus `runs` of `{row, col, len, fg, bg, flags, ul}` over them (a run's fg, bg or ul of 0 keeps the default: the style's colour, no background, the foreground); C a `KuiCell` array with `ul`. `cursorAt` (`cursor_at`) names a cell to paint under its glyph in `cursorColor` as a `block` (default), `bar` or `underline` — its own name, since `cursor` is the pointer shape. `originLine` (`origin_line`) is the absolute line number of row 0: a grid is one screenful of the app's own history, so a row number means a different line after every scroll, and stamping where the screen sits is what lets a selection keep its ends across one (`docs/adr/0017-selection-as-a-scope.md`). Saying nothing is 0, and a selection then holds only while the screen does not move. The node's own rows apply — an `onKey` makes it the terminal's sink, an `onClick` or `onDrag` carries `cell: {row, col}` on its events — and its access row is `terminal`, the rows joined as its value. |
|
|
169
|
-
| `<line from={[x,y]} to={[x,y]} width color/>`, `<line points={[[x,y],…]} curve/>` | `line { from={x,y}, to={x,y}, width=, color= }`, `line { points={{x,y},…}, curve=true }` | `kui_line`, `kui_polyline` | A round-capped stroke: one segment, a polyline through `points`, or a smooth curve through them with `curve`. Always a float in its parent's box space (`float="viewport"` for viewport space), sized to its own bounding box, so it takes no room in a row or column. A stroke in its parent's box space is held by the parent's clip as a child is, its hit region with it, so it is cut at a scroller's edge with the row it is drawn in; a declared float is held that way only when it declares `clip` with a parent anchor, and a `float="viewport"` stroke escapes (backlog F78, `docs/adr/0010-a-segment-primitive.md` decision 5). `width` is the stroke width (default 1) and `color` the stroke colour; `transition` eases the colour, and with `slide` beside it the stroke's position too — the points ride its box, so a stroke whose ends all move together slides with them, while one whose ends move apart resizes at once (a canvas of floats eases everything or nothing, connectors included). Hit by its shape (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press within half the width of any piece hits it — at least 4 px of grab, so a hairline is a target — and a press elsewhere in its bounding box falls through to what is under; with none it takes no input and has no access row, and with input it derives one as a box would (a clickable connector is a button), so name it. What it costs: one quad per segment, and a curve is flattened in the core at one piece per 6 logical px of chord (at most 32 per span) — fixed rather than tolerance-driven so every binding gets the same pieces and the corpus can pin them — so a nine-point curve over ~50 px spans is ~60 quads, and a `quadCount` budget should expect it. |
|
|
167
|
+
| `<path d="M … Z" bg width color fillRule rotate pivot dash dashOffset/>` | `path { d = "M … Z", bg=, width=, color=, fill_rule=, rotate=, pivot=, dash=, dash_offset= }` | `kui_path` | Any outline — SVG's `d`, a pie wedge with a round arc, a map's region, an icon — filled with `bg` by `fillRule` (`nonzero`, the default, or `evenodd`) and stroked `width` wide in `color` when `width` is given, the stroke over the fill (`docs/adr/0040-a-path-is-a-mask-in-the-atlas.md`). `d` is SVG path data (`M L H V C S Q T A Z`, absolute or relative), parsed by one parser in the core, so every binding draws the same shape; one that does not parse raises `path-malformed` and draws nothing. JSX also takes `d` as a flat number array of op codes and operands, Lua the same as `ops`, and C only that form (`kui_path_parse` turns a string into it). Placed as a `line` is — always a float in its parent's box space (`float="viewport"` for viewport space), sized to its own bounding box two pixels out on each side (half the stroke's width further), so it takes no room in a row or column, held by the parent's clip as a child is. `transition` eases the fill and, with `slide`, its position; the stroke's colour does not tween, as a box's border does not. Hit by its outline under the fill rule (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press inside hits it and one in its box past the outline falls through, so a pie's wedges need no hit boxes; a stroke with no fill is hit by its stroke as a line is; with input it derives an access row as a box would (a clickable wedge is a button), so name it. On the wire it is one glyph-mask quad per paint, fill and stroke: the outline is rasterized once per shape, scale and quarter-pixel position into the glyph atlas and tinted like a glyph, so a host that draws text draws paths, and nothing is re-rasterized for a colour tween, a hover or a slide. The fill bleeds half a pixel, so two paths sharing an edge meet without the background showing through; a chart that wants separators gaps its own geometry. A mask a quarter of the biggest atlas page or more, or a path whose ops change twice within a few frames, draws from a texture of its own instead (a `texture` quad), and one past 8192 px on a side draws nothing, with `path-too-large`. `rotate` turns the path, in turns clockwise, about `pivot` — a point in the path's own coordinates, the centre of its box without one (`docs/adr/0041-a-mask-turns-about-its-centre.md`): the turn is the quad's and not the mask's, so a path that only turns — a spinner's arc about its circle's centre — is rasterized once and stays in the atlas at every angle. A path with `rotate` or `pivot` is boxed by the square the turn sweeps, its mask centred on the pivot on a whole pixel, and it is hit where it is drawn; `rotate` does not tween. `dash` and `dashOffset` cut the stroke into marks and gaps as a `line`'s do (backlog V2) — lengths as seen, round-capped marks — restarting at every subpath as SVG's do; the pattern is part of the stroke's mask, so a dashed stroke costs what a solid one does, a stroke with no fill is still hit along its gaps, and a `dashOffset` that changes every frame is a shape that changes every frame: the path leaves the atlas for a texture of its own while it marches. |
|
|
168
|
+
| `<fragment src={id} image={id} params={[…]} animate>` | `fragment { id=, image=, params={…}, animate= }` | `kui_fragment`, `kui_fragment_with` | A box a registered WGSL function paints (`docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md`): a conic or a moving gradient, rings, noise, shimmer — anything the paint vocabulary has no prop for. An ordinary node otherwise — it lays out, rounds, clips, fades, takes input and holds children, which paint over it — but with **no intrinsic size**, so give it a `width`/`height` or `fill` or it is zero by zero. `src` is a handle from `add_fragment`, which validates the source and warns rather than minting one that cannot compile. `params` is up to sixteen numbers the shader reads as four `vec4<f32>`; more are dropped with a warning. `image` is a registered image the function reads — `kui_sample(uv)` (bilinear) and `kui_sample_nearest(uv)` return its texels at `uv` in `[0,1]²`, and `in.image` is its texel rect, `zw` the size — which is what makes a replaced image a waveform, a heatmap, a 50k-point line or an image effect from one quad (`docs/adr/0025-the-image-is-the-canvas.md`, decision 7); the core binds the atlas or the image's own texture, whichever holds it, and a fragment whose image is not live draws nothing, as one whose `src` is not does. `animate` asks for a frame every frame, which is what a fragment that reads `time` needs and what a still one must not declare. |
|
|
169
|
+
| `<cells rows cols cells={Uint32Array} cursorAt={[row, col]} cursorShape cursorColor size family lineHeight/>` | `cells { rows=, cols=, lines={"row text", …}, runs={{row, col, len, fg, bg, flags}, …}, cursor_at={row, col}, cursor_shape=, cursor_color=, size=, family= }` | `kui_cells` | A terminal's screen as one node (backlog C20): `rows × cols` cells, each a character, a foreground and background as `0xRRGGBBAA` (0 = no background), and attribute bits — 1 bold, 2 italic, 4 underline, 8 strikethrough, 16 wide (the glyph spans this cell and the next, which the app leaves blank), 32 the underline is a wave (a terminal's undercurl, SGR 4:3) and 64 dotted (SGR 4:4), either implying it — plus, optionally, the underline's own colour (SGR 58), 0 for the foreground (backlog K4). A glyph is shaped once per character and style variant and thereafter placed at `col × cell_w` without shaping, so a screen whose every cell is new each frame costs what a still one costs (~60 µs for 200 × 50). The cell width is `M`'s advance in the style's font snapped to whole pixels, the height its `lineHeight`; a cell is a cell, so ligatures never form. A character the family has no glyph for is asked of a monospaced face before the platform's fallback list, shaped smaller where it is still wider than its cells (two under wide), and drawn in their middle (backlog F120); the private use area's icons are left as they fall. Box drawing and block elements (U+2500–U+259F) and the Powerline separators (U+E0B0–U+E0BF: the arrows, and the Powerline Extra half circles and wedges) are not shaped at all but drawn from the cell box — a font's are its own line box tall, a cell is `lineHeight` tall, and through the font every `│` was a dash with a gap under it (backlog F66) and a rounded cap a fallback font's squiggle (F112) — so a TUI's frames and rounded rows are seamless in any font, and bold does not thicken a light line (the set has its heavy variants). JSX passes the cells as a `Uint32Array` (or number array) of four entries per cell — codepoint, fg, bg, flags — or five, with the underline colour, in row-major order; Lua a string per row in `lines` plus `runs` of `{row, col, len, fg, bg, flags, ul}` over them (a run's fg, bg or ul of 0 keeps the default: the style's colour, no background, the foreground); C a `KuiCell` array with `ul`. `cursorAt` (`cursor_at`) names a cell to paint under its glyph in `cursorColor` as a `block` (default), `bar` or `underline` — its own name, since `cursor` is the pointer shape. `originLine` (`origin_line`) is the absolute line number of row 0: a grid is one screenful of the app's own history, so a row number means a different line after every scroll, and stamping where the screen sits is what lets a selection keep its ends across one (`docs/adr/0017-selection-as-a-scope.md`). Saying nothing is 0, and a selection then holds only while the screen does not move. The node's own rows apply — an `onKey` makes it the terminal's sink, an `onClick` or `onDrag` carries `cell: {row, col}` on its events — and its access row is `terminal`, the rows joined as its value. |
|
|
170
|
+
| `<line from={[x,y]} to={[x,y]} width color/>`, `<line points={[[x,y],…]} curve dash={[6, 4]} dashOffset/>` | `line { from={x,y}, to={x,y}, width=, color= }`, `line { points={{x,y},…}, curve=true, dash={6, 4}, dash_offset= }` | `kui_line`, `kui_polyline` | A round-capped stroke: one segment, a polyline through `points`, or a smooth curve through them with `curve`. Always a float in its parent's box space (`float="viewport"` for viewport space), sized to its own bounding box, so it takes no room in a row or column. A stroke in its parent's box space is held by the parent's clip as a child is, its hit region with it, so it is cut at a scroller's edge with the row it is drawn in; a declared float is held that way only when it declares `clip` with a parent anchor, and a `float="viewport"` stroke escapes (backlog F78, `docs/adr/0010-a-segment-primitive.md` decision 5). `width` is the stroke width (default 1) and `color` the stroke colour; `transition` eases the colour, and with `slide` beside it the stroke's position too — the points ride its box, so a stroke whose ends all move together slides with them, while one whose ends move apart resizes at once (a canvas of floats eases everything or nothing, connectors included). Hit by its shape (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press within half the width of any piece hits it — at least 4 px of grab, so a hairline is a target — and a press elsewhere in its bounding box falls through to what is under; with none it takes no input and has no access row, and with input it derives one as a box would (a clickable connector is a button), so name it. What it costs: one quad per segment, and a curve is flattened in the core at one piece per 6 logical px of chord (at most 32 per span) — fixed rather than tolerance-driven so every binding gets the same pieces and the corpus can pin them — so a nine-point curve over ~50 px spans is ~60 quads, and a `quadCount` budget should expect it. `dash` cuts the stroke into marks and gaps (backlog V2): one length (marks and gaps alike), a mark and a gap, or four lengths for a dash-dot, in px **as seen** — every mark is a short stroke with the stroke's round caps, so `dash` 6, 4 is 6 px of ink and 4 px of nothing at any width, and a mark no longer than the stroke is wide is a dot (SVG's `stroke-dasharray` measures the centre line instead, so with round caps its `4 4` at a width of 4 is solid; this pattern is SVG's `mark − width, gap + width`). The pattern runs along the stroke's whole length, so it keeps its phase round the corners of a polyline and the pieces of a curve, and `dashOffset` starts that far into it — growing it moves the marks towards the first point, a marquee's marching ants; neither tweens. A pattern with no gap, a mark and gap under a physical pixel together, or more than 16384 marks draws solid. A dashed stroke is hit along its whole length, gaps included, and costs a quad per mark per piece the mark lies on. |
|
|
170
171
|
| `<titlebar title>` or `<titlebar>…</titlebar>` | `titlebar { title= }` / `titlebar { … }` | `kui_titlebar`, `kui_titlebar_with` | Adaptive titlebar for custom chrome: drag strip, native-control inset, window buttons. |
|
|
171
172
|
| `<menuBar menu={[{ label, items: [{ label, id?, role?, accel?, enabled?, checked? }] }]}/>` | `menu_bar { menu = { { label=, items= { { label=, id=, role=, accel=, enabled=, checked= } } } } }` | `kui_menu_bar` | The application menu (`docs/adr/0018-a-menu-bar-the-app-declares.md`): `menu` is what it *is*, and where this element sits is where its titles go when they have to be drawn in the window. One call and not two, because declaring the menu and placing the strip are one decision. It draws **nothing** where the platform owns the bar — macOS, where the driver hands the same declaration to the OS — so the frame has still said what the app's menu is and the strip simply is not there; that is the contract `windowButtons` has under native decorations, and it is what makes one view portable. Its rows are the rows a context menu has: the same `role`s the core performs itself (`copy`, `paste`, `selectAll`, `cut`, `lookUp`), the same `id` payload, the same `accel` text, plus `checked` for a setting — and choosing one posts the same `{kind:"menu", role, item}` event, so an app handles one thing whichever menu it came from. Declared every frame and diffed: an unchanged menu costs a comparison, and an empty list takes it away. An accelerator kui can parse is rewritten into the platform's spelling, so `"mod+s"` reads as `⌘S` on macOS and `Ctrl+S` elsewhere and binds that key in the platform's own bar. On macOS the first menu is the application menu, which the OS titles with the app's own name whatever the label says. While a menu is open the bar is the frame's modal scope, so hovering across the titles moves the open menu, a press on the open title closes it, and Escape or a press in the app below closes it and reaches nothing else. |
|
|
172
173
|
| `<windowButtons/>` | `window_buttons()` | `kui_window_buttons` | Just the min/max/close buttons, for fully custom titlebars. |
|
|
@@ -481,6 +482,8 @@ binding is a row with its three other cells, or a red test.
|
|
|
481
482
|
| `Core::load_font_file` | `kui_font_load_file` | `loadFontFile` | *none: a script owns no handle: the host registers and the script names the id it was given (`image { id = }`, `font = id`, `audio { src = id }`)* | Registers a font file by path. |
|
|
482
483
|
| `Core::load_fonts_dir` | `kui_font_load_dir` | `loadFontsDir` | *none: a script owns no handle: the host registers and the script names the id it was given (`image { id = }`, `font = id`, `audio { src = id }`)* | Registers every font file in a directory. |
|
|
483
484
|
| `Core::reload_system_fonts` | `kui_font_reload_system` | `reloadSystemFonts` | *none: a script is a guest in the host's frame (ADR 0014): its env is the view's reading, and registering, driving, pacing and reading back are the host's* | Scans the system's fonts again, so a font installed while the app runs is found (the scan is otherwise once a process); returns how many faces came and went. |
|
|
485
|
+
| `Core::set_fallback_fonts` | `kui_font_set_fallback` | `setFallbackFonts` | *none: a script owns no handle: the host registers and the script names the id it was given (`image { id = }`, `font = id`, `audio { src = id }`)* | The fonts asked, in order, for a character the text's own family lacks, before the platform's fallback list (backlog F121). |
|
|
486
|
+
| `Core::fallback_fonts` | *none: the list is the one the host set* | *none: the list is the one the host set* | *none: a script owns no handle: the host registers and the script names the id it was given (`image { id = }`, `font = id`, `audio { src = id }`)* | The families `set_fallback_fonts` named, in order. |
|
|
484
487
|
| `Core::remove_font` | `kui_font_remove` | `removeFont` | *none: a script owns no handle: the host registers and the script names the id it was given (`image { id = }`, `font = id`, `audio { src = id }`)* | Drops a font. |
|
|
485
488
|
| `Core::system_font_families` | `kui_font_families` | `systemFontFamilies` | *none: a script owns no handle: the host registers and the script names the id it was given (`image { id = }`, `font = id`, `audio { src = id }`)* | The installed family names `add_system_font` accepts. |
|
|
486
489
|
| `Core::system_fonts` | `kui_system_fonts` | `systemFonts` | *none: a script owns no handle: the host registers and the script names the id it was given (`image { id = }`, `font = id`, `audio { src = id }`)* | The same families, each with what the font database read off its faces: `monospaced` (every face fixed-pitch), `weights`, `italic` (backlog F97) — a font picker's monospaced-first list without a file loaded or a glyph shaped. |
|