@solidrt/core 0.0.55 → 0.0.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,467 @@
1
+ // The input map: named, typed actions between devices and consumers -
2
+ // Godot's InputMap and Unity's action maps, in reactive form. An app
3
+ // declares its actions once (`look: "vec2"`, `jump: "button"`), binds
4
+ // device sources to them (a stick, a key composite, a pointer drag), and
5
+ // consumers read the action, never the device. Anything can then drive a
6
+ // consumer: a second pad on a second map for split screen, a debug
7
+ // command or a network peer through the by-name injection, a replay on
8
+ // the action stream, a settings screen over bindings().
9
+ //
10
+ // Each axis or vec2 action carries two channels, which is the part Godot
11
+ // (no mouse motion in actions) and Unity (mouse delta and stick share one
12
+ // value, consumers scale by dt themselves) each got half of:
13
+ // - the RATE: the sum of every bound source's current value, clamped (an
14
+ // axis to -1..1, a vec2 to unit length), reactive, sampled per frame by
15
+ // the consumer and integrated over dt at its own speed;
16
+ // - the DELTAS: immediate, device-free amounts a gesture produced (a drag
17
+ // in view heights, a wheel notch in octaves), delivered as they happen
18
+ // with begin/end brackets around a finger's gesture.
19
+ // Values use the screen convention: x right, y down.
20
+ //
21
+ // A button action reads pressed while any bound button source is, and
22
+ // may also bind to an axis action (1 while pressed). Kinds are checked at
23
+ // bind: a vec2 source on an axis action throws.
24
+ //
25
+ // Reactivity: rates are memos over reactive sources, so a consumer's
26
+ // active() gate wakes on a stick or a key. Create the map in an owned
27
+ // scope (a component body): onPress/onRelease run effects under it.
28
+ //
29
+ // Consumers with the Axes contract (createAxes; the camera controls) are
30
+ // connected by name with drive(): each axis takes the action of the same
31
+ // name, or the name given, for both channels.
32
+ //
33
+ // Contexts (a menu open, a cutscene) switch actions off and on by name:
34
+ // enable/disable. A disabled action reads neutral, drops its deltas and
35
+ // closes the gesture it had open, while its sources keep their state, so
36
+ // a key still held when the action comes back reads at once. A set is a
37
+ // list of names, which a preset's action object already is
38
+ // (`input.disable(...Object.keys(gameActions))`): Unity's per-map enable
39
+ // and Unreal's stacked contexts, on one map, with no extra concept.
40
+
41
+ import { createEffect, createRoot, createSignal, untrack } from "@solidjs/signals"
42
+ import type { KeyEvent } from "./types"
43
+ import { combineRates } from "./input-axes"
44
+ import type { Axes, AxesDecl, AxisValue, Vec2 } from "./input-axes"
45
+
46
+ export type ActionKind = "button" | "axis" | "vec2"
47
+ export type ActionValue<K extends ActionKind> = K extends "button" ? boolean : K extends "axis" ? number : Vec2
48
+ export type ActionsDecl = Record<string, ActionKind>
49
+
50
+ /** Where a delta source delivers: the gesture brackets and the deltas. */
51
+ export interface DeltaSink {
52
+ begin(): void
53
+ delta(value: number | Vec2, focal?: Vec2): void
54
+ end(): void
55
+ }
56
+
57
+ /**
58
+ * One device control as an input source. `rate` reads its current value
59
+ * (reactive); `deltas` subscribes a sink to the amounts a gesture
60
+ * produces; `key`/`blur` receive the key events a map forwards
61
+ * (InputMap.handlers). A source may carry any subset.
62
+ */
63
+ export interface InputSource<K extends ActionKind = ActionKind> {
64
+ readonly kind: K
65
+ /** For bindings listings and settings screens: "keyboard W/A/S/D". */
66
+ readonly label: string
67
+ rate?: () => ActionValue<K>
68
+ deltas?: (sink: DeltaSink) => () => void
69
+ key?: (event: KeyEvent, down: boolean) => void
70
+ blur?: () => void
71
+ }
72
+
73
+ export type Binding = { action: string; source: InputSource }
74
+
75
+ /**
76
+ * A source with its values negated - Unity's Invert processor. The one
77
+ * convention split every engine has: a drag moves the CONTENT under the
78
+ * finger, while keys and sticks move the CAMERA, so a preset binds the
79
+ * arrows or a stick to `pan` or `rotate` through invert() and the axis
80
+ * keeps one meaning.
81
+ */
82
+ export function invert<K extends "axis" | "vec2">(source: InputSource<K>): InputSource<K> {
83
+ checkSource(source)
84
+ if ((source.kind as ActionKind) === "button") throw new Error(`invert: "${source.label}" is a button`)
85
+ let neg = (v: number | Vec2): number | Vec2 => (typeof v === "number" ? -v : [-v[0], -v[1]])
86
+ return {
87
+ kind: source.kind,
88
+ label: `${source.label} (inverted)`,
89
+ rate: source.rate ? () => neg(source.rate!() as number | Vec2) as never : undefined,
90
+ deltas: source.deltas
91
+ ? sink => source.deltas!({ begin: sink.begin, end: sink.end, delta: (value, focal) => sink.delta(neg(value), focal) })
92
+ : undefined,
93
+ key: source.key,
94
+ blur: source.blur,
95
+ }
96
+ }
97
+
98
+ /** A source scaled by `factor` (rates and deltas alike) - Unity's Scale
99
+ * processor: a slower stick, a finer wheel. */
100
+ export function scale<K extends "axis" | "vec2">(source: InputSource<K>, factor: number): InputSource<K> {
101
+ checkSource(source)
102
+ if ((source.kind as ActionKind) === "button") throw new Error(`scale: "${source.label}" is a button`)
103
+ if (!Number.isFinite(factor)) throw new Error(`scale: factor must be a finite number, got ${factor}`)
104
+ let mul = (v: number | Vec2): number | Vec2 => (typeof v === "number" ? v * factor : [v[0] * factor, v[1] * factor])
105
+ return {
106
+ kind: source.kind,
107
+ label: `${source.label} (x${factor})`,
108
+ rate: source.rate ? () => mul(source.rate!() as number | Vec2) as never : undefined,
109
+ deltas: source.deltas
110
+ ? sink => source.deltas!({ begin: sink.begin, end: sink.end, delta: (value, focal) => sink.delta(mul(value), focal) })
111
+ : undefined,
112
+ key: source.key,
113
+ blur: source.blur,
114
+ }
115
+ }
116
+
117
+ export interface GestureListener<K extends "axis" | "vec2" = "axis" | "vec2"> {
118
+ begin?: () => void
119
+ delta?: (value: AxisValue<K>, focal: Vec2 | undefined) => void
120
+ end?: () => void
121
+ }
122
+
123
+ type AxisActions<A extends ActionsDecl> = { [N in keyof A]: A[N] extends "button" ? never : N }[keyof A] & string
124
+ type ButtonActions<A extends ActionsDecl> = { [N in keyof A]: A[N] extends "button" ? N : never }[keyof A] & string
125
+
126
+ export interface InputMap<A extends ActionsDecl> {
127
+ readonly actions: A
128
+ /** Bind sources to an action (kinds checked), or a list of bindings (a
129
+ * preset's return). Returns the remover for what this call bound. */
130
+ bind<N extends keyof A & string>(action: N, ...sources: InputSource[]): () => void
131
+ bind(bindings: Binding[]): () => void
132
+ unbind(action: keyof A & string, source: InputSource): void
133
+ /** Current bindings, of one action or all, in binding order. */
134
+ bindings(action?: keyof A & string): Binding[]
135
+ /** The action's rate now (reactive): pressed for a button, -1..1 for an
136
+ * axis, a unit-clamped [x, y] for a vec2. */
137
+ value<N extends keyof A & string>(action: N): ActionValue<A[N]>
138
+ /** Reactive: whether a button action is pressed. */
139
+ pressed(action: ButtonActions<A>): boolean
140
+ /** Edge callbacks on a button action; return the unsubscribe. */
141
+ onPress(action: ButtonActions<A>, callback: () => void): () => void
142
+ onRelease(action: ButtonActions<A>, callback: () => void): () => void
143
+ /** Subscribe to an axis or vec2 action's delta channel. */
144
+ onGesture<N extends AxisActions<A>>(action: N, listener: GestureListener<A[N] extends "axis" ? "axis" : "vec2">): () => void
145
+ /** Injection by name, for scripts, debug commands, peers: `set` holds
146
+ * a rate until set again (a value source of the map's own); `press`/
147
+ * `release` do the same for a button; `nudge`, `begin` and `end` feed
148
+ * the delta channel. */
149
+ set<N extends keyof A & string>(action: N, value: ActionValue<A[N]>): void
150
+ press(action: ButtonActions<A>): void
151
+ release(action: ButtonActions<A>): void
152
+ nudge<N extends AxisActions<A>>(action: N, delta: ActionValue<A[N]>, focal?: Vec2): void
153
+ begin(action: AxisActions<A>): void
154
+ end(action: AxisActions<A>): void
155
+ /** Switch actions on and off by name - a context. A disabled action
156
+ * reads neutral (false, 0, [0, 0]), drops its deltas and closes the
157
+ * gesture it had open (its listeners see the end); its sources keep
158
+ * their state, so a key still held when the action comes back reads at
159
+ * once. Edge callbacks see the switch as a release or a press. All
160
+ * actions start enabled. */
161
+ enable(...actions: (keyof A & string)[]): void
162
+ disable(...actions: (keyof A & string)[]): void
163
+ /** Reactive: whether the action is enabled. */
164
+ enabled(action: keyof A & string): boolean
165
+ /** Key events for the bound keyboard sources: spread on the window
166
+ * (app-global) or on the leaf that should hold focus for them. */
167
+ handlers: {
168
+ onKeyDown(event: KeyEvent): void
169
+ onKeyUp(event: KeyEvent): void
170
+ onBlur(): void
171
+ }
172
+ /**
173
+ * Drive a control's axes (createAxes) from this map: each axis reads
174
+ * the action of the same name, or the one `names` gives it (null skips
175
+ * an axis). Both channels: rates as a source, deltas and brackets as
176
+ * nudges. Returns the disconnect.
177
+ */
178
+ drive<X extends AxesDecl>(axes: Axes<X>, names?: Partial<Record<keyof X & string, keyof A & string | null>>): () => void
179
+ }
180
+
181
+ // Per action: the bound sources and the script value are plain state (a
182
+ // write and a read in the same synchronous block must agree, which a
183
+ // signal's deferred write does not give); `version` notifies the reactive
184
+ // readers of a change.
185
+ type ActionState = {
186
+ kind: ActionKind
187
+ sources: InputSource[]
188
+ script: boolean | number | Vec2 | null
189
+ enabled: boolean
190
+ /** Gesture brackets delivered to the listeners and not yet closed. */
191
+ depth: number
192
+ version: () => number
193
+ bump: () => void
194
+ value: () => boolean | number | Vec2
195
+ gesture: Set<GestureListener>
196
+ /** Live delta subscriptions per bound source. */
197
+ live: Map<InputSource, () => void>
198
+ }
199
+
200
+ let compatible = (source: ActionKind, action: ActionKind): boolean => source === action || (source === "button" && action === "axis")
201
+
202
+ let neutral = (kind: ActionKind): boolean | number | Vec2 => (kind === "button" ? false : kind === "axis" ? 0 : [0, 0])
203
+
204
+ function checkValue(what: string, kind: ActionKind, v: unknown): void {
205
+ if (kind === "button") {
206
+ if (typeof v !== "boolean") throw new Error(`${what}: expected a boolean, got ${String(v)}`)
207
+ } else if (kind === "axis") {
208
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`${what}: expected a finite number, got ${String(v)}`)
209
+ } else if (!Array.isArray(v) || v.length !== 2 || !Number.isFinite(v[0]) || !Number.isFinite(v[1])) {
210
+ throw new Error(`${what}: expected [x, y] finite numbers, got ${JSON.stringify(v)}`)
211
+ }
212
+ }
213
+
214
+ function checkSource(source: unknown): InputSource {
215
+ let s = source as InputSource
216
+ if (!s || typeof s !== "object" || (s.kind !== "button" && s.kind !== "axis" && s.kind !== "vec2") || typeof s.label !== "string") {
217
+ throw new Error(`createInputMap: not an input source: ${String(source)}`)
218
+ }
219
+ return s
220
+ }
221
+
222
+ export function createInputMap<A extends ActionsDecl>(actions: A): InputMap<A> {
223
+ if (!actions || typeof actions !== "object") throw new Error("createInputMap: expected an object of action kinds")
224
+ let states = new Map<string, ActionState>()
225
+ for (let [name, kind] of Object.entries(actions)) {
226
+ if (kind !== "button" && kind !== "axis" && kind !== "vec2") throw new Error(`createInputMap: action "${name}" has kind "${String(kind)}", expected "button", "axis" or "vec2"`)
227
+ // ownedWrite: bind() and set() run from component bodies.
228
+ let [version, setVersion] = createSignal(0, { ownedWrite: true })
229
+ let state: ActionState = {
230
+ kind,
231
+ sources: [],
232
+ script: null,
233
+ enabled: true,
234
+ depth: 0,
235
+ version,
236
+ bump: () => setVersion(untrack(version) + 1),
237
+ value: (): boolean | number | Vec2 => {
238
+ version()
239
+ if (!state.enabled) return neutral(kind)
240
+ let s = state.script
241
+ if (kind === "button") {
242
+ if (s === true) return true
243
+ return state.sources.some(src => src.rate?.() === true)
244
+ }
245
+ let values: (number | Vec2)[] = []
246
+ if (s !== null) values.push(s as number | Vec2)
247
+ for (let src of state.sources) {
248
+ if (!src.rate) continue
249
+ let v = src.rate()
250
+ if (src.kind === "button") values.push(v ? 1 : 0)
251
+ else values.push(v as number | Vec2)
252
+ }
253
+ return combineRates(kind, values)
254
+ },
255
+ gesture: new Set(),
256
+ live: new Map(),
257
+ }
258
+ states.set(name, state)
259
+ }
260
+ let state = (name: string): ActionState => {
261
+ let s = states.get(name)
262
+ if (!s) throw new Error(`createInputMap: no action "${name}" (declared: ${[...states.keys()].join(", ")})`)
263
+ return s
264
+ }
265
+ let axisState = (name: string, what: string): ActionState => {
266
+ let s = state(name)
267
+ if (s.kind === "button") throw new Error(`createInputMap: ${what} needs an axis or vec2 action, "${name}" is a button`)
268
+ return s
269
+ }
270
+ let buttonState = (name: string, what: string): ActionState => {
271
+ let s = state(name)
272
+ if (s.kind !== "button") throw new Error(`createInputMap: ${what} needs a button action, "${name}" is a ${s.kind}`)
273
+ return s
274
+ }
275
+ // The delta channel fan-out for one action. Brackets are counted as
276
+ // delivered, so a disable can close what is open and an end whose begin
277
+ // was dropped (or never delivered) reaches no listener.
278
+ let closeGesture = (s: ActionState): void => {
279
+ s.depth--
280
+ s.gesture.forEach(g => g.end?.())
281
+ }
282
+ let sink = (s: ActionState): DeltaSink => ({
283
+ begin: () => {
284
+ if (!s.enabled) return
285
+ s.depth++
286
+ s.gesture.forEach(g => g.begin?.())
287
+ },
288
+ delta: (value, focal) => {
289
+ if (!s.enabled) return
290
+ s.gesture.forEach(g => g.delta?.(value as never, focal))
291
+ },
292
+ end: () => {
293
+ if (s.depth > 0) closeGesture(s)
294
+ },
295
+ })
296
+ let switchActions = (names: string[], on: boolean, what: string): void => {
297
+ if (names.length === 0) throw new Error(`createInputMap: ${what}() needs at least one action`)
298
+ for (let name of names) {
299
+ let s = state(name)
300
+ if (s.enabled === on) continue
301
+ s.enabled = on
302
+ while (!on && s.depth > 0) closeGesture(s)
303
+ s.bump()
304
+ }
305
+ }
306
+
307
+ let bindOne = (name: string, source: InputSource): void => {
308
+ let s = state(name)
309
+ checkSource(source)
310
+ if (!compatible(source.kind, s.kind)) throw new Error(`createInputMap: cannot bind ${source.kind} source "${source.label}" to ${s.kind} action "${name}"`)
311
+ if (s.sources.includes(source)) return
312
+ s.sources.push(source)
313
+ s.bump()
314
+ if (source.deltas && s.kind !== "button") s.live.set(source, source.deltas(sink(s)))
315
+ }
316
+ let unbindOne = (name: string, source: InputSource): void => {
317
+ let s = state(name)
318
+ let i = s.sources.indexOf(source)
319
+ if (i < 0) return
320
+ s.sources.splice(i, 1)
321
+ s.bump()
322
+ let stop = s.live.get(source)
323
+ if (stop) {
324
+ s.live.delete(source)
325
+ stop()
326
+ }
327
+ }
328
+ let edge = (name: string, want: boolean, callback: () => void): (() => void) => {
329
+ let s = buttonState(name, want ? "onPress" : "onRelease")
330
+ if (typeof callback !== "function") throw new Error(`createInputMap: ${want ? "onPress" : "onRelease"}("${name}") expects a function`)
331
+ return createRoot(dispose => {
332
+ // The callback is a side effect that may read state of its own (a
333
+ // focus, a pose): a snapshot, not a dependency, so untracked.
334
+ createEffect(
335
+ () => s.value() as boolean,
336
+ (pressed, prev) => {
337
+ if (pressed === want && prev !== want) untrack(callback)
338
+ },
339
+ { defer: true },
340
+ )
341
+ return dispose
342
+ })
343
+ }
344
+
345
+ let map: InputMap<A> = {
346
+ actions,
347
+ bind(actionOrList: string | Binding[], ...sources: InputSource[]) {
348
+ let list: Binding[] = Array.isArray(actionOrList) ? actionOrList : sources.map(source => ({ action: actionOrList, source }))
349
+ if (list.length === 0) throw new Error("createInputMap: bind() needs at least one source")
350
+ for (let b of list) bindOne(b.action, b.source)
351
+ return () => {
352
+ for (let b of list) unbindOne(b.action, b.source)
353
+ }
354
+ },
355
+ unbind: unbindOne,
356
+ bindings(action) {
357
+ let out: Binding[] = []
358
+ for (let [name, s] of states) {
359
+ if (action !== undefined && name !== action) continue
360
+ for (let source of s.sources) out.push({ action: name, source })
361
+ }
362
+ if (action !== undefined) state(action)
363
+ return out
364
+ },
365
+ value(action) {
366
+ return state(action).value() as never
367
+ },
368
+ pressed(action) {
369
+ return buttonState(action, "pressed").value() as boolean
370
+ },
371
+ onPress: (action, callback) => edge(action, true, callback),
372
+ onRelease: (action, callback) => edge(action, false, callback),
373
+ onGesture(action, listener) {
374
+ let s = axisState(action, "onGesture")
375
+ if (!listener || typeof listener !== "object") throw new Error(`createInputMap: onGesture("${action}") expects a listener object`)
376
+ s.gesture.add(listener as GestureListener)
377
+ return () => {
378
+ s.gesture.delete(listener as GestureListener)
379
+ }
380
+ },
381
+ set(action, value) {
382
+ let s = state(action)
383
+ checkValue(`set("${action}")`, s.kind, value)
384
+ s.script = value
385
+ s.bump()
386
+ },
387
+ press(action) {
388
+ let s = buttonState(action, "press")
389
+ s.script = true
390
+ s.bump()
391
+ },
392
+ release(action) {
393
+ let s = buttonState(action, "release")
394
+ s.script = false
395
+ s.bump()
396
+ },
397
+ nudge(action, delta, focal) {
398
+ let s = axisState(action, "nudge")
399
+ checkValue(`nudge("${action}")`, s.kind, delta)
400
+ if (focal !== undefined) checkValue(`nudge("${action}") focal`, "vec2", focal)
401
+ sink(s).delta(delta as number | Vec2, focal)
402
+ },
403
+ begin(action) {
404
+ sink(axisState(action, "begin")).begin()
405
+ },
406
+ end(action) {
407
+ sink(axisState(action, "end")).end()
408
+ },
409
+ enable: (...actions) => switchActions(actions, true, "enable"),
410
+ disable: (...actions) => switchActions(actions, false, "disable"),
411
+ enabled(action) {
412
+ let s = state(action)
413
+ s.version()
414
+ return s.enabled
415
+ },
416
+ handlers: {
417
+ onKeyDown: event => forwardKey(event, true),
418
+ onKeyUp: event => forwardKey(event, false),
419
+ onBlur: () => {
420
+ let seen = new Set<InputSource>()
421
+ for (let s of states.values()) {
422
+ for (let source of s.sources) {
423
+ if (seen.has(source)) continue
424
+ seen.add(source)
425
+ source.blur?.()
426
+ }
427
+ }
428
+ },
429
+ },
430
+ drive(axes, names) {
431
+ let stops: (() => void)[] = []
432
+ for (let [axis, kind] of Object.entries(axes.kinds)) {
433
+ let mapped = names && axis in names ? names[axis] : axis
434
+ if (mapped === null) continue
435
+ if (mapped === undefined || !states.has(mapped)) {
436
+ throw new Error(`createInputMap: drive() has no action for axis "${axis}" (declared: ${[...states.keys()].join(", ")}); declare it, map it with names, or skip it with null`)
437
+ }
438
+ let s = state(mapped)
439
+ if (s.kind !== kind) throw new Error(`createInputMap: drive() maps ${kind} axis "${axis}" to ${s.kind} action "${mapped}"`)
440
+ stops.push(axes.add(axis, () => s.value() as never))
441
+ stops.push(
442
+ map.onGesture(mapped as never, {
443
+ begin: () => axes.begin(axis),
444
+ delta: (value, focal) => axes.nudge(axis, value as never, focal),
445
+ end: () => axes.end(axis),
446
+ }),
447
+ )
448
+ }
449
+ return () => {
450
+ for (let stop of stops) stop()
451
+ }
452
+ },
453
+ }
454
+ // A key event reaches each bound keyboard source once, however many
455
+ // actions it is bound to.
456
+ let forwardKey = (event: KeyEvent, down: boolean): void => {
457
+ let seen = new Set<InputSource>()
458
+ for (let s of states.values()) {
459
+ for (let source of s.sources) {
460
+ if (seen.has(source) || !source.key) continue
461
+ seen.add(source)
462
+ source.key(event, down)
463
+ }
464
+ }
465
+ }
466
+ return map
467
+ }
@@ -0,0 +1,213 @@
1
+ // A pointer feed: one element's pointer events turned into gesture
2
+ // sources for an input map. The pointer is the one device that is not
3
+ // global - the tree holds UI and scene together, so a drag belongs to the
4
+ // element it started on (a slider beside a viewport must not turn the
5
+ // camera) - hence a feed per element: spread `feed.handlers` on the leaf
6
+ // (a Scene, View3d, SpriteLayer or View2d takes it as its `pointer` prop
7
+ // and spreads it on the built-in leaf) and bind its sources.
8
+ //
9
+ // The sources are gestures, not raw events, because a pointer only means
10
+ // something once a gesture is recognized: a move without a press is
11
+ // nothing, one finger dragging is `drag`, two fingers moving together is
12
+ // `pan`, spreading is `pinch`, turning is `twist`, and the wheel is its
13
+ // own thing. One merged recognizer (createTransform) serves all of them,
14
+ // so they arbitrate in the app-wide gesture arena as one - a viewport
15
+ // inside a scroller never double-handles - and they never fight each
16
+ // other over the same fingers. `mouseDelta` is the pointer-lock path:
17
+ // raw mouse motion while the pointer is locked, when the cursor does not
18
+ // move and no drag can arm.
19
+ //
20
+ // Units are device-free (the input map's contract): drag and pan travel
21
+ // in the element's own heights (a drag across the element is 1 whatever
22
+ // the window size - Three's OrbitControls convention), pinch and wheel
23
+ // in octaves (log2 of the scale change, one notch about a fifth), twist
24
+ // in turns, mouse motion in view-height-equivalents through a fixed
25
+ // reference. The focal point of a pinch or a wheel rides along as a
26
+ // fraction of the element (0..1), the anchor a zoom-about-the-cursor
27
+ // consumer maps to its own space. Screen convention: x right, y down.
28
+ //
29
+ // Gesture brackets: `drag` begins when the first pointer lands and ends
30
+ // when the last lifts (or when a second lands, which begins `pan`,
31
+ // `pinch` and `twist` until the count drops back to one); the wheel
32
+ // sends no brackets, so a consumer can tell a notch from a finger.
33
+ // Brackets fire on the press itself, before the recognizer's slop, so a
34
+ // glide stops the moment a finger lands.
35
+ //
36
+ // The element's laid-out box normalizes the travel: read at the press
37
+ // through getLayoutBox (the untransformed read, so a designSize fit or
38
+ // an ancestor transform never skews it), or from `layout` when given -
39
+ // required for a detached d-* leaf, which has no layout box (the feed
40
+ // throws rather than treat pixels as heights), and handy for a headless
41
+ // check with no tree. A zero-height box passes pixels through unscaled.
42
+ //
43
+ // Create a feed in an owned scope (a component body): the recognizer
44
+ // registers its cleanup with the owner.
45
+
46
+ import { createTransform } from "./transform"
47
+ import { getLayoutBox } from "./core"
48
+ import { pointerLocked } from "./window"
49
+ import type { PointerEvent, WheelEvent } from "./types"
50
+ import type { DeltaSink, InputSource } from "./input-map"
51
+ import type { Vec2 } from "./input-axes"
52
+
53
+ // Octaves per wheel-delta unit: the zoom exponent both cameras used
54
+ // (0.0015 per unit) taken in log2, so a 100-unit notch zooms about a
55
+ // fifth of an octave and a fast scroll of ten notches doubles.
56
+ const WHEEL_OCTAVES = 0.0015 / Math.LN2
57
+ // Mouse travel per delta unit under pointer lock, in logical pixels: one
58
+ // unit is what a drag across the element's height means to a consumer (a
59
+ // half turn of look in the first-person control), and 1500 px per half
60
+ // turn is the ~0.002 rad/px of Three's PointerLockControls.
61
+ const MOUSE_PX_PER_UNIT = 1500
62
+
63
+ export interface PointerFeed {
64
+ /** Spread on the element whose pointer events feed the sources. */
65
+ handlers: {
66
+ onPointerDown(event: PointerEvent): void
67
+ onPointerMove(event: PointerEvent): void
68
+ onPointerUp(event: PointerEvent): void
69
+ onPointerLeave(event: PointerEvent): void
70
+ onWheel(event: WheelEvent): void
71
+ }
72
+ /** One pointer's travel, in element heights; bracketed. */
73
+ drag: InputSource<"vec2">
74
+ /** Two or more pointers' focal travel, in element heights; bracketed. */
75
+ pan: InputSource<"vec2">
76
+ /** Span change of two pointers, in octaves (spreading positive), with
77
+ * the focal point; bracketed. */
78
+ pinch: InputSource<"axis">
79
+ /** Rotation of the pointer pair, in turns, with the focal point; bracketed. */
80
+ twist: InputSource<"axis">
81
+ /** Wheel notches in octaves (wheel up positive), with the cursor as the
82
+ * focal point; unbracketed. */
83
+ wheel: InputSource<"axis">
84
+ /** Raw mouse motion while the pointer is locked, in view-height
85
+ * equivalents (see MOUSE_PX_PER_UNIT); unbracketed. */
86
+ mouseDelta: InputSource<"vec2">
87
+ }
88
+
89
+ export interface PointerFeedOptions {
90
+ /** The element's size, when the app knows it - required for a detached
91
+ * d-* leaf, which has no layout box; default reads getLayoutBox at each
92
+ * press. */
93
+ layout?: () => { width: number; height: number } | null
94
+ }
95
+
96
+ type Sinks = Set<DeltaSink>
97
+
98
+ function source<K extends "axis" | "vec2">(kind: K, label: string, sinks: Sinks): InputSource<K> {
99
+ return {
100
+ kind,
101
+ label,
102
+ deltas(sink) {
103
+ sinks.add(sink)
104
+ return () => {
105
+ sinks.delete(sink)
106
+ }
107
+ },
108
+ }
109
+ }
110
+
111
+ export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed {
112
+ let sinks = {
113
+ drag: new Set<DeltaSink>(),
114
+ pan: new Set<DeltaSink>(),
115
+ pinch: new Set<DeltaSink>(),
116
+ twist: new Set<DeltaSink>(),
117
+ wheel: new Set<DeltaSink>(),
118
+ mouseDelta: new Set<DeltaSink>(),
119
+ }
120
+ let begin = (s: Sinks) => s.forEach(k => k.begin())
121
+ let end = (s: Sinks) => s.forEach(k => k.end())
122
+ let delta = (s: Sinks, value: number | Vec2, focal?: Vec2) => s.forEach(k => k.delta(value, focal))
123
+
124
+ // The element's box, read at each press (see the header). A detached
125
+ // leaf has no layout box and must bring `layout`: normalizing by
126
+ // nothing would turn a 100 px drag into 100 element heights, so that
127
+ // is a throw (the dev validation policy), while a zero-size box is a
128
+ // degenerate layout and passes pixels through.
129
+ let size = (event: { currentTarget: number }): { width: number; height: number } => {
130
+ let box = options.layout ? options.layout() : getLayoutBox({ id: event.currentTarget })
131
+ if (box === null && !options.layout) {
132
+ throw new Error("createPointerFeed: the element has no layout box (a detached d-* leaf, or before its first layout); create the feed with { layout: () => ({ width, height }) } for it")
133
+ }
134
+ return box && box.width > 0 && box.height > 0 ? { width: box.width, height: box.height } : { width: 0, height: 0 }
135
+ }
136
+ let box = { width: 0, height: 0 }
137
+ let unit = () => (box.height > 0 ? box.height : 1)
138
+ let focalOf = (x: number, y: number): Vec2 | undefined => (box.width > 0 && box.height > 0 ? [x / box.width, y / box.height] : undefined)
139
+
140
+ let transform = createTransform({
141
+ onTransformMove: t => {
142
+ let h = unit()
143
+ let travel: Vec2 = [t.dx / h, t.dy / h]
144
+ if (t.pointers >= 2) {
145
+ if (t.dx !== 0 || t.dy !== 0) delta(sinks.pan, travel)
146
+ let focal = focalOf(t.x, t.y)
147
+ if (t.scale !== 1) delta(sinks.pinch, Math.log2(t.scale), focal)
148
+ if (t.rotation !== 0) delta(sinks.twist, t.rotation / (2 * Math.PI), focal)
149
+ } else if (t.dx !== 0 || t.dy !== 0) {
150
+ delta(sinks.drag, travel)
151
+ }
152
+ },
153
+ })
154
+
155
+ // Pointers down on the element, for the gesture brackets: the first
156
+ // press opens drag, a second closes it and opens the two-pointer
157
+ // gestures, and the count coming back down reverses that.
158
+ let downs = new Set<number>()
159
+ let landed = (id: number) => {
160
+ if (downs.has(id)) return
161
+ downs.add(id)
162
+ if (downs.size === 1) begin(sinks.drag)
163
+ if (downs.size === 2) {
164
+ end(sinks.drag)
165
+ begin(sinks.pan)
166
+ begin(sinks.pinch)
167
+ begin(sinks.twist)
168
+ }
169
+ }
170
+ let lifted = (id: number) => {
171
+ if (!downs.delete(id)) return
172
+ if (downs.size === 1) {
173
+ end(sinks.pan)
174
+ end(sinks.pinch)
175
+ end(sinks.twist)
176
+ begin(sinks.drag)
177
+ }
178
+ if (downs.size === 0) end(sinks.drag)
179
+ }
180
+
181
+ return {
182
+ handlers: {
183
+ onPointerDown(e) {
184
+ if (e.button != null && e.button !== 0) return
185
+ if (downs.size === 0) box = size(e)
186
+ landed(e.pointerId)
187
+ transform.handlers.onPointerDown(e)
188
+ },
189
+ onPointerMove(e) {
190
+ if (pointerLocked() && e.pointerType === "mouse" && (e.movementX !== 0 || e.movementY !== 0)) {
191
+ delta(sinks.mouseDelta, [e.movementX / MOUSE_PX_PER_UNIT, e.movementY / MOUSE_PX_PER_UNIT])
192
+ }
193
+ transform.handlers.onPointerMove(e)
194
+ },
195
+ onPointerUp(e) {
196
+ transform.handlers.onPointerUp(e)
197
+ lifted(e.pointerId)
198
+ },
199
+ onPointerLeave() {},
200
+ onWheel(e) {
201
+ if (e.deltaY === 0) return
202
+ box = size(e)
203
+ delta(sinks.wheel, -e.deltaY * WHEEL_OCTAVES, focalOf(e.localX, e.localY))
204
+ },
205
+ },
206
+ drag: source("vec2", "pointer drag", sinks.drag),
207
+ pan: source("vec2", "pointer two-finger pan", sinks.pan),
208
+ pinch: source("axis", "pointer pinch", sinks.pinch),
209
+ twist: source("axis", "pointer twist", sinks.twist),
210
+ wheel: source("axis", "pointer wheel", sinks.wheel),
211
+ mouseDelta: source("vec2", "mouse motion (pointer locked)", sinks.mouseDelta),
212
+ }
213
+ }