@solidrt/core 0.0.59 → 0.0.61

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.
@@ -0,0 +1,150 @@
1
+ // The double-tap recognizer: two taps, the second down within
2
+ // DOUBLE_TAP_MS of the first up, within DOUBLE_TAP_SLOP of it and at
3
+ // least DOUBLE_TAP_MIN_MS after it (a bouncing contact is one tap, not
4
+ // two). Fires on the second DOWN (Flutter, iOS), so it is not delayed by
5
+ // the second lift. A tap is a down that stays within TAP_SLOP and lifts
6
+ // before the long-press hold (a longer hold is a long-press, never half a
7
+ // double-tap).
8
+ //
9
+ // Arena: the first tap claims nothing (it must not disturb a press on the
10
+ // same node) but PENDS a decision on its pointer, so a press that
11
+ // resolved at the first lift defers its firing (arena.defer): the single
12
+ // tap fires once the window passes with no second tap, and never when
13
+ // the double-tap wins. The second down steals its pointer outright (the
14
+ // press's provisional claim is cancelled, so the second tap never shows
15
+ // pressed or fires) and releases it at the lift. Options are read at
16
+ // event time; cancel() retracts without firing; an unmount clears the
17
+ // window timer. Mouse and touch alike (a double-click double-taps).
18
+ //
19
+ // The tap bookkeeping (createTapSequence) is shared with the pointer
20
+ // feed's doubleTap source, which pulses instead of stealing.
21
+
22
+ import { onSettled } from "@solidjs/signals"
23
+ import type { PointerEvent } from "./types"
24
+ import { arena, type ArenaOwner } from "./arena"
25
+ import { pastSlop, pointAt, type PointerPoint } from "./gesture"
26
+
27
+ // Second down at most this long after the first up.
28
+ const DOUBLE_TAP_MS = 300
29
+ // Second down at most this far (window px) from the first down: Flutter's
30
+ // kDoubleTapSlop, generous because the second tap of a quick pair lands
31
+ // where the hand is, not where the eye is.
32
+ const DOUBLE_TAP_SLOP = 100
33
+ // Second down at least this long after the first up (Flutter's
34
+ // kDoubleTapMinTime): sooner is a contact bounce.
35
+ const DOUBLE_TAP_MIN_MS = 40
36
+ // A down held longer than this before its lift is not a tap.
37
+ const TAP_MAX_MS = 500
38
+
39
+ type Mods = { shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean }
40
+
41
+ export interface TapSequenceOptions {
42
+ /** A first tap started: its outcome will be decided. */
43
+ onPend?: (pointerId: number) => void
44
+ /** The first tap's outcome: won (a second tap followed) or lost. */
45
+ onDecide?: (pointerId: number, won: boolean) => void
46
+ /** The second qualifying down, with its position and modifiers. */
47
+ onDouble: (pointerId: number, at: PointerPoint, mods: Mods) => void
48
+ }
49
+
50
+ /** The tap bookkeeping under a double-tap: one first tap at a time,
51
+ * decided by the second down, by travel, by a long hold, by the window
52
+ * timer or by cancel(). */
53
+ export function createTapSequence(options: TapSequenceOptions) {
54
+ let first: { id: number; at: PointerPoint; downAt: number; upAt: number | null } | null = null
55
+ let timer: ReturnType<typeof setTimeout> | null = null
56
+ let settle = (won: boolean) => {
57
+ if (!first) return
58
+ if (timer !== null) clearTimeout(timer)
59
+ timer = null
60
+ let id = first.id
61
+ first = null
62
+ options.onDecide?.(id, won)
63
+ }
64
+ return {
65
+ down(e: PointerEvent) {
66
+ let now = performance.now()
67
+ if (first) {
68
+ if (first.upAt === null) {
69
+ // A second finger while the first is down: neither is a tap.
70
+ if (first.id !== e.pointerId) settle(false)
71
+ return
72
+ }
73
+ let gap = now - first.upAt
74
+ if (gap >= DOUBLE_TAP_MIN_MS && gap <= DOUBLE_TAP_MS && !pastSlop(e, first.at, DOUBLE_TAP_SLOP)) {
75
+ settle(true)
76
+ options.onDouble(e.pointerId, pointAt(e), { shiftKey: e.shiftKey, ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey })
77
+ return
78
+ }
79
+ // Too far, too late or a bounce: the first tap stands alone and
80
+ // this down starts over.
81
+ settle(false)
82
+ }
83
+ first = { id: e.pointerId, at: pointAt(e), downAt: now, upAt: null }
84
+ options.onPend?.(e.pointerId)
85
+ },
86
+ move(e: PointerEvent) {
87
+ if (first && first.id === e.pointerId && first.upAt === null && pastSlop(e, first.at)) settle(false)
88
+ },
89
+ up(e: PointerEvent) {
90
+ if (!first || first.id !== e.pointerId || first.upAt !== null) return
91
+ let now = performance.now()
92
+ if (now - first.downAt > TAP_MAX_MS) {
93
+ settle(false)
94
+ return
95
+ }
96
+ first.upAt = now
97
+ timer = setTimeout(() => {
98
+ timer = null
99
+ settle(false)
100
+ }, DOUBLE_TAP_MS)
101
+ },
102
+ cancel: () => settle(false),
103
+ }
104
+ }
105
+
106
+ export interface DoubleTapOptions {
107
+ /** The second tap's down, with its position. */
108
+ onDoubleTap?: (at: PointerPoint) => void
109
+ }
110
+
111
+ export function createDoubleTap(options: DoubleTapOptions) {
112
+ // The second tap's pointer, held from its down to its lift.
113
+ let second: number | null = null
114
+ let release = () => {
115
+ if (second !== null) {
116
+ arena.release(second, owner)
117
+ second = null
118
+ }
119
+ }
120
+ let cancel = () => {
121
+ taps.cancel()
122
+ release()
123
+ }
124
+ let owner: ArenaOwner = { cancel }
125
+ let taps = createTapSequence({
126
+ onPend: id => arena.pend(id, owner),
127
+ onDecide: (id, won) => arena.decide(id, owner, won),
128
+ onDouble: (id, at) => {
129
+ if (!arena.steal(id, owner)) return
130
+ second = id
131
+ options.onDoubleTap?.(at)
132
+ },
133
+ })
134
+ onSettled(() => cancel)
135
+
136
+ let handlers = {
137
+ onPointerDown: (e: PointerEvent) => {
138
+ if (e.button != null && e.button !== 0) return
139
+ taps.down(e)
140
+ },
141
+ onPointerMove: (e: PointerEvent) => {
142
+ taps.move(e)
143
+ },
144
+ onPointerUp: (e: PointerEvent) => {
145
+ if (second === e.pointerId) release()
146
+ taps.up(e)
147
+ },
148
+ }
149
+ return { handlers, cancel }
150
+ }
package/src/gesture.ts ADDED
@@ -0,0 +1,32 @@
1
+ // What the discrete recognizers (long-press, double-tap, swipe) share: the
2
+ // copied point of a pointer event, and the slop that separates a tap from
3
+ // a move. The event object itself is rewritten per node while it bubbles
4
+ // (window.ts dispatchPath), so a recognizer keeps a copy, never the event.
5
+
6
+ import type { PointerEvent } from "./types"
7
+
8
+ /** A pointer position in every frame the event carried: window pixels
9
+ * (client), the handler node's own pixels (local), and the frame its
10
+ * x/y live in (parent). */
11
+ export type PointerPoint = {
12
+ clientX: number
13
+ clientY: number
14
+ localX: number
15
+ localY: number
16
+ parentX: number
17
+ parentY: number
18
+ }
19
+
20
+ export let pointAt = (e: PointerEvent): PointerPoint => ({ clientX: e.clientX, clientY: e.clientY, localX: e.localX, localY: e.localY, parentX: e.parentX, parentY: e.parentY })
21
+
22
+ // Finger travel in window pixels that turns a held or tapped pointer into
23
+ // a move: a long-press past it never fires, a tap past it is not one
24
+ // (iOS's 10 pt allowable movement).
25
+ export const TAP_SLOP = 10
26
+
27
+ /** Whether the pointer has left the slop circle around the down. */
28
+ export let pastSlop = (e: PointerEvent, down: { clientX: number; clientY: number }, slop = TAP_SLOP): boolean => {
29
+ let dx = e.clientX - down.clientX
30
+ let dy = e.clientY - down.clientY
31
+ return dx * dx + dy * dy > slop * slop
32
+ }
package/src/index.ts CHANGED
@@ -15,6 +15,8 @@ export type { Capabilities, WindowSizeClass } from "./capabilities"
15
15
  export { createCubeTexture, createTexture } from "./gpu"
