@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.
- 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/audio.ts +4 -3
- 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 +82 -15
- package/src/window.ts +16 -9
package/src/input-pointer.ts
CHANGED
|
@@ -57,6 +57,24 @@
|
|
|
57
57
|
// joins nothing, and only the held button's release closes the gesture.
|
|
58
58
|
// The wheel and mouseDelta have no button; a button word on them throws.
|
|
59
59
|
//
|
|
60
|
+
// Discrete gestures are button sources that pulse (pressed for one task,
|
|
61
|
+
// the shape of the `tap()` interaction): `swipe("Left")` at the lift of
|
|
62
|
+
// a one-finger drag that qualifies (swipe.ts: distance, speed, within 30
|
|
63
|
+
// degrees of an axis; the direction is the spec's trailing word, as a
|
|
64
|
+
// drag's button is, and a swipe never has a button word), `longPress`
|
|
65
|
+
// at the hold timer (long-press.ts) and `doubleTap` on the second tap's
|
|
66
|
+
// down (double-tap.ts). They ride the feed's own events - the feed is
|
|
67
|
+
// ONE arena claimant, its transform, so they never steal - and take the
|
|
68
|
+
// chord of the down that opened them. Bound to an AXIS action they
|
|
69
|
+
// nudge 1 with the gesture's focal point (the tap's or hold's position,
|
|
70
|
+
// a fraction of the element) and contribute no rate (input-map.ts skips
|
|
71
|
+
// a pulse's press when it combines an axis's rates), which is how a
|
|
72
|
+
// control learns WHERE a double tap landed (the orbit camera's `focus`,
|
|
73
|
+
// the 2d camera's octave); a swipe has no focal.
|
|
74
|
+
// The drag's end bracket carries the release velocity the transform
|
|
75
|
+
// measured, in element heights per second, so a camera flings from the
|
|
76
|
+
// gesture's speed.
|
|
77
|
+
//
|
|
60
78
|
// The element's laid-out box normalizes the travel: read at the press
|
|
61
79
|
// through getLayoutBox (the untransformed read, so a designSize fit or
|
|
62
80
|
// an ancestor transform never skews it), or from `layout` when given -
|
|
@@ -67,14 +85,26 @@
|
|
|
67
85
|
// Create a feed in an owned scope (a component body): the recognizer
|
|
68
86
|
// registers its cleanup with the owner.
|
|
69
87
|
|
|
88
|
+
import { createSignal } from "@solidjs/signals"
|
|
70
89
|
import { createTransform } from "./transform"
|
|
71
90
|
import { getLayoutBox } from "./core"
|
|
72
91
|
import { pointerLocked } from "./window"
|
|
73
92
|
import type { PointerEvent, WheelEvent } from "./types"
|
|
74
|
-
import type { DeltaSink, InputSource } from "./input-map"
|
|
93
|
+
import type { ActionKind, DeltaSink, InputDevice, InputSource } from "./input-map"
|
|
75
94
|
import type { Vec2 } from "./input-axes"
|
|
76
|
-
import { chordName, mostSpecific, parseModifiers } from "./input-chord"
|
|
95
|
+
import { chordName, eventModifiers, mostSpecific, parseModifiers } from "./input-chord"
|
|
77
96
|
import type { Modified, Modifier } from "./input-chord"
|
|
97
|
+
import { createVelocityTracker } from "./velocity"
|
|
98
|
+
import type { Velocity } from "./velocity"
|
|
99
|
+
import { classifySwipe, SWIPE_DIRECTIONS } from "./swipe"
|
|
100
|
+
import type { SwipeDirection } from "./swipe"
|
|
101
|
+
import { createHoldTimer } from "./long-press"
|
|
102
|
+
import { createTapSequence } from "./double-tap"
|
|
103
|
+
|
|
104
|
+
// Source ids (input-id.ts): `pointer:<gesture>` for the bare source and
|
|
105
|
+
// `pointer:<gesture>:<spec>` for a chord or button variant, the spec in
|
|
106
|
+
// its canonical spelling ("Shift+Ctrl+Right").
|
|
107
|
+
const DEVICE = "pointer"
|
|
78
108
|
|
|
79
109
|
// Octaves per wheel-delta unit: the zoom exponent both cameras used
|
|
80
110
|
// (0.0015 per unit) taken in log2, so a 100-unit notch zooms about a
|
|
@@ -94,9 +124,10 @@ const MOUSE_PX_PER_UNIT = 1500
|
|
|
94
124
|
* then instead of the bare one: the most specific bound chord wins. The
|
|
95
125
|
* same spec returns the same source, so unbind by identity works.
|
|
96
126
|
*/
|
|
97
|
-
export type PointerSource<K extends
|
|
127
|
+
export type PointerSource<K extends ActionKind> = InputSource<K> & ((chord: string) => InputSource<K>)
|
|
98
128
|
|
|
99
|
-
export interface PointerFeed {
|
|
129
|
+
export interface PointerFeed extends InputDevice {
|
|
130
|
+
readonly name: "pointer"
|
|
100
131
|
/** Spread on the element whose pointer events feed the sources. */
|
|
101
132
|
handlers: {
|
|
102
133
|
onPointerDown(event: PointerEvent): void
|
|
@@ -120,6 +151,23 @@ export interface PointerFeed {
|
|
|
120
151
|
/** Raw mouse motion while the pointer is locked, in view-height
|
|
121
152
|
* equivalents (see MOUSE_PX_PER_UNIT); unbracketed. */
|
|
122
153
|
mouseDelta: PointerSource<"vec2">
|
|
154
|
+
/** A one-finger swipe in the spec's direction ("Left", "Ctrl+Right"):
|
|
155
|
+
* a button pulsing once at the lift. */
|
|
156
|
+
swipe(spec: string): InputSource<"button">
|
|
157
|
+
/** A pointer held still for the long-press hold: a button pulsing once
|
|
158
|
+
* at the timer. */
|
|
159
|
+
longPress: PointerSource<"button">
|
|
160
|
+
/** Two quick taps: a button pulsing once on the second down. */
|
|
161
|
+
doubleTap: PointerSource<"button">
|
|
162
|
+
/** The source of an id's spec ("drag", "drag:Ctrl+Right", "wheel",
|
|
163
|
+
* "swipe:Left", "longPress:Ctrl"). */
|
|
164
|
+
resolve(spec: string): InputSource
|
|
165
|
+
/** For a rebind: `found` gets the next gesture of `kind` the pointer
|
|
166
|
+
* opens on the element (a drag, pan or locked mouse motion for a vec2;
|
|
167
|
+
* a pinch, twist or wheel notch for an axis; a swipe, long-press or
|
|
168
|
+
* double-tap for a button), as the variant of the chord and button or
|
|
169
|
+
* direction that opened it. Returns the stop. */
|
|
170
|
+
listen(kind: ActionKind, found: (source: InputSource) => void): () => void
|
|
123
171
|
}
|
|
124
172
|
|
|
125
173
|
export interface PointerFeedOptions {
|
|
@@ -142,9 +190,11 @@ const PRIMARY = 0
|
|
|
142
190
|
// bound mid-gesture joins it.
|
|
143
191
|
type Bucket = { mods: Modifier[]; button: number; sinks: Set<DeltaSink>; source: InputSource }
|
|
144
192
|
|
|
145
|
-
function gesture<K extends "axis" | "vec2">(kind: K, label: string, buttons: boolean) {
|
|
193
|
+
function gesture<K extends "axis" | "vec2">(kind: K, id: string, label: string, buttons: boolean) {
|
|
146
194
|
let buckets = new Map<string, Bucket>()
|
|
147
195
|
let open: Set<DeltaSink>[] = []
|
|
196
|
+
// Rebind listeners: told the variant each bracket or send opens.
|
|
197
|
+
let probes = new Set<(source: InputSource) => void>()
|
|
148
198
|
let variant = (mods: Modifier[], button: number): InputSource<K> => {
|
|
149
199
|
let name = [chordName(mods), button === PRIMARY ? "" : Object.keys(BUTTONS)[button]!].filter(Boolean).join("+")
|
|
150
200
|
let bucket = buckets.get(name)
|
|
@@ -153,6 +203,8 @@ function gesture<K extends "axis" | "vec2">(kind: K, label: string, buttons: boo
|
|
|
153
203
|
let source: InputSource<K> = {
|
|
154
204
|
kind,
|
|
155
205
|
label: name ? `${label} (${name})` : label,
|
|
206
|
+
id: name ? `${DEVICE}:${id}:${name}` : `${DEVICE}:${id}`,
|
|
207
|
+
device: DEVICE,
|
|
156
208
|
deltas(sink) {
|
|
157
209
|
sinks.add(sink)
|
|
158
210
|
return () => {
|
|
@@ -176,39 +228,134 @@ function gesture<K extends "axis" | "vec2">(kind: K, label: string, buttons: boo
|
|
|
176
228
|
}
|
|
177
229
|
return variant(parseModifiers(label, text, parts), button)
|
|
178
230
|
}
|
|
179
|
-
let source: PointerSource<K> = Object.assign(spec, { kind, label: bare.label, deltas: bare.deltas! })
|
|
231
|
+
let source: PointerSource<K> = Object.assign(spec, { kind, label: bare.label, id: bare.id, device: bare.device, deltas: bare.deltas! })
|
|
180
232
|
let resolve = (event: Modified, button: number): Set<DeltaSink>[] =>
|
|
181
233
|
mostSpecific(
|
|
182
234
|
[...buckets.values()].filter(b => b.sinks.size > 0 && b.button === button),
|
|
183
235
|
b => b.mods,
|
|
184
236
|
event,
|
|
185
237
|
).map(b => b.sinks)
|
|
238
|
+
let probe = (event: Modified, button: number) => {
|
|
239
|
+
if (probes.size === 0) return
|
|
240
|
+
let found = variant(eventModifiers(event), button)
|
|
241
|
+
probes.forEach(p => p(found))
|
|
242
|
+
}
|
|
186
243
|
return {
|
|
187
244
|
source,
|
|
245
|
+
spec,
|
|
246
|
+
listen(found: (source: InputSource) => void) {
|
|
247
|
+
probes.add(found)
|
|
248
|
+
return () => {
|
|
249
|
+
probes.delete(found)
|
|
250
|
+
}
|
|
251
|
+
},
|
|
188
252
|
begin(event: Modified, button: number) {
|
|
253
|
+
probe(event, button)
|
|
189
254
|
open = resolve(event, button)
|
|
190
255
|
for (let sinks of open) sinks.forEach(k => k.begin())
|
|
191
256
|
},
|
|
192
257
|
delta(value: number | Vec2, focal?: Vec2) {
|
|
193
258
|
for (let sinks of open) sinks.forEach(k => k.delta(value, focal))
|
|
194
259
|
},
|
|
195
|
-
end() {
|
|
196
|
-
for (let sinks of open) sinks.forEach(k => k.end())
|
|
260
|
+
end(velocity?: number | Vec2) {
|
|
261
|
+
for (let sinks of open) sinks.forEach(k => k.end(velocity))
|
|
197
262
|
open = []
|
|
198
263
|
},
|
|
199
264
|
send(event: Modified, value: number | Vec2, focal?: Vec2) {
|
|
265
|
+
probe(event, PRIMARY)
|
|
200
266
|
for (let sinks of resolve(event, PRIMARY)) sinks.forEach(k => k.delta(value, focal))
|
|
201
267
|
},
|
|
202
268
|
}
|
|
203
269
|
}
|
|
204
270
|
|
|
271
|
+
// A discrete gesture's sources, bucketed by spec like a delta gesture's:
|
|
272
|
+
// the chord plus, when the gesture takes one, a trailing word from
|
|
273
|
+
// `words` (a swipe's direction; then the bare spec has no meaning and
|
|
274
|
+
// `spec` demands the word). A fire resolves the buckets of the word whose
|
|
275
|
+
// chord the event carries, narrowed to the most specific, and pulses each:
|
|
276
|
+
// pressed now, released on the next task, so a map's onPress sees the
|
|
277
|
+
// edge (input-processors.ts `tap`). A created variant counts as bound.
|
|
278
|
+
type PulseBucket = { mods: Modifier[]; word: string | null; source: InputSource<"button">; set: (pressed: boolean) => void; sinks: Set<DeltaSink> }
|
|
279
|
+
|
|
280
|
+
function pulses(id: string, label: string, words: readonly string[] | null) {
|
|
281
|
+
let buckets = new Map<string, PulseBucket>()
|
|
282
|
+
let probes = new Set<(source: InputSource) => void>()
|
|
283
|
+
let variant = (mods: Modifier[], word: string | null): InputSource<"button"> => {
|
|
284
|
+
let name = [chordName(mods), word ?? ""].filter(Boolean).join("+")
|
|
285
|
+
let bucket = buckets.get(name)
|
|
286
|
+
if (!bucket) {
|
|
287
|
+
// ownedWrite: a fire lands inside a pointer handler or a timer.
|
|
288
|
+
let [pressed, set] = createSignal(false, { ownedWrite: true })
|
|
289
|
+
let sinks = new Set<DeltaSink>()
|
|
290
|
+
let source: InputSource<"button"> = {
|
|
291
|
+
kind: "button",
|
|
292
|
+
label: name ? `${label} (${name})` : label,
|
|
293
|
+
id: name ? `${DEVICE}:${id}:${name}` : `${DEVICE}:${id}`,
|
|
294
|
+
device: DEVICE,
|
|
295
|
+
rate: pressed,
|
|
296
|
+
deltas(sink) {
|
|
297
|
+
sinks.add(sink)
|
|
298
|
+
return () => {
|
|
299
|
+
sinks.delete(sink)
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
bucket = { mods, word, source, set, sinks }
|
|
304
|
+
buckets.set(name, bucket)
|
|
305
|
+
}
|
|
306
|
+
return bucket.source
|
|
307
|
+
}
|
|
308
|
+
let spec = (text: string): InputSource<"button"> => {
|
|
309
|
+
if (typeof text !== "string" || text.length === 0) throw new Error(`${label}: expected a spec such as "Ctrl"${words ? `, "${words[0]}" or "Shift+${words[0]}"` : ' or "Shift+Alt"'}, got ${String(text)}`)
|
|
310
|
+
let parts = text.split("+")
|
|
311
|
+
let word: string | null = null
|
|
312
|
+
if (words && words.includes(parts[parts.length - 1]!)) word = parts.pop()!
|
|
313
|
+
if (words && word === null) throw new Error(`${label}: "${text}" must end in a direction (${words.join(", ")})`)
|
|
314
|
+
return variant(parseModifiers(label, text, parts), word)
|
|
315
|
+
}
|
|
316
|
+
let bare = words ? null : variant([], null)
|
|
317
|
+
return {
|
|
318
|
+
/** The bare source (null for a gesture that needs its word). */
|
|
319
|
+
bare,
|
|
320
|
+
spec,
|
|
321
|
+
listen(found: (source: InputSource) => void) {
|
|
322
|
+
probes.add(found)
|
|
323
|
+
return () => {
|
|
324
|
+
probes.delete(found)
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
fire(event: Modified, word: string | null, focal?: Vec2) {
|
|
328
|
+
if (probes.size > 0) {
|
|
329
|
+
let found = variant(eventModifiers(event), word)
|
|
330
|
+
probes.forEach(p => p(found))
|
|
331
|
+
}
|
|
332
|
+
let hit = mostSpecific(
|
|
333
|
+
[...buckets.values()].filter(b => b.word === word),
|
|
334
|
+
b => b.mods,
|
|
335
|
+
event,
|
|
336
|
+
)
|
|
337
|
+
for (let b of hit) {
|
|
338
|
+
b.set(true)
|
|
339
|
+
setTimeout(() => b.set(false), 0)
|
|
340
|
+
b.sinks.forEach(k => k.delta(1, focal))
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
205
346
|
export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed {
|
|
206
|
-
let drag = gesture("vec2", "pointer drag", true)
|
|
207
|
-
let pan = gesture("vec2", "pointer two-finger pan", true)
|
|
208
|
-
let pinch = gesture("axis", "pointer pinch", true)
|
|
209
|
-
let twist = gesture("axis", "pointer twist", true)
|
|
210
|
-
let wheel = gesture("axis", "pointer wheel", false)
|
|
211
|
-
let mouseDelta = gesture("vec2", "mouse motion (pointer locked)", false)
|
|
347
|
+
let drag = gesture("vec2", "drag", "pointer drag", true)
|
|
348
|
+
let pan = gesture("vec2", "pan", "pointer two-finger pan", true)
|
|
349
|
+
let pinch = gesture("axis", "pinch", "pointer pinch", true)
|
|
350
|
+
let twist = gesture("axis", "twist", "pointer twist", true)
|
|
351
|
+
let wheel = gesture("axis", "wheel", "pointer wheel", false)
|
|
352
|
+
let mouseDelta = gesture("vec2", "mouseDelta", "mouse motion (pointer locked)", false)
|
|
353
|
+
let gestures = { drag, pan, pinch, twist, wheel, mouseDelta }
|
|
354
|
+
let swipe = pulses("swipe", "pointer swipe", SWIPE_DIRECTIONS)
|
|
355
|
+
let longPress = pulses("longPress", "pointer long press", null)
|
|
356
|
+
let doubleTap = pulses("doubleTap", "pointer double tap", null)
|
|
357
|
+
let discrete = { swipe, longPress, doubleTap }
|
|
358
|
+
let callable = (p: typeof longPress): PointerSource<"button"> => Object.assign(p.spec, { kind: "button" as const, label: p.bare!.label, id: p.bare!.id, device: p.bare!.device, rate: p.bare!.rate!, deltas: p.bare!.deltas! })
|
|
212
359
|
|
|
213
360
|
// The element's box, read at each press (see the header). A detached
|
|
214
361
|
// leaf has no layout box and must bring `layout`: normalizing by
|
|
@@ -226,8 +373,14 @@ export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed
|
|
|
226
373
|
let unit = () => (box.height > 0 ? box.height : 1)
|
|
227
374
|
let focalOf = (x: number, y: number): Vec2 | undefined => (box.width > 0 && box.height > 0 ? [x / box.width, y / box.height] : undefined)
|
|
228
375
|
|
|
376
|
+
// The release velocity of the gesture that just ended, for the drag's
|
|
377
|
+
// end bracket (the transform's up runs before `lifted`).
|
|
378
|
+
let released: Velocity | null = null
|
|
229
379
|
let transform = createTransform({
|
|
230
380
|
buttons: "any",
|
|
381
|
+
onTransformEnd: v => {
|
|
382
|
+
released = v.vx !== 0 || v.vy !== 0 ? v : null
|
|
383
|
+
},
|
|
231
384
|
onTransformMove: t => {
|
|
232
385
|
let h = unit()
|
|
233
386
|
let travel: Vec2 = [t.dx / h, t.dy / h]
|
|
@@ -267,27 +420,66 @@ export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed
|
|
|
267
420
|
twist.end()
|
|
268
421
|
drag.begin(e, [...downs.values()][0]!)
|
|
269
422
|
}
|
|
270
|
-
if (downs.size === 0)
|
|
423
|
+
if (downs.size === 0) {
|
|
424
|
+
let h = unit()
|
|
425
|
+
drag.end(released ? [released.vx / h, released.vy / h] : undefined)
|
|
426
|
+
released = null
|
|
427
|
+
}
|
|
271
428
|
}
|
|
272
429
|
|
|
430
|
+
// The discrete gestures over the primary button's first pointer: the
|
|
431
|
+
// finger's window-px velocity and the opening down for the swipe (a
|
|
432
|
+
// second finger disqualifies it), the hold timer, the tap sequence.
|
|
433
|
+
let finger = createVelocityTracker()
|
|
434
|
+
let opening: { id: number; x: number; y: number; mods: Modified; alone: boolean } | null = null
|
|
435
|
+
// The primary pointer's last down, as a focal: where a double tap or
|
|
436
|
+
// a long press landed (both fire from that down's own sequence).
|
|
437
|
+
let downFocal: Vec2 | undefined
|
|
438
|
+
let hold = createHoldTimer({ onFire: (_id, _at, mods) => longPress.fire(mods, null, downFocal) })
|
|
439
|
+
let taps = createTapSequence({ onDouble: (_id, _at, mods) => doubleTap.fire(mods, null, downFocal) })
|
|
440
|
+
let mods = (e: PointerEvent): Modified => ({ shiftKey: e.shiftKey, ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey })
|
|
441
|
+
|
|
273
442
|
return {
|
|
274
443
|
handlers: {
|
|
275
444
|
onPointerDown(e) {
|
|
276
445
|
if (downs.has(e.pointerId)) return
|
|
277
446
|
if (downs.size === 0) box = size(e)
|
|
278
|
-
|
|
447
|
+
let button = e.button ?? PRIMARY
|
|
448
|
+
landed(e, button)
|
|
279
449
|
transform.handlers.onPointerDown(e)
|
|
450
|
+
if (button === PRIMARY) {
|
|
451
|
+
downFocal = focalOf(e.localX, e.localY)
|
|
452
|
+
hold.down(e)
|
|
453
|
+
taps.down(e)
|
|
454
|
+
if (downs.size === 1) {
|
|
455
|
+
opening = { id: e.pointerId, x: e.clientX, y: e.clientY, mods: mods(e), alone: true }
|
|
456
|
+
finger.reset()
|
|
457
|
+
finger.push(e.clientX, e.clientY)
|
|
458
|
+
} else if (opening) opening.alone = false
|
|
459
|
+
}
|
|
280
460
|
},
|
|
281
461
|
onPointerMove(e) {
|
|
282
462
|
if (pointerLocked() && e.pointerType === "mouse" && (e.movementX !== 0 || e.movementY !== 0)) {
|
|
283
463
|
mouseDelta.send(e, [e.movementX / MOUSE_PX_PER_UNIT, e.movementY / MOUSE_PX_PER_UNIT])
|
|
284
464
|
}
|
|
285
465
|
transform.handlers.onPointerMove(e)
|
|
466
|
+
hold.move(e)
|
|
467
|
+
taps.move(e)
|
|
468
|
+
if (opening && opening.id === e.pointerId) finger.push(e.clientX, e.clientY)
|
|
286
469
|
},
|
|
287
470
|
onPointerUp(e) {
|
|
288
471
|
if (downs.get(e.pointerId) !== (e.button ?? PRIMARY)) return
|
|
289
472
|
transform.handlers.onPointerUp(e)
|
|
290
473
|
lifted(e)
|
|
474
|
+
hold.up(e)
|
|
475
|
+
taps.up(e)
|
|
476
|
+
if (opening && opening.id === e.pointerId) {
|
|
477
|
+
if (opening.alone && downs.size === 0) {
|
|
478
|
+
let direction: SwipeDirection | null = classifySwipe(finger.velocity(), { dx: e.clientX - opening.x, dy: e.clientY - opening.y })
|
|
479
|
+
if (direction) swipe.fire(opening.mods, direction)
|
|
480
|
+
}
|
|
481
|
+
opening = null
|
|
482
|
+
}
|
|
291
483
|
},
|
|
292
484
|
onPointerLeave() {},
|
|
293
485
|
onWheel(e) {
|
|
@@ -296,11 +488,43 @@ export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed
|
|
|
296
488
|
wheel.send(e, -e.deltaY * WHEEL_OCTAVES, focalOf(e.localX, e.localY))
|
|
297
489
|
},
|
|
298
490
|
},
|
|
491
|
+
name: DEVICE,
|
|
299
492
|
drag: drag.source,
|
|
300
493
|
pan: pan.source,
|
|
301
494
|
pinch: pinch.source,
|
|
302
495
|
twist: twist.source,
|
|
303
496
|
wheel: wheel.source,
|
|
304
497
|
mouseDelta: mouseDelta.source,
|
|
498
|
+
swipe: swipe.spec,
|
|
499
|
+
longPress: callable(longPress),
|
|
500
|
+
doubleTap: callable(doubleTap),
|
|
501
|
+
resolve(spec) {
|
|
502
|
+
let colon = spec.indexOf(":")
|
|
503
|
+
let name = colon < 0 ? spec : spec.slice(0, colon)
|
|
504
|
+
let rest = colon < 0 ? null : spec.slice(colon + 1)
|
|
505
|
+
let g = gestures[name as keyof typeof gestures]
|
|
506
|
+
if (g) return rest === null ? g.source : g.spec(rest)
|
|
507
|
+
let p = discrete[name as keyof typeof discrete]
|
|
508
|
+
if (p) {
|
|
509
|
+
if (rest === null) {
|
|
510
|
+
if (!p.bare) throw new Error(`pointer.resolve: "${spec}" needs a direction (swipe:Left)`)
|
|
511
|
+
return p.bare
|
|
512
|
+
}
|
|
513
|
+
return p.spec(rest)
|
|
514
|
+
}
|
|
515
|
+
throw new Error(`pointer.resolve: unknown gesture "${spec}" (${[...Object.keys(gestures), ...Object.keys(discrete)].join(", ")}, each with an optional :<chord>; swipe with its direction)`)
|
|
516
|
+
},
|
|
517
|
+
listen(kind, found) {
|
|
518
|
+
if (kind !== "button" && kind !== "axis" && kind !== "vec2") throw new Error(`pointer.listen: expected a kind, got ${String(kind)}`)
|
|
519
|
+
if (typeof found !== "function") throw new Error("pointer.listen: expects a function")
|
|
520
|
+
let stops = kind === "button"
|
|
521
|
+
? Object.values(discrete).map(p => p.listen(found))
|
|
522
|
+
: Object.values(gestures)
|
|
523
|
+
.filter(g => g.source.kind === kind)
|
|
524
|
+
.map(g => g.listen(found))
|
|
525
|
+
return () => {
|
|
526
|
+
for (let stop of stops) stop()
|
|
527
|
+
}
|
|
528
|
+
},
|
|
305
529
|
}
|
|
306
530
|
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Processors and interactions: sources made from sources. A processor
|
|
2
|
+
// changes a value (Unity's Invert and Scale), an interaction recognizes a
|
|
3
|
+
// pattern on a button over time (Unity's Hold, Tap and MultiTap
|
|
4
|
+
// interactions, Unreal's Triggers) and is itself a button source, so an
|
|
5
|
+
// action binds `hold(pad.button("west"))` for a charged attack next to
|
|
6
|
+
// the plain press on another action, and the consumer reads one bool.
|
|
7
|
+
//
|
|
8
|
+
// Timing: an interaction watches its source's rate under a root of its
|
|
9
|
+
// own and keeps time with setTimeout, so it works headless and needs no
|
|
10
|
+
// frame loop; the root is disposed with the creating scope (a component
|
|
11
|
+
// body) and lives for the session without one. `tap` and `doubleTap`
|
|
12
|
+
// are edges, not levels: they read pressed for one task, long enough
|
|
13
|
+
// for a map's onPress to fire, then release on their own.
|
|
14
|
+
//
|
|
15
|
+
// Every processor composes the source's id (`invert(gamepad:leftStick)`,
|
|
16
|
+
// `hold(400,keyboard:key:Space)`), so a saved binding restores through
|
|
17
|
+
// the same processor (input-id.ts), and carries its device through for
|
|
18
|
+
// InputMap.device(). A processor's key/blur hooks forward to the source,
|
|
19
|
+
// so a keyboard source keeps receiving the map's key events under one.
|
|
20
|
+
|
|
21
|
+
import { createEffect, createRoot, createSignal, getOwner, onCleanup } from "@solidjs/signals"
|
|
22
|
+
import type { ActionKind, InputSource } from "./input-map"
|
|
23
|
+
import type { Vec2 } from "./input-axes"
|
|
24
|
+
|
|
25
|
+
// Time a button must stay down before hold() reads pressed (Unity's Hold
|
|
26
|
+
// interaction default).
|
|
27
|
+
const HOLD_MS = 400
|
|
28
|
+
// Longest press that still counts as a tap (Unity's Tap default).
|
|
29
|
+
const TAP_MS = 200
|
|
30
|
+
// Longest gap between two taps that makes a double tap.
|
|
31
|
+
const DOUBLE_TAP_GAP_MS = 300
|
|
32
|
+
|
|
33
|
+
export function checkSource(source: unknown): InputSource {
|
|
34
|
+
let s = source as InputSource
|
|
35
|
+
// A pointer gesture is a callable source (`pointer.drag("Ctrl")` is its chord variant).
|
|
36
|
+
if (!s || (typeof s !== "object" && typeof s !== "function") || (s.kind !== "button" && s.kind !== "axis" && s.kind !== "vec2") || typeof s.label !== "string" || typeof s.id !== "string") {
|
|
37
|
+
throw new Error(`createInputMap: not an input source: ${String(source)}`)
|
|
38
|
+
}
|
|
39
|
+
return s
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let checkAxisSource = (what: string, source: InputSource): void => {
|
|
43
|
+
checkSource(source)
|
|
44
|
+
if ((source.kind as ActionKind) === "button") throw new Error(`${what}: "${source.label}" is a button`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let checkButtonSource = (what: string, source: InputSource): void => {
|
|
48
|
+
checkSource(source)
|
|
49
|
+
if (source.kind !== "button") throw new Error(`${what}: "${source.label}" is a ${source.kind}, not a button`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let checkMs = (what: string, ms: number): void => {
|
|
53
|
+
if (!Number.isFinite(ms) || ms < 0) throw new Error(`${what}: ms must be a non-negative number, got ${String(ms)}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A source with its values negated - Unity's Invert processor. The one
|
|
58
|
+
* convention split every engine has: a drag moves the CONTENT under the
|
|
59
|
+
* finger, while keys and sticks move the CAMERA, so a preset binds the
|
|
60
|
+
* arrows or a stick to `pan` or `rotate` through invert() and the axis
|
|
61
|
+
* keeps one meaning.
|
|
62
|
+
*/
|
|
63
|
+
export function invert<K extends "axis" | "vec2">(source: InputSource<K>): InputSource<K> {
|
|
64
|
+
checkAxisSource("invert", source)
|
|
65
|
+
let neg = (v: number | Vec2): number | Vec2 => (typeof v === "number" ? -v : [-v[0], -v[1]])
|
|
66
|
+
return {
|
|
67
|
+
kind: source.kind,
|
|
68
|
+
label: `${source.label} (inverted)`,
|
|
69
|
+
id: `invert(${source.id})`,
|
|
70
|
+
device: source.device,
|
|
71
|
+
rate: source.rate ? () => neg(source.rate!() as number | Vec2) as never : undefined,
|
|
72
|
+
deltas: source.deltas
|
|
73
|
+
? sink => source.deltas!({ begin: sink.begin, end: velocity => sink.end(velocity === undefined ? undefined : neg(velocity)), delta: (value, focal) => sink.delta(neg(value), focal) })
|
|
74
|
+
: undefined,
|
|
75
|
+
key: source.key,
|
|
76
|
+
blur: source.blur,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A source scaled by `factor` (rates and deltas alike) - Unity's Scale
|
|
81
|
+
* processor: a slower stick, a finer wheel. */
|
|
82
|
+
export function scale<K extends "axis" | "vec2">(source: InputSource<K>, factor: number): InputSource<K> {
|
|
83
|
+
checkAxisSource("scale", source)
|
|
84
|
+
if (!Number.isFinite(factor)) throw new Error(`scale: factor must be a finite number, got ${factor}`)
|
|
85
|
+
let mul = (v: number | Vec2): number | Vec2 => (typeof v === "number" ? v * factor : [v[0] * factor, v[1] * factor])
|
|
86
|
+
return {
|
|
87
|
+
kind: source.kind,
|
|
88
|
+
label: `${source.label} (x${factor})`,
|
|
89
|
+
id: `scale(${factor},${source.id})`,
|
|
90
|
+
device: source.device,
|
|
91
|
+
rate: source.rate ? () => mul(source.rate!() as number | Vec2) as never : undefined,
|
|
92
|
+
deltas: source.deltas
|
|
93
|
+
? sink => source.deltas!({ begin: sink.begin, end: velocity => sink.end(velocity === undefined ? undefined : mul(velocity)), delta: (value, focal) => sink.delta(mul(value), focal) })
|
|
94
|
+
: undefined,
|
|
95
|
+
key: source.key,
|
|
96
|
+
blur: source.blur,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A button source derived from one button source's press and release
|
|
101
|
+
// edges: `edge(down, now)` runs on each, with `set` for the derived
|
|
102
|
+
// state and `later` for a timer that the disposal clears.
|
|
103
|
+
type EdgeHooks = { set: (pressed: boolean) => void; later: (ms: number, run: () => void) => void; clearLater: () => void }
|
|
104
|
+
|
|
105
|
+
function derived(source: InputSource<"button">, label: string, id: string, edge: (down: boolean, now: number, hooks: EdgeHooks) => void): InputSource<"button"> {
|
|
106
|
+
// ownedWrite: the edges land inside an effect and timers.
|
|
107
|
+
let [pressed, setPressed] = createSignal(false, { ownedWrite: true })
|
|
108
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
109
|
+
let hooks: EdgeHooks = {
|
|
110
|
+
set: setPressed,
|
|
111
|
+
later(ms, run) {
|
|
112
|
+
hooks.clearLater()
|
|
113
|
+
timer = setTimeout(() => {
|
|
114
|
+
timer = null
|
|
115
|
+
run()
|
|
116
|
+
}, ms)
|
|
117
|
+
},
|
|
118
|
+
clearLater() {
|
|
119
|
+
if (timer !== null) clearTimeout(timer)
|
|
120
|
+
timer = null
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
let dispose = createRoot(dispose => {
|
|
124
|
+
createEffect(
|
|
125
|
+
() => source.rate!(),
|
|
126
|
+
(down, prev) => {
|
|
127
|
+
if (down !== prev) edge(down, performance.now(), hooks)
|
|
128
|
+
},
|
|
129
|
+
{ defer: true },
|
|
130
|
+
)
|
|
131
|
+
return dispose
|
|
132
|
+
})
|
|
133
|
+
if (getOwner()) {
|
|
134
|
+
onCleanup(() => {
|
|
135
|
+
hooks.clearLater()
|
|
136
|
+
dispose()
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
return { kind: "button", label, id, device: source.device, rate: pressed, key: source.key, blur: source.blur }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A one-task pulse: pressed now, released on the next task, so a map's
|
|
143
|
+
// onPress effect (which runs at the flush in between) sees the edge.
|
|
144
|
+
let pulse = (hooks: EdgeHooks): void => {
|
|
145
|
+
hooks.set(true)
|
|
146
|
+
hooks.later(0, () => hooks.set(false))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Pressed once `source` has been held `ms` (default 400), until released:
|
|
150
|
+
* a charged attack, a long-press menu. */
|
|
151
|
+
export function hold(source: InputSource<"button">, ms = HOLD_MS): InputSource<"button"> {
|
|
152
|
+
checkButtonSource("hold", source)
|
|
153
|
+
checkMs("hold", ms)
|
|
154
|
+
return derived(source, `${source.label} (hold ${ms} ms)`, `hold(${ms},${source.id})`, (down, _now, hooks) => {
|
|
155
|
+
if (down) hooks.later(ms, () => hooks.set(true))
|
|
156
|
+
else {
|
|
157
|
+
hooks.clearLater()
|
|
158
|
+
hooks.set(false)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A press released within `ms` (default 200): pressed for one task on the
|
|
164
|
+
* release, so onPress fires once per tap and a long press is not one. */
|
|
165
|
+
export function tap(source: InputSource<"button">, ms = TAP_MS): InputSource<"button"> {
|
|
166
|
+
checkButtonSource("tap", source)
|
|
167
|
+
checkMs("tap", ms)
|
|
168
|
+
let downAt = 0
|
|
169
|
+
return derived(source, `${source.label} (tap)`, `tap(${ms},${source.id})`, (down, now, hooks) => {
|
|
170
|
+
if (down) downAt = now
|
|
171
|
+
else if (now - downAt <= ms) pulse(hooks)
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Two taps (each within `tapMs`, default 200) at most `gapMs` (default
|
|
176
|
+
* 300) apart: pressed for one task on the second release. */
|
|
177
|
+
export function doubleTap(source: InputSource<"button">, gapMs = DOUBLE_TAP_GAP_MS, tapMs = TAP_MS): InputSource<"button"> {
|
|
178
|
+
checkButtonSource("doubleTap", source)
|
|
179
|
+
checkMs("doubleTap", gapMs)
|
|
180
|
+
checkMs("doubleTap", tapMs)
|
|
181
|
+
let downAt = 0
|
|
182
|
+
let lastTap = -Infinity
|
|
183
|
+
return derived(source, `${source.label} (double tap)`, `doubleTap(${gapMs},${tapMs},${source.id})`, (down, now, hooks) => {
|
|
184
|
+
if (down) {
|
|
185
|
+
downAt = now
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
if (now - downAt > tapMs) return
|
|
189
|
+
if (now - lastTap <= gapMs) {
|
|
190
|
+
lastTap = -Infinity
|
|
191
|
+
pulse(hooks)
|
|
192
|
+
} else lastTap = now
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Pressed while every source is: Shift+click on one action, a two-button
|
|
197
|
+
* combo on a pad. Key events reach each keyboard source among them. */
|
|
198
|
+
export function chord(...sources: InputSource<"button">[]): InputSource<"button"> {
|
|
199
|
+
if (sources.length < 2) throw new Error("chord: needs at least two button sources")
|
|
200
|
+
for (let s of sources) checkButtonSource("chord", s)
|
|
201
|
+
return {
|
|
202
|
+
kind: "button",
|
|
203
|
+
label: sources.map(s => s.label).join(" + "),
|
|
204
|
+
id: `chord(${sources.map(s => s.id).join(",")})`,
|
|
205
|
+
device: sources[0]!.device,
|
|
206
|
+
rate: () => sources.every(s => s.rate?.() === true),
|
|
207
|
+
key: (event, down) => {
|
|
208
|
+
for (let s of sources) s.key?.(event, down)
|
|
209
|
+
},
|
|
210
|
+
blur: () => {
|
|
211
|
+
for (let s of sources) s.blur?.()
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|
package/src/input.ts
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// event bus). The map, the control-side axes contract and the keyboard
|
|
5
5
|
// device are pure; the gamepad device and the pointer feed need the
|
|
6
6
|
// runtime and live on the main entry.
|
|
7
|
-
export { createInputMap
|
|
8
|
-
export type { ActionKind, ActionValue, ActionsDecl, Binding, DeltaSink, GestureListener, InputMap, InputSource } from "./input-map"
|
|
7
|
+
export { createInputMap } from "./input-map"
|
|
8
|
+
export type { ActionKind, ActionValue, ActionsDecl, Binding, DeltaSink, DeviceName, GestureListener, InputDevice, InputDeviceSet, InputMap, InputSource, RebindOptions, RebindPart } from "./input-map"
|
|
9
|
+
export { chord, doubleTap, hold, invert, scale, tap } from "./input-processors"
|
|
10
|
+
export { resolveSource } from "./input-id"
|
|
9
11
|
export { createAxes, combineRates } from "./input-axes"
|
|
10
12
|
export type { Axes, AxesDecl, AxesHooks, AxisKind, AxisValue, Vec2 } from "./input-axes"
|
|
11
13
|
export { keyboard } from "./input-keyboard"
|