@solidrt/core 0.0.57 → 0.0.59

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/src/gpu.ts CHANGED
@@ -135,7 +135,7 @@ export {
135
135
  // updated draw range (vertexCount after its buffer gained or lost dynamic
136
136
  // geometry, firstVertex for a different window of a shared buffer,
137
137
  // instanceCount for an instanced population; absent keys keep their current
138
- // value, like params) and/or swapped buffers (instanceBuffer pointed at a
138
+ // value, like params) and/or swapped buffers (an instance buffer pointed at a
139
139
  // larger buffer once a population outgrows the old one - the growth
140
140
  // primitive; replace-only, the range is rechecked); destroyBuffer is the manual
141
141
  // cleanup path for buffers created outside a reactive scope. renderTarget is
@@ -147,7 +147,7 @@ export {
147
147
  // GPU-side (exact, same size): seed a loadOp "load" accumulator, snapshot a
148
148
  // ping-pong buffer, reset state to a known image.
149
149
  export { copyTexture, destroyBuffer, renderTarget, setDraw } from "flux:gpu"
150
- export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange, InstanceAttribute, InstanceOrder, OrderUpdate, ShaderParams, Topology, VertexAttribute } from "flux:gpu"
150
+ export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange, InstanceOrder, OrderUpdate, ShaderParams, Topology, VertexAttribute, VertexBufferLayout, VertexFormat } from "flux:gpu"
151
151
 
152
152
  // The draw-list verbs, re-exported raw: entries live and die with their draw
153
153
  // target (see createDrawTarget below), so there is no per-entry lifetime to
@@ -366,20 +366,18 @@ export function createShaderTexture(
366
366
  * texture id is, e.g. `<texture src>`; resize with `setTargetSize`, drive
367
367
  * uniforms with `<texture params>` or `setTargetParams`). Many targets may
368
368
  * share one pipeline, and creating a target compiles nothing. The target