16
16
  export type { TextureId } from "./gpu"
17
17
  export { createImage, decodeImage, encodeImage } from "./image"
18
+ export { createCursor } from "./cursor"
19
+ export type { CursorFrame, CursorId, CursorName, CursorOptions } from "./cursor"
18
20
  export type { DecodedImage, ImageSource } from "./image"
19
21
  export { parseSvg, svg } from "./svg"
20
22
  export { Logo } from "./logo"
@@ -28,8 +30,19 @@ export { createPan } from "./pan"
28
30
  export type { PanAxis, PanOptions } from "./pan"
29
31
  export { createTransform } from "./transform"
30
32
  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 { createSwipe, classifySwipe } from "./swipe"
34
+ export type { SwipeDirection, SwipeOptions } from "./swipe"
35
+ export { createLongPress } from "./long-press"
36
+ export type { LongPressOptions } from "./long-press"
37
+ export { createDoubleTap } from "./double-tap"
38
+ export type { DoubleTapOptions } from "./double-tap"
39
+ export type { PointerPoint } from "./gesture"
40
+ export { createVelocityTracker, flingVelocity, FLING_MIN_VELOCITY } from "./velocity"
41
+ export type { Velocity, VelocityTracker } from "./velocity"
42
+ export { createInputMap } from "./input-map"
43
+ export type { ActionKind, ActionValue, ActionsDecl, Binding, DeltaSink, DeviceName, GestureListener, InputDevice, InputDeviceSet, InputMap, InputSource, RebindOptions, RebindPart } from "./input-map"
44
+ export { chord, doubleTap, hold, invert, scale, tap } from "./input-processors"
45
+ export { resolveSource } from "./input-id"
33
46
  export { createAxes } from "./input-axes"
