@qxuken/kui 0.0.0-stage → 0.1.0-alpha.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13313 -0
- package/LICENSE +21 -0
- package/README.md +336 -3
- package/docs/adr/0001-accessibility-as-data.md +414 -0
- package/docs/adr/0002-keyboard-focus-as-data.md +452 -0
- package/docs/adr/0003-modal-surfaces.md +248 -0
- package/docs/adr/0004-multi-window.md +626 -0
- package/docs/adr/0005-the-paint-vocabulary.md +601 -0
- package/docs/adr/0006-c-abi-versioning.md +257 -0
- package/docs/adr/0007-composite-keyboard-patterns.md +350 -0
- package/docs/adr/0008-live-regions-and-announcements.md +379 -0
- package/docs/adr/0009-press-drag-release-into-a-popup.md +318 -0
- package/docs/adr/0010-a-segment-primitive.md +410 -0
- package/docs/adr/0011-keys-bubble-to-the-enclosing-sink.md +258 -0
- package/docs/adr/0012-the-exit-budget.md +411 -0
- package/docs/adr/0013-effects-as-data.md +197 -0
- package/docs/adr/0014-slots-an-extension-fills-in-place.md +411 -0
- package/docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md +559 -0
- package/docs/adr/0016-caching-against-the-last-frame.md +351 -0
- package/docs/adr/0017-selection-as-a-scope.md +696 -0
- package/docs/adr/0018-a-menu-bar-the-app-declares.md +264 -0
- package/docs/adr/0019-a-theme-derived-from-appearance-and-accent.md +382 -0
- package/docs/adr/0020-the-surface-the-schema-does-not-cover.md +351 -0
- package/docs/adr/0021-one-subject-per-example.md +729 -0
- package/docs/adr/0022-focus-regions.md +205 -0
- package/docs/adr/0023-layers-stack-in-the-order-they-open.md +376 -0
- package/docs/adr/0024-the-devtools-are-the-cores.md +425 -0
- package/docs/adr/0025-the-image-is-the-canvas.md +507 -0
- package/docs/adr/0026-hit-testing-by-shape.md +197 -0
- package/docs/adr/0027-tokens-beside-the-theme.md +521 -0
- package/docs/adr/0028-derived-tokens.md +572 -0
- package/docs/adr/0029-a-selection-follows-the-pointer-past-the-edge.md +543 -0
- package/docs/adr/0030-the-standard-menus-the-runner-keeps.md +292 -0
- package/docs/adr/0031-a-drop-zone-is-a-row-and-the-files-are-an-event.md +409 -0
- package/docs/adr/0032-a-devtools-tab-mounts-a-slot.md +480 -0
- package/docs/adr/0033-a-table-is-a-column-whose-cells-align.md +377 -0
- package/docs/adr/0034-stock-controls-over-the-roles.md +192 -0
- package/docs/adr/0035-a-rounded-background-is-joined-by-meeting.md +134 -0
- package/docs/adr/0036-an-event-handler-gets-its-window.md +142 -0
- package/docs/adr/0037-a-family-is-named.md +134 -0
- package/docs/adr/0038-a-scroll-gesture-latches-its-target.md +188 -0
- package/docs/adr/0039-a-tutorial-is-a-sequence.md +139 -0
- package/encoder.js +1282 -0
- package/howto.md +1774 -0
- package/index.d.ts +5162 -0
- package/index.js +1414 -0
- package/jsx-dev-runtime.js +1 -0
- package/jsx-runtime.d.ts +905 -0
- package/jsx-runtime.js +45 -0
- package/native.cjs +118 -0
- package/package.json +51 -6
- package/prebuilds/darwin-arm64/kui_node.node +0 -0
- package/prebuilds/darwin-x64/kui_node.node +0 -0
- package/prebuilds/linux-arm64/kui_node.node +0 -0
- package/prebuilds/linux-x64/kui_node.node +0 -0
- package/prebuilds/win32-x64/kui_node.node +0 -0
- package/props.md +636 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nasonov Viktor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,336 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# kui
|
|
2
|
+
|
|
3
|
+
kui for Node: JSX views (a custom jsx-runtime, no React) lowered into the kui
|
|
4
|
+
IR in one call per frame, Elm-style messages as data. The library itself is
|
|
5
|
+
documented in the [kui repository](https://github.com/qxuken/kui).
|
|
6
|
+
|
|
7
|
+
Install it from npm:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npm install @qxuken/kui@alpha
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Every release is also on the Forgejo npm registry the project grew up on
|
|
14
|
+
(`npm config set @qxuken:registry https://drydock9.qxuken.dev/api/packages/qxuken/npm/`
|
|
15
|
+
first; it does not proxy npmjs, so scope the setting rather than replacing
|
|
16
|
+
the default registry).
|
|
17
|
+
|
|
18
|
+
Or start from the template: `npm create @qxuken/kui-node my-app`.
|
|
19
|
+
|
|
20
|
+
Every release so far is a prerelease, so `latest` and `alpha` point at the same
|
|
21
|
+
newest alpha and a bare `npm install @qxuken/kui` gets it; `npm view
|
|
22
|
+
@qxuken/kui@alpha version` is the query that still answers if `latest` is ever
|
|
23
|
+
absent. Ranges do not pin a prerelease — `^0.1.0-alpha.8` and `~0.1.0-alpha.8`
|
|
24
|
+
both admit every later alpha of the same `0.1.0` — so an app that wants the
|
|
25
|
+
version it tested writes that version exactly and commits its lockfile.
|
|
26
|
+
|
|
27
|
+
The tarball bundles the native addon for linux-x64, linux-arm64, darwin-arm64,
|
|
28
|
+
darwin-x64 and win32-x64 under `prebuilds/`; `native.cjs` picks the one matching
|
|
29
|
+
`process.platform`-`process.arch`. On any other platform build it from the
|
|
30
|
+
repo (`cargo build -p kui-node --release`) and set `KUI_NODE_LIB` to the
|
|
31
|
+
resulting library.
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
// tsconfig: "jsx": "react-jsx", "jsxImportSource": "@qxuken/kui"
|
|
35
|
+
import { createApp, runWindowed } from '@qxuken/kui';
|
|
36
|
+
import type { CoreMsg, Ctx, KuiWindow, UiEvent } from '@qxuken/kui';
|
|
37
|
+
|
|
38
|
+
type Model = { count: number };
|
|
39
|
+
// This app's own messages plus the ones the core sends by itself.
|
|
40
|
+
type Msg = { kind: 'add'; by: number } | { kind: 'reset' } | CoreMsg;
|
|
41
|
+
|
|
42
|
+
// The fourth argument is the surface the loop drives — the headless `Ctx`
|
|
43
|
+
// under `createApp`, the `KuiWindow` under `runWindowed` — for `editText`,
|
|
44
|
+
// `focus`, `play` and the rest. Both drivers pass it.
|
|
45
|
+
function update(model: Model, msg: Msg, ev: UiEvent<Msg>, ui: Ctx | KuiWindow): Model | undefined {
|
|
46
|
+
switch (msg.kind) { // one union, no casts
|
|
47
|
+
case 'add': return { count: model.count + msg.by };
|
|
48
|
+
case 'reset': return { count: 0 };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// `init` is the first model, or a function the surface is handed to — after
|
|
53
|
+
// `setup`, so the fonts and images it registered are there to measure
|
|
54
|
+
// against. Under a window that argument is the window, so a first model can
|
|
55
|
+
// be built at the size it really opened at instead of at a constant
|
|
56
|
+
// corrected on the first `resize`:
|
|
57
|
+
// runWindowed({ init: (win) => ({ count: 0, size: win.size() }), ... })
|
|
58
|
+
const init = (): Model => ({ count: 0 });
|
|
59
|
+
|
|
60
|
+
// `view(model, window, surface)`: the window's name (`'main'` unless
|
|
61
|
+
// `windows` declared others) and the surface, for the measurement a tree
|
|
62
|
+
// needs while it is being built. A single-window app that measures nothing
|
|
63
|
+
// takes `model` alone.
|
|
64
|
+
const view = (model: Model, _window: string, ui: Ctx | KuiWindow) => (
|
|
65
|
+
<box pad={24} gap={16}>
|
|
66
|
+
<button onClick={{ kind: 'add', by: 1 }}>+1</button>
|
|
67
|
+
{/* Wide enough for the widest count it will ever show, so the button
|
|
68
|
+
beside it does not shift as the number grows. */}
|
|
69
|
+
<box width={ui.measureText('count = 0000', { size: 20 }).width}>
|
|
70
|
+
<text size={20}>{`count = ${model.count}`}</text>
|
|
71
|
+
</box>
|
|
72
|
+
</box>
|
|
73
|
+
);
|
|
74
|
+
const app = createApp({ init, update, view }, { width: 640, height: 480 }); // headless
|
|
75
|
+
const final = await runWindowed({ init, update, view }, { title: 'counter' }); // a window
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Both drivers run one loop over one surface, so what differs between them is
|
|
79
|
+
only what really differs: `runWindowed` pumps the OS and resolves with the
|
|
80
|
+
final model, `createApp` is synchronous. Everything else — the clock, the
|
|
81
|
+
diagnostics gate, the test affordances — is the same code either way. To
|
|
82
|
+
drive the window one of these opens — a smoke test reading `win.quads()`,
|
|
83
|
+
say — press with `access(key, 'click')`: against a real window that is not
|
|
84
|
+
only the screen reader's path but the only synthetic input it takes, since
|
|
85
|
+
`click`, `type` and `key` are refused on a surface the OS drives.
|
|
86
|
+
|
|
87
|
+
That loop takes a clock — `tick: { every: 250, msg: (now) => ({ kind: 'tick',
|
|
88
|
+
now }) }` — and re-renders on a tick only when `update` returns a new model,
|
|
89
|
+
so a countdown is free between displayed seconds. That contract cuts both
|
|
90
|
+
ways; see **A clock** below. A window fires the ticks off its own timer; a
|
|
91
|
+
test moves the hands itself with `app.advance(ms)`, so an app with a clock
|
|
92
|
+
still runs headless.
|
|
93
|
+
|
|
94
|
+
An effect the app defines and kui knows nothing about — a file write, a
|
|
95
|
+
request, the clipboard — is data on the same terms as everything else the
|
|
96
|
+
loop handles ([ADR 0013](../../docs/adr/0013-effects-as-data.md)).
|
|
97
|
+
`update` returns it beside the model:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
case 'save': return withEffects({ ...model, dirty: false }, { kind: 'write', path: model.path, text: model.text });
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
and the app says once, in the options, what performing one means —
|
|
104
|
+
`effects: (effect, dispatch, surface) => { … }` — which the loop calls
|
|
105
|
+
after the frame, so an effect that dispatches its result
|
|
106
|
+
(`dispatch({ kind: 'saved' })`) lands in the next turn, and one that reads
|
|
107
|
+
the surface sees the frame its cause produced. `update` stays pure, the
|
|
108
|
+
same handler serves `createApp` and `runWindowed`, and headless
|
|
109
|
+
`app.effects()` drains what `update` returned whether or not a handler
|
|
110
|
+
ran, so a test asserts the effect the way it asserts an audio command.
|
|
111
|
+
`withEffects(undefined, …)` keeps the model, and a function `init` may
|
|
112
|
+
return one too. kui's own effects stay where they are: a sound is
|
|
113
|
+
`surface.play` or an `<audio>` node, a window is `windows`.
|
|
114
|
+
|
|
115
|
+
## Testing
|
|
116
|
+
|
|
117
|
+
`createApp` runs the same app headless, and everything a frame produces
|
|
118
|
+
comes back as data, so a test drives the app the way a user would and
|
|
119
|
+
asserts on what the core produced:
|
|
120
|
+
|
|
121
|
+
- **Input**: `app.click(x, y)`, `app.type(s)`, `app.press(code)` settle the
|
|
122
|
+
loop for you; `app.ctx.cursor` / `mouse` / `scroll` / `modifiers` are the
|
|
123
|
+
raw events (a drag is cursor, mouse down, cursor, mouse up).
|
|
124
|
+
**`press` is the key one.** A real key press is two channels and a window
|
|
125
|
+
drives both — the raw press an `onKey` sink hears, and then what the core
|
|
126
|
+
is asked to do with that key (Escape dismisses a modal, Tab walks the
|
|
127
|
+
ring, an arrow nudges a focused slider, Space presses a focused control).
|
|
128
|
+
`press('escape')` does both; `app.key(name)` and `app.ctx.keyDown(code)`
|
|
129
|
+
are its two halves, for a test that means to drive one channel and not
|
|
130
|
+
the other. `app.release(code)` is the key coming up. The loop sets the frame clock before every frame, so a
|
|
131
|
+
`transition` eases from the frame that changes it and `app.advance(ms)`
|
|
132
|
+
is what moves it; a test that wants only the end state advances past
|
|
133
|
+
the duration. (A bare `Ctx` whose `setTime` is never called snaps.)
|
|
134
|
+
- **Time**: `app.advance(ms)` is the window's timer by hand. It fires every
|
|
135
|
+
`tick` that falls inside the span, moves the frame clock behind
|
|
136
|
+
`transition` with it, and re-renders — so a ticking app (a countdown, a
|
|
137
|
+
clock, a game loop) is driven from a test the same way a user's window
|
|
138
|
+
drives it, and a transition can be watched a step at a time
|
|
139
|
+
(`app.ctx.animating()` says when it has settled). `startTime` in the
|
|
140
|
+
options pins where that clock starts, so assertions on `tick.msg(now)`
|
|
141
|
+
are exact.
|
|
142
|
+
- **Effects**: `app.effects()` drains what `update` returned with
|
|
143
|
+
`withEffects` since the last drain — the file write or request the app
|
|
144
|
+
asked for, as a value — whether or not an `effects` handler was
|
|
145
|
+
registered; with one, the handler already ran after the frame and its
|
|
146
|
+
`dispatch` has already gone through `update`.
|
|
147
|
+
- **Either surface**: `settle`, `access`, `accessTree`, `dispatch`, `render`
|
|
148
|
+
and `step` are the loop's, not the headless driver's, so they work against
|
|
149
|
+
a real window too — `runWindowed`'s `setup(win, app)` hands you the same
|
|
150
|
+
object. Synthetic input (`click` / `type` / `key`) needs a surface that
|
|
151
|
+
takes it: a `Ctx` does, and a window, which the OS drives, says so rather
|
|
152
|
+
than pretending.
|
|
153
|
+
- **The frame**: `decodeQuads(app.ctx.quads())` is the display list
|
|
154
|
+
(`x`, `y`, `w`, `h`, `color`, `radii`, `kind`), so "the compact tier fits
|
|
155
|
+
its window" is `every((q) => q.x + q.w <= width)`.
|
|
156
|
+
- **Events and sound**: `app.ctx.pollEvents()`, and `app.ctx.audioCommands()`
|
|
157
|
+
is what a window would have played.
|
|
158
|
+
- **Measurement**: `app.ctx.measureText(content, style, maxWidth)` is what
|
|
159
|
+
layout gives the same `<text>`, so a breakpoint assertion is arithmetic.
|
|
160
|
+
It is the same object `view`'s third argument is, so a test measures what
|
|
161
|
+
the view measured.
|
|
162
|
+
- **Warnings**: `app.warnings` is every silent misconfiguration the core
|
|
163
|
+
noticed (a `grow` weight with nothing to split, a transition on an unkeyed
|
|
164
|
+
list item, a duplicate key); assert it is empty.
|
|
165
|
+
|
|
166
|
+
## Windowed app checklist
|
|
167
|
+
|
|
168
|
+
Things the package already does that are easy to miss when building a
|
|
169
|
+
real window. The full prop / element / event reference ships with the
|
|
170
|
+
package as [props.md](props.md) (`docs/props.md` in the repository), and so
|
|
171
|
+
do [CHANGELOG.md](CHANGELOG.md) — every release lists what it adds and,
|
|
172
|
+
separately, what you can delete — and the ADRs under `docs/adr/`, which
|
|
173
|
+
is where a doc comment pointing at `docs/adr/0003-modal-surfaces.md`
|
|
174
|
+
resolves from inside `node_modules`. [howto.md](howto.md) ships with them:
|
|
175
|
+
about twenty questions — playing a sound, animating a removal, resetting an
|
|
176
|
+
editor, driving a real window in a test — answered in two sentences each and
|
|
177
|
+
pointing into the other three.
|
|
178
|
+
|
|
179
|
+
- **Hover and pressed colors** are props, not queries: `hoverBg`,
|
|
180
|
+
`pressedBg`, and `hoverGroup="name"` to light connected pieces together.
|
|
181
|
+
Add `transition={150}` and the swap eases. `<button>` is exactly that data.
|
|
182
|
+
For hover-dependent *layout* use `onHover={tag}` and react to
|
|
183
|
+
`{kind: 'hover', phase: 'enter' | 'leave'}` events; `win.isHovered(key)`
|
|
184
|
+
and `isPressed` answer for keys you got from events.
|
|
185
|
+
- **The pointer shape is declared**, not derived: `cursor="pointer"` on a
|
|
186
|
+
control, `cursor="grab"` on a handle (and `'grabbing'` while its drag
|
|
187
|
+
runs), `cursor="ewResize"` on a splitter. A node that declares nothing is
|
|
188
|
+
`default` — an `onClick`, `focusable` or `onDrag` box included, as a
|
|
189
|
+
native button is — except over an editor or a `selectable` scope, where
|
|
190
|
+
it is `text`. The stock `<button>` declares its own hand. A window applies
|
|
191
|
+
it by itself; `ctx.cursorShape()` reads it back for a test.
|
|
192
|
+
- **Motion that never settles** is data too: `keyframes` takes CSS-style
|
|
193
|
+
stops for `width` / `height` / `bg` / `radius`, cycled over
|
|
194
|
+
`transition` ms in CSS's `animation-direction` (`repeat="alternate"`)
|
|
195
|
+
and held back by `delay` ms so siblings stagger —
|
|
196
|
+
`<box transition={1100} easing="easeInOut" repeat="alternate" delay={i * 550}
|
|
197
|
+
width={{ grow: 0 }} keyframes={[{ width: { grow: 1 } }]} />` slides
|
|
198
|
+
forever without the app ever waking up to flip it. Stops spread evenly
|
|
199
|
+
unless they name `at` (0..1); a slot a stop leaves out falls back to the
|
|
200
|
+
node's own prop, so `[{ at: 0.5, bg: '#f5a97f' }]` is a pulse.
|
|
201
|
+
- **Arrivals** are data too. A node's first frame snaps, so a slide-in
|
|
202
|
+
needed an off-screen frame and a second render; `enter` states the
|
|
203
|
+
starting point instead: `<box key="toast" float="viewport" transition={200}
|
|
204
|
+
enter={{ dx: 320, bg: '#00000000' }} …/>` slides in from the right and
|
|
205
|
+
fades up on the frame it appears, and enters again after being dismissed.
|
|
206
|
+
Add `slide` if it should also glide when layout moves it later — a float
|
|
207
|
+
whose `dx`/`dy` changes eases to the new offset with `slide` alone.
|
|
208
|
+
- **Frame timing without the overlay**: `win.frameStats()` is the latency
|
|
209
|
+
HUD as data (`last.viewMs`, `avgWorkMs`, …), `win.stats()` the display
|
|
210
|
+
list summary, and `win.animating()` tells a test when motion has settled.
|
|
211
|
+
- **Tooltips**: `tooltip="hint"` on any box (implies hover tracking).
|
|
212
|
+
- **Per-corner radius**: `radius` for all four, `radiusTL` / `radiusTR` /
|
|
213
|
+
`radiusBR` / `radiusBL` after it for the exceptions.
|
|
214
|
+
- **Sound**: register bytes once (`win.addSound(buf)` in `setup`, any
|
|
215
|
+
wav/ogg/mp3/flac), then reach for it three ways. As props — `clickSound`
|
|
216
|
+
and `hoverSound` on any box, the audio equivalent of `hoverBg`. As an
|
|
217
|
+
element — `<audio key="music" src={id} loop volume={0.3} />` is a playback
|
|
218
|
+
retained by key: it plays while the view declares it, stops when the view
|
|
219
|
+
drops it, and `volume` / `paused` changes apply to the running sound
|
|
220
|
+
rather than restarting it, so `{model.music && <audio … />}` is the whole
|
|
221
|
+
on/off story. Or imperatively — `win.play(id, { volume, loop, fadeIn,
|
|
222
|
+
tag })` returns a playback id for `stop` / `setVolume` / `pause` /
|
|
223
|
+
`resume`, and a `tag` comes back as a `SoundMsg`
|
|
224
|
+
(`{kind: 'sound', phase: 'ended', tag}`) when that playback finishes on
|
|
225
|
+
its own, which is how a chime chains into the next state. Volumes are
|
|
226
|
+
linear amplitude. Headless (`createApp`) nothing plays: `ctx.audioCommands()`
|
|
227
|
+
hands you what a window would have played, which is what to assert on.
|
|
228
|
+
- **Fonts**: `win.loadFontsDir('fonts')` then `win.addSystemFont('Antonio')`,
|
|
229
|
+
or `win.loadFontFile('fonts/Antonio.ttf')`, `win.addFont(bytes)`, or an
|
|
230
|
+
installed family by name (see `systemFontFamilies()`, or `systemFonts()`
|
|
231
|
+
for which are monospaced, their weights and italics); then
|
|
232
|
+
`<text font={id}>`. Register in `setup(win)` before the first frame.
|
|
233
|
+
The installed ones are rescanned when macOS or Windows says a font was
|
|
234
|
+
installed or removed, and a `FontsMsg` follows for a model that keeps
|
|
235
|
+
the list; on Linux `win.reloadSystemFonts()` scans again.
|
|
236
|
+
- **Window chrome**: open with `chrome: 'custom'`, put a `<titlebar>` (or
|
|
237
|
+
your own strip with `window="drag"` plus `<windowButtons/>`) in the root;
|
|
238
|
+
on macOS it insets past the traffic lights itself. The root box's
|
|
239
|
+
`title` prop names the window each frame.
|
|
240
|
+
- **Overlays**: `float="below" | "above"` or
|
|
241
|
+
`float={{ anchor: 'viewport', at: ['end','end'], self: ['end','end'] }}`
|
|
242
|
+
draws on top without shifting anything.
|
|
243
|
+
- **Sliders and dividers**: `onDrag={tag}` gives `{ x, y, dx, dy, parent }`
|
|
244
|
+
— `parent` is the container rect, so a fraction needs no geometry query.
|
|
245
|
+
- **Measuring text**: `win.measureText('1,234', { size: 48, font })` (and
|
|
246
|
+
`ctx.measureText` headless) returns `{ width, height, lines }` — what
|
|
247
|
+
layout gives a `<text>` with that content and those props, at the
|
|
248
|
+
window's scale; pass a `maxWidth` to see it wrapped, and `wrap` /
|
|
249
|
+
`maxLines` / `ellipsis` apply. Size a column to its widest label, or pick
|
|
250
|
+
the tier whose labels fit, from these numbers; they follow the font. Both
|
|
251
|
+
are reachable where the sizing happens: `view(model, window, surface)`
|
|
252
|
+
gets the surface third, and `init(surface)` gets it before the first
|
|
253
|
+
model, so neither needs the surface parked in a module-level variable.
|
|
254
|
+
- **Where did layout put it**: `onLayout={tag}` on a keyed box brings back
|
|
255
|
+
`{ kind: 'layout', x, y, w, h, parent, tag }` — on its first frame and
|
|
256
|
+
whenever the rect changes, never on a frame that left it alone, so
|
|
257
|
+
keeping it in the model and re-rendering does not loop. A `slide` reports
|
|
258
|
+
every frame it moves. It is the numbers layout already computed, handed
|
|
259
|
+
back, for the case no prop covers yet.
|
|
260
|
+
- **Warnings**: the core notices what used to fail silently — a `{ grow: 2 }`
|
|
261
|
+
on the only grow child (or across the parent's main axis), a `transition`
|
|
262
|
+
on an unkeyed list item whose siblings changed count, two nodes on one
|
|
263
|
+
`key` — and `runWindowed` prints each once (`warnings: false` in the
|
|
264
|
+
options to stop it; `win.warnings()` drains them yourself). The checks
|
|
265
|
+
are a development aid: under `NODE_ENV=production` they do not run at
|
|
266
|
+
all (`diagnostics: true` forces them on).
|
|
267
|
+
- **Window size**: `win.size()` gives `{width, height, scale}` (logical px)
|
|
268
|
+
— in `setup(win)` before the first frame, in `init(win)` while the first
|
|
269
|
+
model is built, and any time after. It is the viewport the app lays out
|
|
270
|
+
into: the window's inner size, less the devtools' dock while the panel is
|
|
271
|
+
docked (`KUI_DEVTOOLS=1`), and the same number `env().viewport` reads once
|
|
272
|
+
a frame has run. `win.hostArea()` gives that viewport's place in the
|
|
273
|
+
window after a frame, `{x, y, w, h}` — right of the pane under a left
|
|
274
|
+
dock — which is what tells the app's quads from the dock's.
|
|
275
|
+
Headless, `ctx.size()` answers the same shape: the size
|
|
276
|
+
`createApp` frames at (its `width` / `height` / `scale`) before the first
|
|
277
|
+
frame, and the last frame's after. Changes
|
|
278
|
+
arrive as `{kind: 'resize', width, height, scale}` events — a dock coming,
|
|
279
|
+
going or being dragged among them — so a model that
|
|
280
|
+
tracks the size updates in `update` like anything else. Bound what the
|
|
281
|
+
user can resize to with `minWidth` / `minHeight` / `maxWidth` / `maxHeight`
|
|
282
|
+
next to `width` / `height` at open; either half of a pair may stand alone,
|
|
283
|
+
and `width`/`height` are clamped into the bounds the OS will enforce.
|
|
284
|
+
- **A clock**: `tick: { every, msg }` on either driver (`app.advance(ms)`
|
|
285
|
+
fires them headless), or `setTimeout` toward the next boundary in your own
|
|
286
|
+
loop; do not call `update` every pump. Ticks are frequent, so unlike UI events **a tick re-renders only
|
|
287
|
+
when `update` returns a new model** — a countdown that returns
|
|
288
|
+
`undefined` until the displayed second changes costs nothing in
|
|
289
|
+
between. The same rule read backwards is the trap: a tick handler that
|
|
290
|
+
mutates the model in place and returns `undefined` (the escape hatch
|
|
291
|
+
the rest of `update` allows) never reaches the screen. Any
|
|
292
|
+
non-`undefined` return renders, so `return model` after a mutation is
|
|
293
|
+
the whole fix. `every` may read the model — `every: (m) => m.running
|
|
294
|
+
? 16 : 1000` — for an app whose cadence depends on its state: the loop
|
|
295
|
+
re-reads it after every `update`, and since the windowed driver never
|
|
296
|
+
sleeps through a tick, a stopped countdown then costs a pump a second
|
|
297
|
+
instead of pinning the idle backoff at 16 ms.
|
|
298
|
+
- **Keys**: `onKey` on the root plus `keyFocus`; presses arrive as
|
|
299
|
+
`{ kind: 'key', phase: 'down', code, ... }` with `code` a character or a
|
|
300
|
+
name (`'space'`, `'enter'`, `'f5'`), and that is all a keymap needs — no
|
|
301
|
+
`phase` check. A held-key interaction (WASD, press-and-hold) adds `keyUp`
|
|
302
|
+
to the sink and hears releases too, as the same shape with
|
|
303
|
+
`phase: 'up'`. `repeat` marks an auto-repeat, and a release carries a
|
|
304
|
+
null `text`. A key only comes up where it went down — focus moving
|
|
305
|
+
delivers the release first — so nothing is left stuck. `location`
|
|
306
|
+
(`'numpad'`, `'left'`, `'right'`, else `'standard'`) says which of a
|
|
307
|
+
key's twins it was, `caps_lock` and `num_lock` what was locked, and a
|
|
308
|
+
sink that adds `modifierKeys` hears the modifier keys themselves
|
|
309
|
+
(`'shift'`, `'capslock'`, …).
|
|
310
|
+
`onKey={null}` is a sink whose events carry no `tag` (the same goes for
|
|
311
|
+
`onDrag`, `onHover` and `onLayout`), so a root sink needs no inert
|
|
312
|
+
message in the app's union.
|
|
313
|
+
- **Messages are yours**: annotate `update` and the loop follows —
|
|
314
|
+
`createApp` / `runWindowed` infer the union, so `ev`, `dispatch` and
|
|
315
|
+
`tick.msg` speak it too (a `tick.msg` written inline needs the union named
|
|
316
|
+
— `runWindowed<Model, Msg>(...)`, or a `msg` annotated where it is
|
|
317
|
+
written — since its return is what would be inferred from). `CoreMsg` is what the core sends on its own
|
|
318
|
+
(`DragMsg`, `KeyMsg`, `HoverMsg`, `ModifiersMsg`, `changed` / `submit`),
|
|
319
|
+
each with the payload fields spelled out; `pollEvents<KeyMsg<Tag>>()`
|
|
320
|
+
types a raw poll the same way. To have the *payload props* checked at the
|
|
321
|
+
node as well, register the app's own union once:
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
declare module '@qxuken/kui/jsx-runtime' {
|
|
325
|
+
interface KuiMsg { msg: MyMsg }
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
`onClick` then takes exactly `MyMsg` (and `onDrag` / `onHover` / `onKey` /
|
|
330
|
+
`onLayout` take `MyMsg | null`) rather than any plain data, so a typo
|
|
331
|
+
fails where it is written. Register the messages you wrote, not
|
|
332
|
+
`MyMsg | CoreMsg`: `CoreMsg` is typed in terms of the registration (its
|
|
333
|
+
`tag` fields carry your messages), so naming it there makes the alias
|
|
334
|
+
circular — keep that union for `update`. It is a program-wide
|
|
335
|
+
declaration (one app per tsconfig); left out, payload props stay untyped
|
|
336
|
+
and nothing else changes.
|