369
- * brings the per-target half: size, the concrete vertex `buffer` the
370
- * pipeline's attribute layout describes, the `instanceBuffer` its
371
- * `instanceAttributes` describe (required exactly when it declares any),
372
- * the draw range (`vertexCount` defaults to the rest of the buffer from
373
- * `firstVertex` on, `instanceCount` repeats it as instances told apart by
374
- * `gl_InstanceID` and defaults to one per instance-buffer record; a
375
- * fullscreen pass over an attributeless pipeline is `{ vertexCount: 3 }`
376
- * with a covering-triangle vertex stage), uniforms, and
377
- * `clearColor`. An `indexBuffer` + `indexFormat` pair makes the draw indexed
378
- * (shared vertices stored once), with the range in `firstIndex`/`indexCount`
379
- * spelling - see IndexBinding/IndexRange. Draw state (`attributes`,
380
- * `instanceAttributes`, `topology`, `blend`, `cull`, `depth`, `depthWrite`)
381
- * lives on the pipeline
382
- * and throws here. Frees the target when the reactive owner is disposed (opt
369
+ * brings the per-target half: size, the concrete `buffers` the pipeline's
370
+ * layouts describe (one id per layout, in declaration order), the draw
371
+ * range (`vertexCount` defaults to the rest of the tightest vertex-step
372
+ * buffer from `firstVertex` on, `instanceCount` repeats it as instances
373
+ * told apart by `gl_InstanceID` and defaults to one per record of the
374
+ * tightest instance-step buffer; a fullscreen pass over an attributeless
375
+ * pipeline is `{ vertexCount: 3 }` with a covering-triangle vertex stage),
376
+ * uniforms, and `clearColor`. An `indexBuffer` + `indexFormat` pair makes
377
+ * the draw indexed (shared vertices stored once), with the range in
378
+ * `firstIndex`/`indexCount` spelling - see IndexBinding/IndexRange. Draw
379
+ * state (`topology`, `blend`, `cull`, `depth`, `depthWrite`) lives on the
380
+ * pipeline and throws here. Frees the target when the reactive owner is disposed (opt
383
381
  * out with `autoFree: false`); the pipeline is yours and outlives it.
384
382
  *
385
383
  * `render: "manual"` makes it a manual target: the runtime never renders it
@@ -404,11 +402,8 @@ export function createShaderTarget(
404
402
  params?: gpu.ShaderParams | null,
405
403
  opts?: {
406
404
  textures?: gpu.TextureBindings
407
- buffer?: gpu.BufferId
408
- instanceBuffer?: gpu.BufferId
409
- /** One buffer per instance slot of the pipeline (index = the
410
- * attributes' `slot`); pass this OR `instanceBuffer`, not both. */
411
- instanceBuffers?: gpu.BufferId[]
405
+ /** One buffer id per layout of the pipeline, in declaration order. */
406
+ buffers?: gpu.BufferId[]
412
407
  /** Draw the instance records in key order (see InstanceOrder). */
413
408
  instanceOrder?: gpu.InstanceOrder
414
409
  clearColor?: [number, number, number, number]
@@ -658,9 +653,9 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
658
653
  * texture, returning the texture id (usable anywhere a normal texture id is,
659
654
  * e.g. `<texture src>`) - named, like `createShaderTexture`, for what comes
660
655
  * back. Unlike `createShaderTexture` the vertex stage is yours:
661
- * declare `in` attributes matching `opts.attributes` (one interleaved vertex
662
- * in `opts.buffer`, a {@link createBuffer} id) and your own varyings toward
663
- * the fragment stage. Clip space is y-down: `gl_Position` y = -1 is the top
656
+ * declare `in` attributes matching `opts.buffers` (each layout with the
657
+ * {@link createBuffer} id it reads) and your own varyings toward the
658
+ * fragment stage. Clip space is y-down: `gl_Position` y = -1 is the top
664
659
  * row of the target and +1 the bottom, so camera-up geometry must negate y
665
660
  * (or fold the flip into its projection) to display up. Both sources may
666
661
  * reference `iResolution` and any uniform they declare (`float`/`int`
@@ -679,10 +674,10 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
679
674
  * see DrawRange) defaults to the whole buffer drawn once and can be changed
680
675
  * later with `setDraw`; `instanceCount` is the standard answer to particles
681
676
  * and repeated meshes, N copies of the range told apart by `gl_InstanceID`
682
- * in the vertex stage. `opts.instanceAttributes` + `opts.instanceBuffer`
683
- * (declare both or neither) give each instance its own interleaved record -
684
- * real per-instance state instead of `gl_InstanceID` arithmetic - and
685
- * `instanceCount` then defaults to one instance per record. An
677
+ * in the vertex stage. A layout with `stepMode: "instance"` gives each
678
+ * instance its own interleaved record - real per-instance state instead of
679
+ * `gl_InstanceID` arithmetic - and `instanceCount` then defaults to one
680
+ * instance per record. An
686
681
  * `indexBuffer` + `indexFormat` pair makes the draw
687
682
  * indexed (shared vertices stored once), with the range in
688
683
  * `firstIndex`/`indexCount` spelling; `opts.cull` discards one face set by
@@ -701,13 +696,8 @@ export function createPipelineTexture(
701
696
  params?: gpu.ShaderParams | null,
702
697
  opts?: {
703
698
  textures?: gpu.TextureBindings
704
- attributes?: gpu.VertexAttribute[]
705
- buffer?: gpu.BufferId
706
- instanceAttributes?: gpu.InstanceAttribute[]
707
- instanceBuffer?: gpu.BufferId
708
- /** One buffer per instance slot (index = the attributes' `slot`);
709
- * pass this OR `instanceBuffer`, not both. */
710
- instanceBuffers?: gpu.BufferId[]
699
+ /** The pipeline's layouts, each with the buffer it reads. */
700
+ buffers?: (gpu.VertexBufferLayout & { buffer: gpu.BufferId })[]
711
701
  /** Draw the instance records in key order (see InstanceOrder). */
712
702
  instanceOrder?: gpu.InstanceOrder
713
703
  topology?: gpu.Topology
package/src/index.ts CHANGED
@@ -3,11 +3,11 @@ export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables,
3
3
  export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
4
4
  export { parseColor, mixColors, withAlpha, brightness, createLinearGradient, createRadialGradient } from "./color"
5
5
  export type { Gradient, GradientStop } from "./color"
6
- export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, exit } from "./window"
7
- export type { BackEvent } from "./window"
6
+ export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, onSuspend, onQuit, exit, background } from "./window"
7
+ export type { BackEvent, LifecycleHandler } from "./window"
8
8
  export { windowSize, safeArea, displayScale, windowFocused, keyboardHeight, lockPointer, pointerLocked } from "./window"
9
9
  export { env } from "./environment"
10
- export type { InputDevices, SystemTheme, Orientation, Visibility } from "./environment"
10
+ export type { InputDevices, SystemTheme, Orientation, Visibility, Launch } from "./environment"
11
11
  export { gamepads } from "./gamepad"
12
12
  export type { GamepadState } from "./gamepad"
13
13
  export { capabilities } from "./capabilities"
@@ -37,7 +37,7 @@ export type { KeyboardDevice, KeyboardVec2Keys } from "./input-keyboard"
37
37
  export { gamepad } from "./input-gamepad"
38
38
  export type { GamepadDevice } from "./input-gamepad"
39
39
  export { createPointerFeed } from "./input-pointer"
40
- export type { PointerFeed, PointerFeedOptions } from "./input-pointer"
40
+ export type { PointerFeed, PointerFeedOptions, PointerSource } from "./input-pointer"
41
41
  export type {
42
42
  Element,
43
43
  LayoutProps,
@@ -0,0 +1,44 @@
1
+ // Modifier chords, the vocabulary two devices share: a spec names
2
+ // modifiers ahead of what it qualifies ("Shift+Tab" on the keyboard,
3
+ // "Ctrl" on a pointer gesture), the event must carry them, a bare spec
4
+ // ignores them (a shifted W still walks, a Ctrl-drag still drags), and
5
+ // among the specs one event satisfies the most specific wins - Unreal's
6
+ // chord blocker, Three's OrbitControls picking PAN over ROTATE at
7
+ // mousedown; Godot and Unity fire both by default and every app writes
8
+ // the exclusion itself. Which event resolves a chord is the device's
9
+ // call: the keyboard settles each key down, the pointer feed the event
10
+ // that opens a gesture bracket.
11
+
12
+ export type Modifier = "shiftKey" | "ctrlKey" | "altKey" | "metaKey"
13
+ /** The modifier flags every key, pointer and wheel event carries. */
14
+ export type Modified = Record<Modifier, boolean>
15
+
16
+ const MODIFIERS: Record<string, Modifier> = { Shift: "shiftKey", Ctrl: "ctrlKey", Control: "ctrlKey", Alt: "altKey", Meta: "metaKey" }
17
+ // The canonical order a chord is spelled in, so one modifier set has one name.
18
+ const ORDER: Modifier[] = ["shiftKey", "ctrlKey", "altKey", "metaKey"]
19
+ const NAMES: Record<Modifier, string> = { shiftKey: "Shift", ctrlKey: "Ctrl", altKey: "Alt", metaKey: "Meta" }
20
+
21
+ /** The modifiers `parts` name (["Ctrl", "Shift"]), deduplicated, in
22
+ * canonical order; `what` and `text` name the caller and its spec in the
23
+ * throw on an unknown name. */
24
+ export function parseModifiers(what: string, text: string, parts: string[]): Modifier[] {
25
+ let mods = new Set<Modifier>()
26
+ for (let part of parts) {
27
+ let mod = MODIFIERS[part]
28
+ if (!mod) throw new Error(`${what}: unknown modifier "${part}" in "${text}" (Shift, Ctrl, Alt or Meta)`)
29
+ mods.add(mod)
30
+ }
31
+ return ORDER.filter(m => mods.has(m))
32
+ }
33
+
34
+ /** The canonical spelling of a modifier set: "Shift+Ctrl"; "" for none. */
35
+ export let chordName = (mods: Modifier[]): string => mods.map(m => NAMES[m]).join("+")
36
+
37
+ /** Of `items`, those whose modifiers the event carries, narrowed to the
38
+ * most specific (the longest modifier lists, all of them on a tie);
39
+ * empty when none match. */
40
+ export function mostSpecific<T>(items: T[], mods: (item: T) => Modifier[], event: Modified): T[] {
41
+ let hits = items.filter(item => mods(item).every(m => event[m]))
42
+ let most = hits.reduce((n, item) => Math.max(n, mods(item).length), 0)
43
+ return hits.filter(item => mods(item).length === most)
44
+ }
@@ -8,11 +8,12 @@
8
8
  // a shifted "W".
9
9
  //
10
10
  // A spec may name modifiers ahead of the key ("Shift+Tab", "Ctrl+KeyS";
11
- // Shift, Ctrl, Alt, Meta): the down must carry them, while a bare spec
12
- // ignores them (a shifted W still walks). Within one source the most
13
- // specific matching spec wins a down, so `axis("Shift+Tab", "Tab")` reads
14
- // -1, not 0, on Shift+Tab; and an up releases on the key alone, so a
15
- // modifier let go first cannot leave the key stuck.
11
+ // the chord vocabulary of input-chord.ts, shared with the pointer feed):
12
+ // the down must carry them, while a bare spec ignores them (a shifted W
13
+ // still walks). Within one source the most specific matching spec wins
14
+ // a down, so `axis("Shift+Tab", "Tab")` reads -1, not 0, on Shift+Tab;
15
+ // and an up releases on the key alone, so a modifier let go first
16
+ // cannot leave the key stuck.
16
17
  //
17
18
  // Held state is a count behind a signal, so a source's rate is reactive
18
19
  // (a held key wakes a control's frame loop) and a blur - the up that never
@@ -27,10 +28,8 @@ import { createSignal } from "@solidjs/signals"
27
28
  import type { KeyEvent } from "./types"
28
29
  import type { InputSource } from "./input-map"
29
30
  import type { Vec2 } from "./input-axes"
30
-
31
- type Modifier = "shiftKey" | "ctrlKey" | "altKey" | "metaKey"
32
-
33
- const MODIFIERS: Record<string, Modifier> = { Shift: "shiftKey", Ctrl: "ctrlKey", Control: "ctrlKey", Alt: "altKey", Meta: "metaKey" }
31
+ import { mostSpecific, parseModifiers } from "./input-chord"
32
+ import type { Modifier } from "./input-chord"
34
33
 
35
34
  // A parsed spec: the text as given (the held-state key and the label),
36
35
  // the key it names, and the modifiers the down must carry.
@@ -41,13 +40,7 @@ function parse(what: string, text: unknown): Spec {
41
40
  let parts = text.split("+")
42
41
  let key = parts.pop()!
43
42
  if (key.length === 0) throw new Error(`keyboard.${what}: "${text}" names no key`)
44
- let mods: Modifier[] = []
45
- for (let part of parts) {
46
- let mod = MODIFIERS[part]
47
- if (!mod) throw new Error(`keyboard.${what}: unknown modifier "${part}" in "${text}" (Shift, Ctrl, Alt or Meta)`)
48
- if (!mods.includes(mod)) mods.push(mod)
49
- }
50
- return { text, key, mods }
43
+ return { text, key, mods: parseModifiers(`keyboard.${what}`, text, parts) }
51
44
  }
52
45
 
53
46
  // A key matches its physical code or its logical key; a letter code
@@ -75,11 +68,7 @@ function held(specs: Spec[]) {
75
68
  // moves "Tab" to "Shift+Tab" and back), an up frees them all.
76
69
  let onKey = specs.filter(s => matches(event, s.key))
77
70
  for (let s of onKey) down.delete(s.text)
78
- if (isDown) {
79
- let hits = onKey.filter(s => s.mods.every(m => event[m]))
80
- let most = hits.reduce((n, s) => Math.max(n, s.mods.length), 0)
81
- for (let s of hits) if (s.mods.length === most) down.add(s.text)
82
- }
71
+ if (isDown) for (let s of mostSpecific(onKey, s => s.mods, event)) down.add(s.text)
83
72
  setCount(down.size)
84
73
  },
85
74
  blur() {
package/src/input-map.ts CHANGED
@@ -213,7 +213,8 @@ function checkValue(what: string, kind: ActionKind, v: unknown): void {
213
213
 
214
214
  function checkSource(source: unknown): InputSource {
215
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") {
216
+ // A pointer gesture is a callable source (`pointer.drag("Ctrl")` is its chord variant).
217
+ if (!s || (typeof s !== "object" && typeof s !== "function") || (s.kind !== "button" && s.kind !== "axis" && s.kind !== "vec2") || typeof s.label !== "string") {
217
218
  throw new Error(`createInputMap: not an input source: ${String(source)}`)
218
219
  }
219
220
  return s
@@ -33,6 +33,30 @@
33
33
  // Brackets fire on the press itself, before the recognizer's slop, so a
34
34
  // glide stops the moment a finger lands.
35
35
  //
36
+ // Chords: every source is also callable with a modifier spec, and
37
+ // `pointer.drag("Ctrl")` is the variant that fires only when the event
38
+ // opening the gesture carries Ctrl - and then instead of the bare `drag`,
39
+ // as the keyboard's most specific spec wins a key down (input-chord.ts,
40
+ // the one grammar for both devices). That is the every-viewport default,
41
+ // plain drag orbits and Ctrl-drag pans, as one binding each. A chord is
42
+ // resolved once per bracket, at the event that opens it (the down that
43
+ // starts a drag, the second down that starts pan/pinch/twist, the up
44
+ // that hands the gesture back to drag), and held for the bracket, so a
45
+ // modifier tapped mid-drag cannot split one gesture across two actions
46
+ // (Three decides at mousedown; Unreal's continuous chord splits). The
47
+ // unbracketed wheel and mouseDelta resolve per event. Touch carries no
48
+ // modifiers, so a chord binding is the desktop path and the bare one
49
+ // stays for fingers.
50
+ //
51
+ // Buttons: a bracketed spec may end in the button that opens it, as a
52
+ // key spec ends in its key - `pointer.drag("Right")`, `drag("Shift+Middle")`
53
+ // (Left, Middle, Right; bare means Left) - so Three's right-drag pan is
54
+ // a binding too. A button is a discriminator, not a modifier: a right
55
+ // drag feeds only Right specs, and the chord rule picks among those. One
56
+ // button per pointer: a second button pressed while the first is held
57
+ // joins nothing, and only the held button's release closes the gesture.
58
+ // The wheel and mouseDelta have no button; a button word on them throws.
59
+ //
36
60
  // The element's laid-out box normalizes the travel: read at the press
37
61
  // through getLayoutBox (the untransformed read, so a designSize fit or
38
62
  // an ancestor transform never skews it), or from `layout` when given -
@@ -49,6 +73,8 @@ import { pointerLocked } from "./window"
49
73
  import type { PointerEvent, WheelEvent } from "./types"
50
74
  import type { DeltaSink, InputSource } from "./input-map"
51
75
  import type { Vec2 } from "./input-axes"
76
+ import { chordName, mostSpecific, parseModifiers } from "./input-chord"
77
+ import type { Modified, Modifier } from "./input-chord"
52
78
 
53
79
  // Octaves per wheel-delta unit: the zoom exponent both cameras used
54
80
  // (0.0015 per unit) taken in log2, so a 100-unit notch zooms about a
@@ -60,6 +86,16 @@ const WHEEL_OCTAVES = 0.0015 / Math.LN2
60
86
  // turn is the ~0.002 rad/px of Three's PointerLockControls.
61
87
  const MOUSE_PX_PER_UNIT = 1500
62
88
 
89
+ /**
90
+ * A pointer gesture as a source. Bound as is, it fires on the primary
91
+ * button under any modifiers; called with a spec ("Ctrl", "Shift+Alt",
92
+ * "Right", "Shift+Middle") it is the variant that fires only when the
93
+ * event opening the gesture carries those modifiers and that button, and
94
+ * then instead of the bare one: the most specific bound chord wins. The
95
+ * same spec returns the same source, so unbind by identity works.
96
+ */
97
+ export type PointerSource<K extends "axis" | "vec2"> = InputSource<K> & ((chord: string) => InputSource<K>)
98
+
63
99
  export interface PointerFeed {
64
100
  /** Spread on the element whose pointer events feed the sources. */
65
101
  handlers: {
@@ -70,20 +106,20 @@ export interface PointerFeed {
70
106
  onWheel(event: WheelEvent): void
71
107
  }
72
108
  /** One pointer's travel, in element heights; bracketed. */
73
- drag: InputSource<"vec2">
109
+ drag: PointerSource<"vec2">
74
110
  /** Two or more pointers' focal travel, in element heights; bracketed. */
75
- pan: InputSource<"vec2">
111
+ pan: PointerSource<"vec2">
76
112
  /** Span change of two pointers, in octaves (spreading positive), with
77
113
  * the focal point; bracketed. */
78
- pinch: InputSource<"axis">
114
+ pinch: PointerSource<"axis">
79
115
  /** Rotation of the pointer pair, in turns, with the focal point; bracketed. */
80
- twist: InputSource<"axis">
116
+ twist: PointerSource<"axis">
81
117
  /** Wheel notches in octaves (wheel up positive), with the cursor as the
82
118
  * focal point; unbracketed. */
83
- wheel: InputSource<"axis">
119
+ wheel: PointerSource<"axis">
84
120
  /** Raw mouse motion while the pointer is locked, in view-height
85
121
  * equivalents (see MOUSE_PX_PER_UNIT); unbracketed. */
86
- mouseDelta: InputSource<"vec2">
122
+ mouseDelta: PointerSource<"vec2">
87
123
  }
88
124
 
89
125
  export interface PointerFeedOptions {
@@ -93,33 +129,86 @@ export interface PointerFeedOptions {
93
129
  layout?: () => { width: number; height: number } | null
94
130
  }
95
131
 
96
- type Sinks = Set<DeltaSink>
132
+ // Button names a bracketed spec may end in, to the event's button index.
133
+ const BUTTONS: Record<string, number> = { Left: 0, Middle: 1, Right: 2 }
134
+ const PRIMARY = 0
97
135
 
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)
136
+ // One gesture's sinks, bucketed by spec (the bare source is the empty
137
+ // chord on the primary button). A bracket opens the buckets of the
138
+ // button that opened it whose modifiers the opening event carries,
139
+ // narrowed to the most specific among those with a sink bound, and
140
+ // delivers to them until it closes; an unbracketed send resolves the
141
+ // same way per event. Sinks are live sets, as before chords: a consumer
142
+ // bound mid-gesture joins it.
143
+ type Bucket = { mods: Modifier[]; button: number; sinks: Set<DeltaSink>; source: InputSource }
144
+
145
+ function gesture<K extends "axis" | "vec2">(kind: K, label: string, buttons: boolean) {
146
+ let buckets = new Map<string, Bucket>()
147
+ let open: Set<DeltaSink>[] = []
148
+ let variant = (mods: Modifier[], button: number): InputSource<K> => {
149
+ let name = [chordName(mods), button === PRIMARY ? "" : Object.keys(BUTTONS)[button]!].filter(Boolean).join("+")
150
+ let bucket = buckets.get(name)
151
+ if (!bucket) {
152
+ let sinks = new Set<DeltaSink>()
153
+ let source: InputSource<K> = {
154
+ kind,
155
+ label: name ? `${label} (${name})` : label,
156
+ deltas(sink) {
157
+ sinks.add(sink)
158
+ return () => {
159
+ sinks.delete(sink)
160
+ }
161
+ },
106
162
  }
163
+ bucket = { mods, button, sinks, source }
164
+ buckets.set(name, bucket)
165
+ }
166
+ return bucket.source as InputSource<K>
167
+ }
168
+ let bare = variant([], PRIMARY)
169
+ let spec = (text: string): InputSource<K> => {
170
+ if (typeof text !== "string" || text.length === 0) throw new Error(`${label}: expected a spec such as "Ctrl", "Shift+Alt"${buttons ? ', "Right" or "Shift+Middle"' : ""}, got ${String(text)}`)
171
+ let parts = text.split("+")
172
+ let button = PRIMARY
173
+ if (parts[parts.length - 1]! in BUTTONS) {
174
+ if (!buttons) throw new Error(`${label}: has no button, but "${text}" names one`)
175
+ button = BUTTONS[parts.pop()!]!
176
+ }
177
+ return variant(parseModifiers(label, text, parts), button)
178
+ }
179
+ let source: PointerSource<K> = Object.assign(spec, { kind, label: bare.label, deltas: bare.deltas! })
180
+ let resolve = (event: Modified, button: number): Set<DeltaSink>[] =>
181
+ mostSpecific(
182
+ [...buckets.values()].filter(b => b.sinks.size > 0 && b.button === button),
183
+ b => b.mods,
184
+ event,
185
+ ).map(b => b.sinks)
186
+ return {
187
+ source,
188
+ begin(event: Modified, button: number) {
189
+ open = resolve(event, button)
190
+ for (let sinks of open) sinks.forEach(k => k.begin())
191
+ },
192
+ delta(value: number | Vec2, focal?: Vec2) {
193
+ for (let sinks of open) sinks.forEach(k => k.delta(value, focal))
194
+ },
195
+ end() {
196
+ for (let sinks of open) sinks.forEach(k => k.end())
197
+ open = []
198
+ },
199
+ send(event: Modified, value: number | Vec2, focal?: Vec2) {
200
+ for (let sinks of resolve(event, PRIMARY)) sinks.forEach(k => k.delta(value, focal))
107
201
  },
108
202
  }
109
203
  }
110
204
 
111
205
  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))
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)
123
212
 
124
213
  // The element's box, read at each press (see the header). A detached
125
214
  // leaf has no layout box and must bring `layout`: normalizing by
@@ -138,76 +227,80 @@ export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed
138
227
  let focalOf = (x: number, y: number): Vec2 | undefined => (box.width > 0 && box.height > 0 ? [x / box.width, y / box.height] : undefined)
139
228
 
140
229
  let transform = createTransform({
230
+ buttons: "any",
141
231
  onTransformMove: t => {
142
232
  let h = unit()
143
233
  let travel: Vec2 = [t.dx / h, t.dy / h]
144
234
  if (t.pointers >= 2) {
145
- if (t.dx !== 0 || t.dy !== 0) delta(sinks.pan, travel)
235
+ if (t.dx !== 0 || t.dy !== 0) pan.delta(travel)
146
236
  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)
237
+ if (t.scale !== 1) pinch.delta(Math.log2(t.scale), focal)
238
+ if (t.rotation !== 0) twist.delta(t.rotation / (2 * Math.PI), focal)
149
239
  } else if (t.dx !== 0 || t.dy !== 0) {
150
- delta(sinks.drag, travel)
240
+ drag.delta(travel)
151
241
  }
152
242
  },
153
243
  })
154
244
 
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)
245
+ // Pointers down on the element with the button that pressed them, for
246
+ // the gesture brackets: the first press opens drag, a second closes it
247
+ // and opens the two-pointer gestures, and the count coming back down
248
+ // reverses that. The event that opens a bracket resolves its chord,
249
+ // and the pointer it opens for supplies the button (the drag that
250
+ // resumes when a second pointer lifts belongs to the one still down).
251
+ let downs = new Map<number, number>()
252
+ let landed = (e: PointerEvent, button: number) => {
253
+ downs.set(e.pointerId, button)
254
+ if (downs.size === 1) drag.begin(e, button)
163
255
  if (downs.size === 2) {
164
- end(sinks.drag)
165
- begin(sinks.pan)
166
- begin(sinks.pinch)
167
- begin(sinks.twist)
256
+ drag.end()
257
+ pan.begin(e, button)
258
+ pinch.begin(e, button)
259
+ twist.begin(e, button)
168
260
  }
169
261
  }
170
- let lifted = (id: number) => {
171
- if (!downs.delete(id)) return
262
+ let lifted = (e: PointerEvent) => {
263
+ downs.delete(e.pointerId)
172
264
  if (downs.size === 1) {
173
- end(sinks.pan)
174
- end(sinks.pinch)
175
- end(sinks.twist)
176
- begin(sinks.drag)
265
+ pan.end()
266
+ pinch.end()
267
+ twist.end()
268
+ drag.begin(e, [...downs.values()][0]!)
177
269
  }
178
- if (downs.size === 0) end(sinks.drag)
270
+ if (downs.size === 0) drag.end()
179
271
  }
180
272
 
181
273
  return {
182
274
  handlers: {
183
275
  onPointerDown(e) {
184
- if (e.button != null && e.button !== 0) return
276
+ if (downs.has(e.pointerId)) return
185
277
  if (downs.size === 0) box = size(e)
186
- landed(e.pointerId)
278
+ landed(e, e.button ?? PRIMARY)
187
279
  transform.handlers.onPointerDown(e)
188
280
  },
189
281
  onPointerMove(e) {
190
282
  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])
283
+ mouseDelta.send(e, [e.movementX / MOUSE_PX_PER_UNIT, e.movementY / MOUSE_PX_PER_UNIT])
192
284
  }
193
285
  transform.handlers.onPointerMove(e)
194
286
  },
195
287
  onPointerUp(e) {
288
+ if (downs.get(e.pointerId) !== (e.button ?? PRIMARY)) return
196
289
  transform.handlers.onPointerUp(e)
197
- lifted(e.pointerId)
290
+ lifted(e)
198
291
  },
199
292
  onPointerLeave() {},
200
293
  onWheel(e) {
201
294
  if (e.deltaY === 0) return
202
295
  box = size(e)
203
- delta(sinks.wheel, -e.deltaY * WHEEL_OCTAVES, focalOf(e.localX, e.localY))
296
+ wheel.send(e, -e.deltaY * WHEEL_OCTAVES, focalOf(e.localX, e.localY))
204
297
  },
205
298
  },
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),
299
+ drag: drag.source,
300
+ pan: pan.source,
301
+ pinch: pinch.source,
302
+ twist: twist.source,
303
+ wheel: wheel.source,
304
+ mouseDelta: mouseDelta.source,
212
305
  }