34
47
  export type { Axes, AxesDecl, AxesHooks, AxisKind, AxisValue, Vec2 } from "./input-axes"
35
48
  export { keyboard } from "./input-keyboard"
@@ -45,6 +58,7 @@ export type {
45
58
  PointerProps,
46
59
  TransitionProps,
47
60
  Transition,
61
+ TransitionCurve,
48
62
  TransitionPropName,
49
63
  TransitionEndEvent,
50
64
  PointerEvent,
package/src/input-axes.ts CHANGED
@@ -42,9 +42,12 @@ export interface AxesHooks<A extends AxesDecl> {
42
42
  * unit (octaves, turns) for an axis; `focal` in 0..1 of the element
43
43
  * when the gesture has one (a pinch, a wheel). */
44
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). */
45
+ /** A gesture on `name` started (a finger landed) / ended (lifted). The
46
+ * end carries the release velocity in the axis's units per second when
47
+ * the gesture measured one (a drag's lift: view heights per second, the
48
+ * fling a control glides on), else undefined. */
46
49
  onBegin?: (name: keyof A & string) => void
47
- onEnd?: (name: keyof A & string) => void
50
+ onEnd?: <N extends keyof A & string>(name: N, velocity: AxisValue<A[N]> | undefined) => void
48
51
  }
49
52
 
