@solidrt/core 0.0.56 → 0.0.58

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/AGENTS.md CHANGED
@@ -132,6 +132,14 @@ latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works is
132
132
  window (message, stack, a Reset button that retries the failed
133
133
  computations) and logs `Uncaught error`. `<Errored>` gives a subtree its
134
134
  own in-place fallback.
135
+ An `onFrame` callback is its own boundary, event-listener style: a throw
136
+ is caught and logged (`Error in onFrame callback`, repeating every
137
+ frame), the subscription stays, and THE REST OF THAT CALLBACK IS
138
+ ABANDONED - what it wrote before the throw lands, what came after never
139
+ does, frame after frame while the app keeps presenting. A partial frame
140
+ is therefore a live possibility: every sprite drawn after the throwing
141
+ line simply stops appearing, which reads on screen as a logic bug, not a
142
+ crash. Check the logs before reasoning about a rendering symptom.
135
143
 
136
144
  - Two kinds of element:
137
145
  - Containers - `<window>`, `<view>`. Do layout + transform + pointer events.
@@ -312,6 +320,97 @@ latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works is
312
320
  rather than track pointers ad hoc, or they will double-handle against
313
321
  scrollers and pressables.
314
322
 
323
+ - The input map (input-map.ts) is how anything drives a control:
324
+ ARCHITECTURE.md's rule is that a control (a camera, a character
325
+ controller) consumes a device-free abstraction and never handles events
326
+ or reads a device itself, and the map is the named, typed layer between
327
+ devices and consumers - Godot's InputMap and Unity's action maps in
328
+ reactive form. `createInputMap({ look: "vec2", move: "vec2", zoom:
329
+ "axis", jump: "button" })` declares the app's actions once; devices bind
330
+ to them by name (`input.bind("look", pointer.drag, gamepad(0).rightStick)`,
331
+ `input.bind("move", keyboard.wasd, gamepad(0).leftStick)`); consumers
332
+ read the action, never the device. Every axis/vec2 action carries TWO
333
+ channels: the RATE (the sum of every bound source's current value,
334
+ clamped - an axis to -1..1, a vec2 to unit length - reactive, sampled per
335
+ frame by the consumer and integrated over dt at its own speed) and the
336
+ DELTAS (immediate amounts a gesture produced, in device-free units: a
337
+ drag in element heights, a wheel notch or a pinch in octaves, a twist in
338
+ turns, with begin/end brackets around a finger's gesture and the focal
339
+ point as a fraction of the element). Values use the screen convention
340
+ throughout: x right, y down, a stick pushed up reads y = -1, and a
341
+ consumer that means "forward" by a vec2 reads -y. A button source may
342
+ bind to an axis action (1 while pressed); a vec2 source on an axis
343
+ action throws.
344
+ Devices are the nouns: `keyboard.key("Space")`, `keyboard.axis("KeyQ",
345
+ "KeyE")`, `keyboard.vec2({ up, down, left, right })`, `keyboard.wasd`,
346
+ `keyboard.arrows`, a spec with modifiers ahead of the key
347
+ ("Shift+Tab", "Ctrl+KeyS": the most specific spec in a source wins a
348
+ down, an up releases on the bare key) (key events reach them through
349
+ `input.handlers`, spread on `<window>` for app-global keys or on the
350
+ leaf that should hold focus for them - keys bubble to the window, so a
351
+ TextInput's consumed keys never arrive); `gamepad(slot)`, `gamepad()`
352
+ for every pad, or `gamepad.next()` for the next unclaimed pad to press
353
+ any button (split screen seats players in pick-up order; its `slot`
354
+ resolves reactively and frees when the creating scope is disposed),
355
+ with `leftStick`, `rightStick`, `dpad`, `triggers` (right minus left),
356
+ `shoulders`, `axis(name)`, `button(name)`, reactive over gamepads() so a
357
+ stick wakes a control's frame loop; `createPointerFeed()` for the one
358
+ device that is not global - a pointer event belongs to the element
359
+ under it, so the feed is per element: spread `feed.handlers` on the
360
+ leaf (`<Scene pointer>`, `<View3d pointer>`, `<SpriteLayer pointer>` and
361
+ `<View2d pointer>` take a feed and spread it on their built-in leaf) and
362
+ bind its gestures `drag` (one pointer), `pan` (two), `pinch`, `twist`,
363
+ `wheel` and `mouseDelta` (raw motion while pointerLocked()). One
364
+ merged recognizer serves them, so they arbitrate in the arena as one.
365
+ Each gesture takes a chord in the key-spec grammar: `pointer.drag("Ctrl")`
366
+ fires only when the event that opens the gesture carries Ctrl, and then
367
+ instead of the bare `drag` (the most specific bound chord wins, as a
368
+ key spec does), held for the whole gesture - so plain drag on `rotate`
369
+ and `drag("Ctrl")` on `pan` is one binding each, the every-viewport
370
+ default. A bracketed spec may end in its button (`drag("Right")`,
371
+ `drag("Shift+Middle")`; bare means Left), a discriminator rather than
372
+ a modifier: a right drag feeds only Right specs. Touch carries no
373
+ modifiers or buttons: a chord is the desktop path, the bare binding
374
+ stays for fingers. A
375
+ detached d-* leaf has no layout box: give the feed `{ layout }` or it
376
+ throws at the first press. `invert(source)` and `scale(source, k)` are
377
+ the two processors: keys and sticks move the CAMERA where a drag moves
378
+ the content, so a preset binds arrows or a stick to `pan`/`rotate`
379
+ through invert() and the action keeps one meaning.
380
+ Consumers with the axes contract (`createAxes({ rotate: "vec2", zoom:
381
+ "axis" }, { onNudge, onBegin, onEnd })`, what every camera control
382
+ exposes as `.axes`) are connected by name with `input.drive(control.axes,
383
+ names?)`: each axis takes the action of the same name, or the one
384
+ `names` gives it (null skips one), both channels; the camera components
385
+ take the map as their `input` prop and do exactly that. Injection by
386
+ name is the wire for scripts, debug commands, peers and replays:
387
+ `input.set("move", [0, -1])` holds a rate, `press`/`release` a button,
388
+ `nudge`/`begin`/`end` feed the delta channel. `bindings()` lists what is
389
+ bound (source labels are display strings), `unbind` removes one, and a
390
+ preset (`orbitBindings`, `firstPersonBindings` in @solidrt/3d,
391
+ `camera2dBindings` in @solidrt/2d) is a plain bindings list the app
392
+ applies and edits - nothing binds unless the app says so (the one
393
+ built-in default is the UI set inside components' createFocusNav, the
394
+ platform's own layer). The map is an instance: two pads on two views is
395
+ two maps. Contexts are a switch per action: `input.disable("move",
396
+ "jump")` / `enable(...)` (a set is a list of names, so
397
+ `disable(...Object.keys(gameActions))` when a menu opens); a disabled
398
+ action reads neutral, drops its deltas and closes the gesture it had
399
+ open, while its sources keep their state so a key still held when it
400
+ comes back reads at once; `enabled(name)` is reactive. Create maps and
401
+ feeds in an owned scope (a component body): onPress/onRelease run
402
+ effects under it and the feed's recognizer registers its cleanup there. The vocabulary the
403
+ controls share, one kind and unit per word: `pan` vec2 (view heights),
404
+ `zoom` axis (octaves, positive in), `rotate` vec2 (orbit turns), `roll`
405
+ axis (turns about the view axis), `look` vec2 (turns), `move` vec2
406
+ ([right, forward], forward = -y), `rise` axis (up). The runtime-free
407
+ half (`createInputMap`, `createAxes`, `keyboard`, the processors) is
408
+ importable as `@solidrt/core/input` for headless checks;
409
+ checks/input-map-check.ts runs it on the bare flux binary, and
410
+ checks/input-gamepad-check.ts drives the gamepad device and the join
411
+ order over a signal of pad snapshots (input-gamepad-device.ts is
412
+ runtime-free; input-gamepad.ts hands it gamepads()).
413
+
315
414
  - Reactivity is SolidJS 2.0 (`@solidjs/signals`), NOT Solid 1.x. `createSignal`
316
415
  is as you expect, but `createEffect` takes the 2.0 two-function shape: a
317
416
  TRACKED compute that reads signals and returns a value, then an UNTRACKED
@@ -347,7 +446,11 @@ latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works is
347
446
  TypeScript with no @solidrt imports, with the JSX reading it and the
348
447
  per-frame hook doing one state step plus a handful of property writes.
349
448
  That shape runs under bun with no window (tests, audits, replaying a whole
350
- session in seconds) and it is what keeps per-frame work cheap.
449
+ session in seconds) and it is what keeps per-frame work cheap. For a game
450
+ it is the tuning loop itself: minutes of play run headless in a fraction
451
+ of a second, so a difficulty curve or a spawn table is tuned against
452
+ scripted policies in seconds instead of by playing, and the design bugs
453
+ that only show at scale surface there first.
351
454
  - Animation is target-shaped first: declare `transition` on the element and
352
455
  write targets, and the runtime animates natively with no per-frame JS.
353
456
  Enter and exit are per-property entries on that same spec: `from` is the
@@ -359,7 +462,11 @@ latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works is
359
462
  descendants declare `from`/`exit`. An enter plays once per mount; to
360
463
  replay it, remount the subtree (`<Show when={epoch()} keyed>` around it,
361
464
  then bump `epoch`). See examples/stagger.tsx.
362
- Reach for per-frame work only for genuinely procedural motion:
465
+ Per-frame work is for genuinely procedural motion, and a game or a
466
+ simulation is that in full: its natural shape is ONE `onFrame` doing one
467
+ sim step and N property (or sprite) writes, which is not a smell but the
468
+ budget the runtime is built for - agents/performance.md rule 4 has the
469
+ rules, @solidrt/2d's notes the measured write costs at scale.
363
470
  `onFrame((tick, frame, rate) => {})` is the native hook (runtime-paced,
364
471
  returns a cleanup, auto-cleaned inside an owned scope; from an effect's
365
472
  apply, return it so the loop runs only while the condition holds); `rate` is the
@@ -25,6 +25,9 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
25
25
  ## Pointer input
26
26
  - `pointer-local-coords.tsx` - the three pointer coordinate frames (`clientX` window, `localX` the handling node's own frame, `parentX` its path-parent's frame - where the node's x/y live) and the transform-proof drag idiom: grab offset from `localX` at down, place with `parentX - offset` on moves. Exact inside rotated/scaled ancestors and when the pointer leaves the node mid-drag.
27
27
 
28
+ ## Gamepads
29
+ - `gamepad.tsx` - the pad test bed: every connected pad's raw `gamepads()` snapshot (slots, buttons, axes) and two players seated by `gamepad.next()` - press any button on a pad to join player 1, another pad joins player 2 - each an input map (`move` on the left stick and dpad, `zoom` on the triggers, `jump` on south) printing its values live and walking a marker with `move`. Nothing moves until a pad does.
30
+
28
31
  ## Performance
29
32
  - `snapshot-texture.tsx` - `snapshotTexture(ref)`: a `repaintBoundary="snapshot"` view's rasterized pixels as a live texture id; a shader texture samples the panel and a sibling `<texture>` shows the warped copy. The boundary re-rasterizes only when its content changes.
30
33
  - `repaint-boundary.tsx` - `repaintBoundary` on a `<view>` to keep static content from rebuilding while a neighbor animates: `{true}` retains the recorded draw list, `"snapshot"` also retains the rasterized pixels as a GPU texture (for raster-expensive, screen-aligned, static subtrees). `"snapshot-no-aa"` rasterizes without anti-aliasing: cheaper, fine for text and axis-aligned rects, hard-edged on vector content.
@@ -0,0 +1,94 @@
1
+ // Gamepad test bed: every connected pad's raw snapshot (gamepads()) at the
2
+ // top, and two players seated by gamepad.next() below - press any button
3
+ // on a pad and it joins player 1, another pad joins player 2. Each player
4
+ // is an input map (`move` vec2 on the left stick and the dpad, `zoom` axis
5
+ // on the triggers, `jump` button on south) whose values print live, plus
6
+ // a marker that walks with `move`, so what a pad does is on screen.
7
+ // Nothing moves until a pad does.
8
+ import { createInputMap, createSignal, gamepad, gamepads, onFrame, render, untrack, windowSize, For } from "@solidrt/core"
9
+ import type { Vec2 } from "@solidrt/core"
10
+
11
+ const PLAYERS = 2
12
+ // Marker travel at full stick deflection, pixels per second.
13
+ const SPEED = 300
14
+ const MARKER = 28
15
+ const COLORS = ["#ffcc4d", "#66e6ff"]
16
+
17
+ let fmt = (v: number) => (v < 0 ? "" : " ") + v.toFixed(2)
18
+ let clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
19
+
20
+ function App() {
21
+ let players = Array.from({ length: PLAYERS }, (_, i) => {
22
+ let pad = gamepad.next()
23
+ let input = createInputMap({ move: "vec2", zoom: "axis", jump: "button" })
24
+ input.bind("move", pad.leftStick, pad.dpad)
25
+ input.bind("zoom", pad.triggers)
26
+ input.bind("jump", pad.button("south"))
27
+ // The marker: plain numbers stepped per frame, a signal to redraw.
28
+ let x = 80 + i * 120
29
+ let y = 260
30
+ let [pos, setPos] = createSignal<Vec2>([x, y])
31
+ let [jumps, setJumps] = createSignal(0)
32
+ input.onPress("jump", () => setJumps(n => n + 1))
33
+ let step = (dt: number) => {
34
+ let [mx, my] = untrack(() => input.value("move"))
35
+ if (mx === 0 && my === 0) return
36
+ let size = untrack(windowSize)
37
+ x = clamp(x + mx * SPEED * dt, 0, Math.max(0, size.width - MARKER))
38
+ y = clamp(y + my * SPEED * dt, 0, Math.max(0, size.height - MARKER))
39
+ setPos([x, y])
40
+ }
41
+ return { pad, input, pos, jumps, step }
42
+ })
43
+ let last: number | null = null
44
+ onFrame(tick => {
45
+ let dt = last === null ? 0 : (tick - last) / 1000
46
+ last = tick
47
+ for (let p of players) p.step(dt)
48
+ })
49
+ let padLine = (slot: number, pad: { name: string; buttons: string[]; axes: Record<string, number> } | null) => {
50
+ if (!pad) return `slot ${slot}: empty`
51
+ let a = pad.axes
52
+ return `slot ${slot}: ${pad.name} - buttons [${pad.buttons.join(" ")}] left${fmt(a.leftX ?? 0)},${fmt(a.leftY ?? 0)} right${fmt(a.rightX ?? 0)},${fmt(a.rightY ?? 0)} triggers${fmt(a.leftTrigger ?? 0)},${fmt(a.rightTrigger ?? 0)}`
53
+ }
54
+ return (
55
+ <window>
56
+ <d-rect color="#181820" />
57
+ <view flexDirection="column" gap={10} padding={24}>
58
+ <text color="#eef4ff" fontSize={22} fontWeight={700}>
59
+ Gamepads
60
+ </text>
61
+ <text color="#a9bcd6" fontSize={14}>
62
+ press any button on a pad to join as player 1, another pad for player 2 - left stick or dpad walks the marker, triggers read zoom, south counts jumps
63
+ </text>
64
+ <view flexDirection="column" gap={2}>
65
+ <text color="#a9bcd6" fontSize={14}>
66
+ connected: {String(gamepads().filter(p => p !== null).length)}
67
+ </text>
68
+ <For each={gamepads()} keyed={false}>
69
+ {(pad, i) => (
70
+ <text color="#eef4ff" fontSize={14}>
71
+ {padLine(i, pad())}
72
+ </text>
73
+ )}
74
+ </For>
75
+ </view>
76
+ <For each={players}>
77
+ {(p, i) => (
78
+ <view flexDirection="column" gap={2}>
79
+ <text color={COLORS[i()]} fontSize={16} fontWeight={700}>
80
+ player {String(i() + 1)}: {p.pad.slot === undefined ? "press any button on a pad to join" : `pad ${p.pad.slot}`}
81
+ </text>
82
+ <text color="#eef4ff" fontSize={14}>
83
+ move{fmt(p.input.value("move")[0])},{fmt(p.input.value("move")[1])} zoom{fmt(p.input.value("zoom"))} jump {p.input.pressed("jump") ? "down" : "up"} (x{String(p.jumps())})
84
+ </text>
85
+ </view>
86
+ )}
87
+ </For>
88
+ </view>
89
+ <For each={players}>{(p, i) => <d-rect x={p.pos()[0]} y={p.pos()[1]} w={MARKER} h={MARKER} radius={6} color={COLORS[i()]} />}</For>
90
+ </window>
91
+ )
92
+ }
93
+
94
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.56",
3
+ "version": "0.0.58",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -13,6 +13,7 @@
13
13
  "./data": "./src/data.ts",
14
14
  "./gpu": "./src/gpu.ts",
15
15
  "./image": "./src/image.ts",
16
+ "./input": "./src/input.ts",
16
17
  "./audio": "./src/audio.ts",
17
18
  "./microphone": "./src/microphone.ts",
18
19
  "./speech-recognition": "./src/speech-recognition.ts",
@@ -30,7 +31,7 @@
30
31
  "AGENTS.md"
31
32
  ],
32
33
  "devDependencies": {
33
- "@solidrt/flux-types": "0.0.56"
34
+ "@solidrt/flux-types": "0.0.58"
34
35
  },
35
36
  "peerDependencies": {
36
37
  "@solidjs/signals": "2.0.0-rc.6",
package/src/index.ts CHANGED
@@ -28,6 +28,16 @@ export { createPan } from "./pan"
28
28
  export type { PanAxis, PanOptions } from "./pan"
29
29
  export { createTransform } from "./transform"
30
30
  export type { TransformDelta, TransformOptions } from "./transform"
31
+ export { createInputMap, invert, scale } from "./input-map"
32
+ export type { ActionKind, ActionValue, ActionsDecl, Binding, DeltaSink, GestureListener, InputMap, InputSource } from "./input-map"
33
+ export { createAxes } from "./input-axes"
34
+ export type { Axes, AxesDecl, AxesHooks, AxisKind, AxisValue, Vec2 } from "./input-axes"
35
+ export { keyboard } from "./input-keyboard"
36
+ export type { KeyboardDevice, KeyboardVec2Keys } from "./input-keyboard"
37
+ export { gamepad } from "./input-gamepad"
38
+ export type { GamepadDevice } from "./input-gamepad"
39
+ export { createPointerFeed } from "./input-pointer"
40
+ export type { PointerFeed, PointerFeedOptions, PointerSource } from "./input-pointer"
31
41
  export type {
32
42
  Element,
33
43
  LayoutProps,
@@ -91,6 +101,8 @@ export {
91
101
  createRoot,
92
102
  createStore,
93
103
  reconcile,
104
+ merge,
105
+ omit,
94
106
  mapArray,
95
107
  repeat,
96
108
  untrack,
@@ -0,0 +1,180 @@
1
+ // The control side of the input abstraction (ARCHITECTURE.md: controls
2
+ // through an abstraction, never direct event handling). A control - a
3
+ // camera controller, a character controller - declares the axes it
4
+ // consumes and receives input only through them, in device-free units:
5
+ //
6
+ // - rates: -1..1 values sampled at update time (a held key, a deflected
7
+ // stick, a value an app set), which the control integrates over dt at
8
+ // its own speeds. Sources are added, never set: several may feed one
9
+ // axis (keys and a stick), and the control sums and clamps them, so
10
+ // removing one never has to know what the others hold.
11
+ // - nudges: immediate deltas (a drag, a wheel notch, a pinch) in the
12
+ // vocabulary's units - view heights of travel, octaves of zoom, turns -
13
+ // applied at once, with the focal point (a fraction of the element)
14
+ // when the gesture has one. begin/end bracket a gesture, so a control
15
+ // can tell a finger's tracking (apply exactly) from an impulse (a wheel
16
+ // notch, unbracketed: ease it), stop a glide when a finger lands, and
17
+ // fling when it lifts.
18
+ //
19
+ // The values use the screen convention throughout: x right, y down, the
20
+ // way pointer deltas and the web Gamepad API report (a stick pushed up
21
+ // reads y = -1). A control that means "forward" by a vec2 reads -y.
22
+ //
23
+ // `active()` is the frame-loop gate: reactive, true while any rate source
24
+ // reads non-zero, so a control's loop runs only while something drives it.
25
+ // A rate source must therefore read reactive state (a signal, gamepads());
26
+ // a source over plain variables never wakes the loop - the caller runs
27
+ // update(dt) itself then.
28
+ //
29
+ // An input map drives an Axes object by action name (InputMap.drive); an
30
+ // app can also add its own sources and nudges directly.
31
+
32
+ import { createMemo, createSignal, untrack } from "@solidjs/signals"
33
+
34
+ export type AxisKind = "axis" | "vec2"
35
+ export type Vec2 = [number, number]
36
+ export type AxisValue<K extends AxisKind> = K extends "axis" ? number : Vec2
37
+
38
+ export type AxesDecl = Record<string, AxisKind>
39
+
40
+ export interface AxesHooks<A extends AxesDecl> {
41
+ /** An immediate delta on `name`: view heights for a vec2, the axis's
42
+ * unit (octaves, turns) for an axis; `focal` in 0..1 of the element
43
+ * when the gesture has one (a pinch, a wheel). */
44
+ onNudge?: <N extends keyof A & string>(name: N, delta: AxisValue<A[N]>, focal: Vec2 | undefined) => void
45
+ /** A gesture on `name` started (a finger landed) / ended (lifted). */
46
+ onBegin?: (name: keyof A & string) => void
47
+ onEnd?: (name: keyof A & string) => void
48
+ }
49
+
50
+ export interface Axes<A extends AxesDecl> {
51
+ /** The declared axes, name to kind - what a map's drive() reads. */
52
+ readonly kinds: A
53
+ /** Add a rate source (reactive read, -1..1 per component); returns the
54
+ * remover. Several sources sum, then clamp (an axis to -1..1, a vec2 to
55
+ * unit length). */
56
+ add<N extends keyof A & string>(name: N, rate: () => AxisValue<A[N]>): () => void
57
+ /** The combined rate of `name` now (reactive read). */
58
+ rate<N extends keyof A & string>(name: N): AxisValue<A[N]>
59
+ /** Reactive: whether any rate reads non-zero - the frame-loop gate. */
60
+ active(): boolean
61
+ /** Apply an immediate delta (see AxesHooks.onNudge). */
62
+ nudge<N extends keyof A & string>(name: N, delta: AxisValue<A[N]>, focal?: Vec2): void
63
+ /** Bracket a gesture on `name`; nested begins count. */
64
+ begin(name: keyof A & string): void
65
+ end(name: keyof A & string): void
66
+ /** Whether a gesture on `name` is open (between begin and end). */
67
+ inGesture(name: keyof A & string): boolean
68
+ }
69
+
70
+ let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
71
+
72
+ /** Sum rate values of one kind and clamp: an axis to -1..1, a vec2 to unit length. */
73
+ export function combineRates(kind: AxisKind, values: (number | Vec2)[]): number | Vec2 {
74
+ if (kind === "axis") {
75
+ let sum = 0
76
+ for (let v of values) sum += v as number
77
+ return clampNum(sum, -1, 1)
78
+ }
79
+ let x = 0
80
+ let y = 0
81
+ for (let v of values) {
82
+ x += (v as Vec2)[0]
83
+ y += (v as Vec2)[1]
84
+ }
85
+ let len = Math.hypot(x, y)
86
+ return len > 1 ? [x / len, y / len] : [x, y]
87
+ }
88
+
89
+ function checkValue(what: string, kind: AxisKind, v: unknown): void {
90
+ if (kind === "axis") {
91
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`${what}: expected a finite number, got ${String(v)}`)
92
+ return
93
+ }
94
+ if (!Array.isArray(v) || v.length !== 2 || !Number.isFinite(v[0]) || !Number.isFinite(v[1])) {
95
+ throw new Error(`${what}: expected [x, y] finite numbers, got ${JSON.stringify(v)}`)
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Declare a control's axes: `createAxes({ rotate: "vec2", zoom: "axis" },
101
+ * { onNudge, onBegin, onEnd })`. The hooks are how nudges reach the
102
+ * control's pose; rates are read back with `rate()` from its update(dt).
103
+ */
104
+ export function createAxes<A extends AxesDecl>(kinds: A, hooks: AxesHooks<A> = {}): Axes<A> {
105
+ for (let [name, kind] of Object.entries(kinds)) {
106
+ if (kind !== "axis" && kind !== "vec2") throw new Error(`createAxes: axis "${name}" has kind "${String(kind)}", expected "axis" or "vec2"`)
107
+ }
108
+ let check = (name: string): AxisKind => {
109
+ let kind = kinds[name]
110
+ if (!kind) throw new Error(`createAxes: no axis "${name}" (declared: ${Object.keys(kinds).join(", ")})`)
111
+ return kind
112
+ }
113
+ // Sources per axis: a plain list is the truth (a write and a read in
114
+ // the same synchronous block must agree, which a signal's deferred
115
+ // write does not give), and a version signal notifies the reactive
116
+ // readers - active() and whoever tracks rate() - of a change.
117
+ let sources = new Map<string, (() => number | Vec2)[]>()
118
+ // ownedWrite: add() runs from component bodies (a map's drive()).
119
+ let [version, setVersion] = createSignal(0, { ownedWrite: true })
120
+ let bump = () => setVersion(untrack(version) + 1)
121
+ for (let name of Object.keys(kinds)) sources.set(name, [])
122
+ let rate = (name: string): number | Vec2 => {
123
+ version()
124
+ let values: (number | Vec2)[] = []
125
+ for (let read of sources.get(name)!) values.push(read())
126
+ return combineRates(kinds[name]!, values)
127
+ }
128
+ let active = createMemo(() => {
129
+ for (let [name, kind] of Object.entries(kinds)) {
130
+ let v = rate(name)
131
+ if (kind === "axis" ? v !== 0 : (v as Vec2)[0] !== 0 || (v as Vec2)[1] !== 0) return true
132
+ }
133
+ return false
134
+ })
135
+ let open = new Map<string, number>()
136
+ return {
137
+ kinds,
138
+ add(name, read) {
139
+ check(name)
140
+ if (typeof read !== "function") throw new Error(`createAxes: add("${name}") expects a function`)
141
+ let list = sources.get(name)!
142
+ list.push(read)
143
+ bump()
144
+ return () => {
145
+ let i = list.indexOf(read)
146
+ if (i < 0) return
147
+ list.splice(i, 1)
148
+ bump()
149
+ }
150
+ },
151
+ rate(name) {
152
+ check(name)
153
+ return rate(name) as never
154
+ },
155
+ active,
156
+ nudge(name, delta, focal) {
157
+ let kind = check(name)
158
+ checkValue(`nudge("${name}")`, kind, delta)
159
+ if (focal !== undefined) checkValue(`nudge("${name}") focal`, "vec2", focal)
160
+ hooks.onNudge?.(name, delta, focal)
161
+ },
162
+ begin(name) {
163
+ check(name)
164
+ let depth = open.get(name) ?? 0
165
+ open.set(name, depth + 1)
166
+ if (depth === 0) hooks.onBegin?.(name)
167
+ },
168
+ end(name) {
169
+ check(name)
170
+ let depth = open.get(name) ?? 0
171
+ if (depth === 0) return
172
+ open.set(name, depth - 1)
173
+ if (depth === 1) hooks.onEnd?.(name)
174
+ },
175
+ inGesture(name) {
176
+ check(name)
177
+ return (open.get(name) ?? 0) > 0
178
+ },
179
+ }
180
+ }
@@ -0,0 +1,44 @@
1
+ // Modifier chords, the vocabulary two devices share: a spec names
2
+ // modifiers ahead of what it qualifies ("Shift+Tab" on the keyboard,
3
+ // "Ctrl" on a pointer gesture), the event must carry them, a bare spec
4
+ // ignores them (a shifted W still walks, a Ctrl-drag still drags), and
5
+ // among the specs one event satisfies the most specific wins - Unreal's
6
+ // chord blocker, Three's OrbitControls picking PAN over ROTATE at
7
+ // mousedown; Godot and Unity fire both by default and every app writes
8
+ // the exclusion itself. Which event resolves a chord is the device's
9
+ // call: the keyboard settles each key down, the pointer feed the event
10
+ // that opens a gesture bracket.
11
+
12
+ export type Modifier = "shiftKey" | "ctrlKey" | "altKey" | "metaKey"
13
+ /** The modifier flags every key, pointer and wheel event carries. */
14
+ export type Modified = Record<Modifier, boolean>
15
+
16
+ const MODIFIERS: Record<string, Modifier> = { Shift: "shiftKey", Ctrl: "ctrlKey", Control: "ctrlKey", Alt: "altKey", Meta: "metaKey" }
17
+ // The canonical order a chord is spelled in, so one modifier set has one name.
18
+ const ORDER: Modifier[] = ["shiftKey", "ctrlKey", "altKey", "metaKey"]
19
+ const NAMES: Record<Modifier, string> = { shiftKey: "Shift", ctrlKey: "Ctrl", altKey: "Alt", metaKey: "Meta" }
20
+
21
+ /** The modifiers `parts` name (["Ctrl", "Shift"]), deduplicated, in
22
+ * canonical order; `what` and `text` name the caller and its spec in the
23
+ * throw on an unknown name. */
24
+ export function parseModifiers(what: string, text: string, parts: string[]): Modifier[] {
25
+ let mods = new Set<Modifier>()
26
+ for (let part of parts) {
27
+ let mod = MODIFIERS[part]
28
+ if (!mod) throw new Error(`${what}: unknown modifier "${part}" in "${text}" (Shift, Ctrl, Alt or Meta)`)
29
+ mods.add(mod)
30
+ }
31
+ return ORDER.filter(m => mods.has(m))
32
+ }
33
+
34
+ /** The canonical spelling of a modifier set: "Shift+Ctrl"; "" for none. */
35
+ export let chordName = (mods: Modifier[]): string => mods.map(m => NAMES[m]).join("+")
36
+
37
+ /** Of `items`, those whose modifiers the event carries, narrowed to the
38
+ * most specific (the longest modifier lists, all of them on a tie);
39
+ * empty when none match. */
40
+ export function mostSpecific<T>(items: T[], mods: (item: T) => Modifier[], event: Modified): T[] {
41
+ let hits = items.filter(item => mods(item).every(m => event[m]))
42
+ let most = hits.reduce((n, item) => Math.max(n, mods(item).length), 0)
43
+ return hits.filter(item => mods(item).length === most)
44
+ }