@solidrt/core 0.0.60 → 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.
- package/AGENTS.md +100 -16
- package/agents/performance.md +5 -1
- package/examples/README.md +2 -1
- package/examples/gestures.tsx +197 -0
- package/examples/layout-slide.tsx +44 -15
- package/package.json +6 -5
- package/src/arena.ts +55 -0
- package/src/camera-control.ts +428 -0
- package/src/cursor.ts +113 -0
- package/src/double-tap.ts +150 -0
- package/src/gesture.ts +32 -0
- package/src/index.ts +16 -2
- package/src/input-axes.ts +12 -7
- package/src/input-chord.ts +3 -0
- package/src/input-gamepad-device.ts +115 -6
- package/src/input-id.ts +92 -0
- package/src/input-keyboard.ts +49 -2
- package/src/input-map.ts +268 -68
- package/src/input-pointer.ts +240 -16
- package/src/input-processors.ts +214 -0
- package/src/input.ts +4 -2
- package/src/long-press.ts +137 -0
- package/src/pan.ts +23 -4
- package/src/renderer.ts +16 -6
- package/src/runtime-modules.d.ts +4 -0
- package/src/svg.ts +1 -0
- package/src/swipe.ts +130 -0
- package/src/text-input.ts +104 -46
- package/src/transform.ts +23 -3
- package/src/types.d.ts +44 -7
- package/src/velocity.ts +134 -0
- package/src/video.ts +7 -6
- package/src/window.ts +16 -9
|
@@ -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 {
|
|
32
|
-
export type {
|
|
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:
|
|
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
|
|
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)
|
package/src/input-chord.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|
package/src/input-id.ts
ADDED
|
@@ -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
|
+
}
|
package/src/input-keyboard.ts
CHANGED
|
@@ -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
|
}
|