50
53
  export interface Axes<A extends AxesDecl> {
@@ -60,9 +63,10 @@ export interface Axes<A extends AxesDecl> {
60
63
  active(): boolean
61
64
  /** Apply an immediate delta (see AxesHooks.onNudge). */
62
65
  nudge<N extends keyof A & string>(name: N, delta: AxisValue<A[N]>, focal?: Vec2): void
63
- /** Bracket a gesture on `name`; nested begins count. */
66
+ /** Bracket a gesture on `name`; nested begins count. An end may carry
67
+ * the release velocity (see AxesHooks.onEnd). */
64
68
  begin(name: keyof A & string): void
65
- end(name: keyof A & string): void
69
+ end<N extends keyof A & string>(name: N, velocity?: AxisValue<A[N]>): void
66
70
  /** Whether a gesture on `name` is open (between begin and end). */
67
71
  inGesture(name: keyof A & string): boolean
68
72
  }
@@ -165,12 +169,13 @@ export function createAxes<A extends AxesDecl>(kinds: A, hooks: AxesHooks<A> = {
165
169
  open.set(name, depth + 1)
166
170
  if (depth === 0) hooks.onBegin?.(name)
167
171
  },
168
- end(name) {
169
- check(name)
172
+ end(name, velocity) {
173
+ let kind = check(name)
174
+ if (velocity !== undefined) checkValue(`end("${name}") velocity`, kind, velocity)
170
175
  let depth = open.get(name) ?? 0
171
176
  if (depth === 0) return
172
177
  open.set(name, depth - 1)
173
- if (depth === 1) hooks.onEnd?.(name)
178
+ if (depth === 1) hooks.onEnd?.(name, velocity as never)
174
179
  },
175
180
  inGesture(name) {
176
181
  check(name)
@@ -34,6 +34,9 @@ export function parseModifiers(what: string, text: string, parts: string[]): Mod
34
34
  /** The canonical spelling of a modifier set: "Shift+Ctrl"; "" for none. */
35
35
  export let chordName = (mods: Modifier[]): string => mods.map(m => NAMES[m]).join("+")
36
36
 
37
+ /** The modifiers an event carries, in canonical order. */
38
+ export let eventModifiers = (event: Modified): Modifier[] => ORDER.filter(m => event[m])
39
+
37
40
  /** Of `items`, those whose modifiers the event carries, narrowed to the
38
41
  * most specific (the longest modifier lists, all of them on a tie);
39
42
  * empty when none match. */
@@ -15,18 +15,28 @@
15
15
  // they pick up a pad. A claim lasts until the owning scope is disposed
16
16
  // (an unmounted pane frees its pad); with no owner, for the session.
17
17
 
18
- import { createEffect, createRoot, createSignal, getOwner, onCleanup } from "@solidjs/signals"
18
+ import { createEffect, createRoot, createSignal, getOwner, onCleanup, untrack } from "@solidjs/signals"
19
19
  import type { GamepadState } from "./gamepad"
20
- import type { InputSource } from "./input-map"
20
+ import type { ActionKind, InputDevice, InputSource } from "./input-map"
21
21
  import type { Vec2 } from "./input-axes"
22
22
 
23
23
  // Stick deflection below which a resting stick reads as zero (radial:
24
24
  // the whole vector, so a stick nudged diagonally also rests).
25
25
  const STICK_DEADZONE = 0.15
26
+ // Deflection (a stick's vector, a trigger or raw axis) that names the
27
+ // control a rebind listens for: past a rest twitch, short of full travel.
28
+ const LISTEN_THRESHOLD = 0.5
29
+
30
+ // Source ids (input-id.ts): `gamepad:leftStick`, `gamepad:rightStick`,
31
+ // `gamepad:dpad`, `gamepad:triggers`, `gamepad:shoulders`,
32
+ // `gamepad:axis:<name>`, `gamepad:button:<name>`. No slot in the id: a
33
+ // saved binding restores onto the device the app hands in.
34
+ const DEVICE = "gamepad"
26
35
 
27
36
  export type PadsAccessor = () => (GamepadState | null)[]
28
37
 
29
- export interface GamepadDevice {
38
+ export interface GamepadDevice extends InputDevice {
39
+ readonly name: "gamepad"
30
40
  /** The slot this device reads: a number for one pad, undefined for every
31
41
  * connected pad (`gamepad()`) or for a joining device no pad has claimed
32
42
  * yet (reactive: it resolves when one joins). */
@@ -43,10 +53,25 @@ export interface GamepadDevice {
43
53
  axis(name: string): InputSource<"axis">
44
54
  /** A button by SDL positional name ("south", "start", "leftShoulder", ...). */
45
55
  button(name: string): InputSource<"button">
56
+ /** The source of an id's spec ("button:south", "leftStick", "axis:leftX"). */
57
+ resolve(spec: string): InputSource
58
+ /** For a rebind: `found` gets the next control of `kind` the pad
59
+ * actuates past rest (a button pressed, a trigger or raw axis pulled,
60
+ * a stick pushed or the dpad, as `axis`/`button`/the stick sources);
61
+ * what is held when the listen starts must be released first. Returns
62
+ * the stop. */
63
+ listen(kind: ActionKind, found: (source: InputSource) => void): () => void
46
64
  }
47
65
 
48
66
  let deadzone = (x: number, y: number): Vec2 => (Math.hypot(x, y) < STICK_DEADZONE ? [0, 0] : [x, y])
49
67
 
68
+ // The sticks as (name, x axis, y axis) for the listener.
69
+ const STICKS = [
70
+ ["leftStick", "leftX", "leftY"],
71
+ ["rightStick", "rightX", "rightY"],
72
+ ] as const
73
+ const DPAD_BUTTONS = ["dpadUp", "dpadDown", "dpadLeft", "dpadRight"]
74
+
50
75
  /**
51
76
  * The sources over `pads()` (the pads this device reads now, reactive),
52
77
  * with `slot()` as the device's slot and `who` as the label prefix.
@@ -72,9 +97,15 @@ export function createGamepadDevice(pads: () => GamepadState[], slot: () => numb
72
97
  let stick = (side: "left" | "right"): InputSource<"vec2"> => ({
73
98
  kind: "vec2",
74
99
  label: `${who} ${side} stick`,
100
+ id: `${DEVICE}:${side}Stick`,
101
+ device: DEVICE,
75
102
  rate: sumVec2(pad => deadzone(pad.axes[`${side}X`] ?? 0, pad.axes[`${side}Y`] ?? 0)),
76
103
  })
77
- return {
104
+ // Same name, same source: unbind by identity works for a resolved id too.
105
+ let axes = new Map<string, InputSource<"axis">>()
106
+ let buttons = new Map<string, InputSource<"button">>()
107
+ let device: GamepadDevice = {
108
+ name: DEVICE,
78
109
  get slot() {
79
110
  return slot()
80
111
  },
@@ -83,27 +114,105 @@ export function createGamepadDevice(pads: () => GamepadState[], slot: () => numb
83
114
  dpad: {
84
115
  kind: "vec2",
85
116
  label: `${who} dpad`,
117
+ id: `${DEVICE}:dpad`,
118
+ device: DEVICE,
86
119
  rate: sumVec2(pad => [pressed(pad, "dpadRight") - pressed(pad, "dpadLeft"), pressed(pad, "dpadDown") - pressed(pad, "dpadUp")]),
87
120
  },
88
121
  triggers: {
89
122
  kind: "axis",
90
123
  label: `${who} triggers`,
124
+ id: `${DEVICE}:triggers`,
125
+ device: DEVICE,
91
126
  rate: sumAxis(pad => (pad.axes.rightTrigger ?? 0) - (pad.axes.leftTrigger ?? 0)),
92
127
  },
93
128
  shoulders: {
94
129
  kind: "axis",
95
130
  label: `${who} shoulders`,
131
+ id: `${DEVICE}:shoulders`,
132
+ device: DEVICE,
96
133
  rate: sumAxis(pad => pressed(pad, "rightShoulder") - pressed(pad, "leftShoulder")),
97
134
  },
98
135
  axis(name) {
99
136
  if (typeof name !== "string" || name.length === 0) throw new Error(`gamepad.axis: expected an axis name, got ${String(name)}`)
100
- return { kind: "axis", label: `${who} ${name}`, rate: sumAxis(pad => pad.axes[name] ?? 0) }
137
+ let source = axes.get(name)
138
+ if (!source) {
139
+ source = { kind: "axis", label: `${who} ${name}`, id: `${DEVICE}:axis:${name}`, device: DEVICE, rate: sumAxis(pad => pad.axes[name] ?? 0) }
140
+ axes.set(name, source)
141
+ }
142
+ return source
101
143
  },
102
144
  button(name) {
103
145
  if (typeof name !== "string" || name.length === 0) throw new Error(`gamepad.button: expected a button name, got ${String(name)}`)
104
- return { kind: "button", label: `${who} ${name}`, rate: anyButton(name) }
146
+ let source = buttons.get(name)
147
+ if (!source) {
148
+ source = { kind: "button", label: `${who} ${name}`, id: `${DEVICE}:button:${name}`, device: DEVICE, rate: anyButton(name) }
149
+ buttons.set(name, source)
150
+ }
151
+ return source
152
+ },
153
+ resolve(spec) {
154
+ let colon = spec.indexOf(":")
155
+ let head = colon < 0 ? spec : spec.slice(0, colon)
156
+ let rest = colon < 0 ? "" : spec.slice(colon + 1)
157
+ switch (head) {
158
+ case "leftStick":
159
+ case "rightStick":
160
+ case "dpad":
161
+ case "triggers":
162
+ case "shoulders":
163
+ if (rest) break
164
+ return device[head]
165
+ case "axis":
166
+ return device.axis(rest)
167
+ case "button":
168
+ return device.button(rest)
169
+ }
170
+ throw new Error(`gamepad.resolve: unknown source "${spec}" (leftStick, rightStick, dpad, triggers, shoulders, axis:<name>, button:<name>)`)
171
+ },
172
+ listen(kind, found) {
173
+ if (kind !== "button" && kind !== "axis" && kind !== "vec2") throw new Error(`gamepad.listen: expected a kind, got ${String(kind)}`)
174
+ if (typeof found !== "function") throw new Error("gamepad.listen: expects a function")
175
+ // What is actuated as the listen starts, released before it counts.
176
+ let held = new Set<string>()
177
+ let arm = (pads: GamepadState[]) => {
178
+ let now = new Set<string>()
179
+ for (let pad of pads) {
180
+ for (let b of pad.buttons) now.add(b)
181
+ for (let [name, v] of Object.entries(pad.axes)) if (Math.abs(v) >= LISTEN_THRESHOLD) now.add(name)
182
+ for (let [name, x, y] of STICKS) if (Math.hypot(pad.axes[x] ?? 0, pad.axes[y] ?? 0) >= LISTEN_THRESHOLD) now.add(name)
183
+ }
184
+ for (let name of held) if (!now.has(name)) held.delete(name)
185
+ return now
186
+ }
187
+ for (let name of arm(untrack(pads))) held.add(name)
188
+ let fresh = (now: Set<string>, name: string) => now.has(name) && !held.has(name)
189
+ return createRoot(dispose => {
190
+ createEffect(
191
+ () => pads(),
192
+ ps => {
193
+ let now = arm(ps)
194
+ let pick = (): InputSource | undefined => {
195
+ if (kind === "button") {
196
+ for (let pad of ps) for (let b of pad.buttons) if (fresh(now, b)) return device.button(b)
197
+ return
198
+ }
199
+ if (kind === "axis") {
200
+ for (let pad of ps) for (let name of Object.keys(pad.axes)) if (fresh(now, name)) return device.axis(name)
201
+ return
202
+ }
203
+ for (let [name] of STICKS) if (fresh(now, name)) return device[name]
204
+ for (let pad of ps) for (let b of pad.buttons) if (DPAD_BUTTONS.includes(b) && fresh(now, b)) return device.dpad
205
+ }
206
+ let source = pick()
207
+ if (source) found(source)
208
+ },
209
+ { defer: true },
210
+ )
211
+ return dispose
212
+ })
105
213
  },
106
214
  }
215
+ return device
107
216
  }
108
217
 
109
218
  /** One slot, or every connected pad when `slot` is undefined. */
@@ -0,0 +1,92 @@
1
+ // Source ids: the serializable half of a binding. A label is what a
2
+ // settings screen prints ("gamepad 0 south"); an id is what it saves and
3
+ // what a saved file restores from, so it names the source without the
4
+ // device instance: `gamepad:button:south` restores onto whichever pad
5
+ // device the app hands in, and player 2's file lands on player 2's pad.
6
+ //
7
+ // The grammar is one line: `<device>:<spec>` for a device's source, the
8
+ // spec being the device's own vocabulary (input-keyboard.ts,
9
+ // input-gamepad-device.ts, input-pointer.ts each document theirs), and
10
+ // `<processor>(<args>)` around it for a processed one, args separated
11
+ // by commas with ids nested as they are:
12
+ //
13
+ // keyboard:key:Shift+Tab keyboard:axis:Minus/Equal
14
+ // keyboard:vec2:KeyW/KeyS/KeyA/KeyD
15
+ // gamepad:leftStick gamepad:button:south gamepad:axis:leftTrigger
16
+ // pointer:drag pointer:drag:Ctrl+Right pointer:wheel
17
+ // invert(gamepad:leftStick) scale(0.5,pointer:wheel) hold(400,keyboard:key:Space)
18
+ // chord(keyboard:key:Shift,keyboard:key:KeyA)
19
+ //
20
+ // A custom source names itself under a prefix of its own; load() resolves
21
+ // only the devices it is given and throws on anything else, so a file
22
+ // from another version of the app fails loudly, not silently half-bound.
23
+
24
+ import type { InputDeviceSet, InputSource } from "./input-map"
25
+ import { chord, doubleTap, hold, invert, scale, tap } from "./input-processors"
26
+
27
+ // Split processor arguments at top-level commas (nested ids keep theirs).
28
+ let splitArgs = (text: string): string[] => {
29
+ let out: string[] = []
30
+ let depth = 0
31
+ let start = 0
32
+ for (let i = 0; i < text.length; i++) {
33
+ let c = text[i]
34
+ if (c === "(") depth++
35
+ else if (c === ")") depth--
36
+ else if (c === "," && depth === 0) {
37
+ out.push(text.slice(start, i))
38
+ start = i + 1
39
+ }
40
+ }
41
+ out.push(text.slice(start))
42
+ return out
43
+ }
44
+
45
+ let number = (id: string, text: string): number => {
46
+ let n = Number(text)
47
+ if (text.trim() === "" || !Number.isFinite(n)) throw new Error(`resolveSource: "${id}" has a non-numeric argument "${text}"`)
48
+ return n
49
+ }
50
+
51
+ /** The source an id names, built from `devices`; throws when the id names
52
+ * a device not given, an unknown processor, or a spec the device rejects. */
53
+ export function resolveSource(id: string, devices: InputDeviceSet): InputSource {
54
+ if (typeof id !== "string" || id.length === 0) throw new Error(`resolveSource: expected an id, got ${String(id)}`)
55
+ let processed = /^([A-Za-z]+)\((.*)\)$/.exec(id)
56
+ if (processed) {
57
+ let [, name, inner] = processed
58
+ let args = splitArgs(inner!)
59
+ let axis = (i: number) => resolveSource(args[i]!, devices) as InputSource<"axis" | "vec2">
60
+ let button = (i: number) => resolveSource(args[i]!, devices) as InputSource<"button">
61
+ let arity = (n: number) => {
62
+ if (args.length !== n) throw new Error(`resolveSource: ${name}() takes ${n} argument(s), "${id}" gives ${args.length}`)
63
+ }
64
+ switch (name) {
65
+ case "invert":
66
+ arity(1)
67
+ return invert(axis(0))
68
+ case "scale":
69
+ arity(2)
70
+ return scale(axis(1), number(id, args[0]!))
71
+ case "hold":
72
+ arity(2)
73
+ return hold(button(1), number(id, args[0]!))
74
+ case "tap":
75
+ arity(2)
76
+ return tap(button(1), number(id, args[0]!))
77
+ case "doubleTap":
78
+ arity(3)
79
+ return doubleTap(button(2), number(id, args[0]!), number(id, args[1]!))
80
+ case "chord":
81
+ return chord(...args.map((_, i) => button(i)))
82
+ default:
83
+ throw new Error(`resolveSource: unknown processor "${name}" in "${id}"`)
84
+ }
85
+ }
86
+ let colon = id.indexOf(":")
87
+ if (colon <= 0) throw new Error(`resolveSource: "${id}" names no device (expected "<device>:<spec>")`)
88
+ let name = id.slice(0, colon) as keyof InputDeviceSet
89
+ let device = devices[name]
90
+ if (!device) throw new Error(`resolveSource: no "${name}" device given for "${id}" (given: ${Object.keys(devices).join(", ") || "none"})`)
91
+ return device.resolve(id.slice(colon + 1))
92
+ }
@@ -28,9 +28,14 @@ import { createSignal } from "@solidjs/signals"
28
28
  import type { KeyEvent } from "./types"
29
29
  import type { InputSource } from "./input-map"
30
30
  import type { Vec2 } from "./input-axes"
31
- import { mostSpecific, parseModifiers } from "./input-chord"
31
+ import { chordName, eventModifiers, mostSpecific, parseModifiers } from "./input-chord"
32
32
  import type { Modifier } from "./input-chord"
33
33
 
34
+ // Source ids (input-id.ts): `keyboard:key:<spec>`, `keyboard:axis:<neg>/<pos>`,
35
+ // `keyboard:vec2:<up>/<down>/<left>/<right>`; specs as given, so a saved
36
+ // id restores the very spec the app or the player wrote.
37
+ const DEVICE = "keyboard"
38
+
34
39
  // A parsed spec: the text as given (the held-state key and the label),
35
40
  // the key it names, and the modifiers the down must carry.
36
41
  type Spec = { text: string; key: string; mods: Modifier[] }
@@ -87,6 +92,8 @@ function key(spec: string): InputSource<"button"> {
87
92
  return {
88
93
  kind: "button",
89
94
  label: `keyboard ${spec}`,
95
+ id: `${DEVICE}:key:${spec}`,
96
+ device: DEVICE,
90
97
  rate: () => state.count() > 0,
91
98
  key: state.key,
92
99
  blur: state.blur,
@@ -99,6 +106,8 @@ function axis(neg: string, pos: string): InputSource<"axis"> {
99
106
  return {
100
107
  kind: "axis",
101
108
  label: `keyboard ${neg}/${pos}`,
109
+ id: `${DEVICE}:axis:${neg}/${pos}`,
110
+ device: DEVICE,
102
111
  rate: () => {
103
112
  state.count()
104
113
  return (state.has(pos) ? 1 : 0) - (state.has(neg) ? 1 : 0)
@@ -117,6 +126,8 @@ function vec2(keys: KeyboardVec2Keys): InputSource<"vec2"> {
117
126
  return {
118
127
  kind: "vec2",
119
128
  label: `keyboard ${keys.up}/${keys.left}/${keys.down}/${keys.right}`,
129
+ id: `${DEVICE}:vec2:${keys.up}/${keys.down}/${keys.left}/${keys.right}`,
130
+ device: DEVICE,
120
131
  rate: (): Vec2 => {
121
132
  state.count()
122
133
  return [(state.has(keys.right) ? 1 : 0) - (state.has(keys.left) ? 1 : 0), (state.has(keys.down) ? 1 : 0) - (state.has(keys.up) ? 1 : 0)]
@@ -126,18 +137,54 @@ function vec2(keys: KeyboardVec2Keys): InputSource<"vec2"> {
126
137
  }
127
138
  }
128
139
 
140
+ /** A key event as a spec a rebind binds: the modifiers it carries ahead
141
+ * of its code (or its key, for a synthetic event without one); null for a
142
+ * bare modifier key, which names nothing on its own. */
143
+ export function keySpec(event: KeyEvent): string | null {
144
+ if (MODIFIER_NAMES.has(event.key)) return null
145
+ let key = event.code || event.key
146
+ if (!key) return null
147
+ let mods = chordName(eventModifiers(event))
148
+ return mods ? `${mods}+${key}` : key
149
+ }
150
+
151
+ // The logical key names of the modifiers, as key events report them.
152
+ const MODIFIER_NAMES = new Set(["Shift", "Control", "Alt", "Meta"])
153
+
154
+ // A device spec (the id past "keyboard:") back to a source.
155
+ function resolve(spec: string): InputSource {
156
+ let colon = spec.indexOf(":")
157
+ let kind = colon < 0 ? spec : spec.slice(0, colon)
158
+ let rest = colon < 0 ? "" : spec.slice(colon + 1)
159
+ let parts = rest.split("/")
160
+ switch (kind) {
161
+ case "key":
162
+ return key(rest)
163
+ case "axis":
164
+ if (parts.length !== 2) throw new Error(`keyboard.resolve: "${spec}" needs two keys, neg/pos`)
165
+ return axis(parts[0]!, parts[1]!)
166
+ case "vec2":
167
+ if (parts.length !== 4) throw new Error(`keyboard.resolve: "${spec}" needs four keys, up/down/left/right`)
168
+ return vec2({ up: parts[0]!, down: parts[1]!, left: parts[2]!, right: parts[3]! })
169
+ default:
170
+ throw new Error(`keyboard.resolve: unknown source "${spec}" (key:<spec>, axis:<neg>/<pos>, vec2:<up>/<down>/<left>/<right>)`)
171
+ }
172
+ }
173
+
129
174
  /**
130
175
  * The keyboard device: `keyboard.key("Space")`, `keyboard.axis("KeyQ",
131
176
  * "KeyE")`, `keyboard.vec2({ up, down, left, right })`, and the two
132
177
  * composites every game binds, `keyboard.wasd` and `keyboard.arrows`.
133
178
  * A spec may carry modifiers ("Shift+Tab", "Ctrl+KeyS"). Each call makes
134
179
  * an independent source with its own held state; the composites are
135
- * shared singletons.
180
+ * shared singletons. `resolve` rebuilds a source from its id's spec.
136
181
  */
137
182
  export let keyboard = {
183
+ name: DEVICE as "keyboard",
138
184
  key,
139
185
  axis,
140
186
  vec2,
187
+ resolve,
141
188
  wasd: vec2({ up: "KeyW", down: "KeyS", left: "KeyA", right: "KeyD" }),
142
189
  arrows: vec2({ up: "ArrowUp", down: "ArrowDown", left: "ArrowLeft", right: "ArrowRight" }),
143
190
  }