@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.
@@ -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 "axis" | "vec2"> = InputSource<K> & ((chord: string) => InputSource<K>)
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) drag.end()
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
- landed(e, e.button ?? PRIMARY)
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, invert, scale } from "./input-map"
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"