213
306
  }
package/src/renderer.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createRoot, onCleanup, NotReadyError } from "@solidjs/signals"
1
+ import { createRoot, onCleanup, NotReadyError, untrack } from "@solidjs/signals"
2
2
  import { createRenderer } from "@solidjs/universal"
3
3
  import { createErrorBoundary } from "solid-js"
4
4
  import type { Element } from "solid-js"
@@ -51,9 +51,13 @@ let destroyScheduled = false
51
51
  // before dropping handlers so onBlur still fires for a focused descendant.
52
52
  function destroyNode(node: ProxyNode): void {
53
53
  tree.destroyNode(node.id)
54
+ // Read once, untracked: destroyNode runs inside effect apply phases (the
55
+ // root boundary's swap, the end-of-tick sweep), where a per-node signal
56
+ // read is a STRICT_READ_UNTRACKED warning per node.
57
+ let focused = untrack(focusedNode)
54
58
  let cleanup = (n: ProxyNode) => {
55
59
  for (let child of n.children) if (child.parent === n) cleanup(child)
56
- if (n.id === focusedNode()) setFocus(null)
60
+ if (n.id === focused) setFocus(null)
57
61
  nodes.delete(n.id)
58
62
  cleanupNode(n.id)
59
63
  }
@@ -288,6 +292,17 @@ let renderer = createRenderer<ProxyNode>({
288
292
  if (anchor) tree.insertNode(parent.id, node.id, anchor.id)
289
293
  else tree.insertNode(parent.id, node.id)
290
294
 
295
+ // A bare move - Solid reorders a <For> with insertBefore on a node
296
+ // that is still attached, no removeNode first - unlinks the node from
297
+ // wherever it was on the native side (a same-parent reorder included).
298
+ // The mirror must drop the old entry too, or it keeps both and
299
+ // getNextSibling hands Solid stale anchors from then on, so every
300
+ // later insert lands in the wrong place.
301
+ let previous = node.parent
302
+ if (previous) {
303
+ let at = previous.children.indexOf(node)
304
+ if (at !== -1) previous.children.splice(at, 1)
305
+ }
291
306
  node.parent = parent
292
307
 
293
308
  if (!anchor) {
@@ -66,12 +66,19 @@ declare module "srt:events" {
66
66
  // The running application's own surface (lattice), present in every build.
67
67
  declare module "srt:app" {
68
68
  /**
69
- * Leave the current app, unconditionally: back to the player in a dev
70
- * client, quit when standalone or at the player root (on Android the
71
- * client backgrounds instead of dying). The default action of an
72
- * unprevented `back` event; prefer the @solidrt/core re-export.
69
+ * End the current app now, without running the quit hooks: back to the
70
+ * player in a dev client, quit when standalone or at the player root (on
71
+ * Android the activity finishes). Prefer the @solidrt/core `exit`, which
72
+ * runs `onQuit` first.
73
73
  */
74
74
  export function exit(): void
75
+ /**
76
+ * Leave the current app without ending it: back to the player in a dev
77
+ * client; otherwise to the OS background (Android moveTaskToBack, a
78
+ * minimized window on desktop). Core's default action of an unprevented
79
+ * `back` on Android; exposed there as `background`.
80
+ */
81
+ export function background(): void
75
82
  }
76
83
 
77
84
  // Dev-server control surface (lattice). Present only in dev/go builds; in other
package/src/transform.ts CHANGED
@@ -47,6 +47,9 @@ export type TransformDelta = {
47
47
  }
48
48
 
49
49
  export interface TransformOptions {
50
+ /** Which mouse buttons open a gesture: the primary only (default), or
51
+ * any - the pointer feed's choice, which tells its buttons apart itself. */
52
+ buttons?: "primary" | "any"
50
53
  onTransformStart?: () => void
51
54
  /** Streams one delta per frame; compose them multiplicatively (scale) / additively (dx, dy, rotation). */
52
55
  onTransformMove?: (t: TransformDelta) => void
@@ -251,7 +254,7 @@ export function createTransform(options: TransformOptions) {
251
254
 
252
255
  let handlers = {
253
256
  onPointerDown: (e: PointerEvent) => {
254
- if (e.button != null && e.button !== 0) return
257
+ if (options.buttons !== "any" && e.button != null && e.button !== 0) return
255
258
  if (pointers.has(e.pointerId)) return
256
259
  if (active) {
257
260
  // A finger joining an established gesture belongs to it outright; if