@solidrt/core 0.0.66 → 0.0.67

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.
@@ -151,27 +151,29 @@ Rules, in order of leverage:
151
151
  snapshot instead of re-rasterizing. A window shader's output is invisible
152
152
  to get_snapshot and every other MCP tool; `bun run sol render` is the only
153
153
  way to see it (see @solidrt/cli AGENTS.md).
154
- 8. `flux:wasm` runs a pure interpreter (wasmi, no JIT), so temper browser
155
- expectations - but do not write it off for compute. A genuinely numeric
156
- kernel (typed-array math, tight inner loops, no host calls inside the
157
- loop) compiled from a systems language can come out a real multiple
158
- faster than the same loop in interpreted JavaScript, and when profiling
159
- shows such a kernel is what the app is spending its time on, that
160
- multiple is worth having: measure the JS loop, port the kernel, measure
161
- again, keep whichever wins. What wasm does not do is speed up
162
- render-path work (rules 1-3 are that leverage), and every host call
163
- costs marshalling, so batch at the boundary - one call over a byte
164
- buffer, not a call per element. It is also the way to ship one compiled
165
- module across every target with no native toolchain, and it pairs with
166
- an isolate when a call runs long enough to block.
167
- 9. `flux:ffi` (dlopen of a native library) is a binding tool, not a
168
- performance tool. It needs a shared library compiled per platform and
169
- architecture and shipped under each target's packing rules (Android
170
- loads only what arrives inside the APK as a lib*.so), so reaching for
171
- it "to make something fast" buys a build-and-packaging problem on every
172
- platform the app targets. Use it when the app must call a native
173
- library that already exists and already ships for those targets; for
174
- speed, everything above comes first.
154
+ 8. `flux:wasm` compiles a module to machine code on devices that allow it
155
+ (`Flux.capabilities` lists `wasm-native`: desktop, 64-bit Android) and
156
+ interprets it elsewhere (iOS, 32-bit Android). Both lanes are the fast
157
+ lane for compute: measured on a desktop, an arm64 tablet and a 32-bit
158
+ TV, a numeric kernel (typed-array math, tight inner loops, no host calls
159
+ inside the loop) runs 18x to 960x faster than the same loop in
160
+ JavaScript when compiled and 4x to 88x when interpreted, float at the low
161
+ end, integer and SIMD-shaped loops at the high end. So when profiling
162
+ shows such a kernel is where the app spends its time, port it: measure
163
+ the JS loop, port the kernel, measure again, keep whichever wins. The
164
+ boundary is the cost, not the code: a host call out of the module costs
165
+ 3x to 7x a plain JavaScript call, two to three hundred iterations of a
166
+ tight loop, so batch at the boundary - one call per frame over a byte
167
+ buffer, never a call per element. What wasm does not do is speed up
168
+ render-path work (rules 1-3 are that leverage). It is also the way to
169
+ ship one compiled module across every target with no native toolchain,
170
+ and it pairs with an isolate when a call runs long enough to block.
171
+ 9. There is no native-library route from app code. A shared library is
172
+ compiled per platform, so it cannot travel in a portable app payload,
173
+ and Android loads only what arrived inside the APK. Binding one is a
174
+ custom runtime build: the runtime crates with the library registered
175
+ beside the built-in modules. For speed, everything above comes first;
176
+ for a proven library that has a wasm build, rule 8.
175
177
  10. Text costs shaping, once per word per style, through a shared word cache;
176
178
  line breaking is arithmetic over the shaped words. So re-breaking text
177
179
  every frame with prepareText + layoutNextLine (a shape that breathes, an
@@ -188,7 +190,7 @@ Rules, in order of leverage:
188
190
  ## Isolates: heavy work off the JS thread
189
191
 
190
192
  A long synchronous computation (a big parse, a simulation step, a blocking
191
- `flux:ffi`/`flux:wasm` call) freezes rendering and input for its duration.
193
+ `flux:wasm` call) freezes rendering and input for its duration.
192
194
  Move it into an isolate module: a file whose first statement is the
193
195
  `"use isolate"` directive runs in a second runtime on its own thread, and
194
196
  main calls its exports as async functions.
package/docs/index.md CHANGED
@@ -91,9 +91,13 @@ ancestor with `position="relative"`.
91
91
  ## Input
92
92
 
93
93
  Pointer, wheel, and key events are props on any element:
94
- `onPointerDown`, `onPointerMove`, `onPointerUp`, `onPointerEnter`,
95
- `onPointerLeave`, `onWheel`, `onKeyDown`, `onKeyUp`. Events travel from the
96
- hit leaf up to the root, and `stopPropagation()` ends the walk. Key events
94
+ `onPointerDown`, `onPointerMove`, `onPointerUp`, `onPointerCancel`,
95
+ `onPointerEnter`, `onPointerLeave`, `onWheel`, `onKeyDown`, `onKeyUp`.
96
+ Events travel from the hit leaf up to the root, and `stopPropagation()`
97
+ ends the walk. A pointer ends with an up or a cancel, never both: the
98
+ cancel is the system taking the touch away (a back swipe, palm rejection),
99
+ delivered along the pressed path with the pointer's last position, so a
100
+ press ends without firing and a drag ends without a fling. Key events
97
101
  bubble the same way, starting at the focused node - or at the window root
98
102
  when nothing is focused, so `onKeyDown` on the window is where app-global
99
103
  shortcuts live.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.66",
3
+ "version": "0.0.67",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -33,8 +33,8 @@
33
33
  "AGENTS.md"
34
34
  ],
35
35
  "devDependencies": {
36
- "@solidrt/flux-types": "0.0.66",
37
- "@solidrt/test": "0.0.66"
36
+ "@solidrt/flux-types": "0.0.67",
37
+ "@solidrt/test": "0.0.67"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@solidjs/signals": "2.0.0-rc.13",
package/src/core.ts CHANGED
@@ -8,8 +8,9 @@ let handlers = new Map<number, Map<string, Function>>()
8
8
  // Pointer-handler presence mirrored into the render tree, so the runtime can
9
9
  // skip building deliveries that would reach nobody (moves over static content
10
10
  // are the flood case). Bits match alloy's EventInterest (rendertree/hit.rs);
11
- // keep the two in sync. Down/up are recorded but the runtime never gates
12
- // them: focus and gesture side effects hang off them regardless of handlers.
11
+ // keep the two in sync. Down, up and cancel are recorded but the runtime
12
+ // never gates them: focus and gesture side effects hang off them regardless
13
+ // of handlers.
13
14
  const MOVE_BIT = 1
14
15
  const POINTER_INTEREST: Record<string, number> = {
15
16
  onPointerMove: MOVE_BIT,
@@ -18,6 +19,7 @@ const POINTER_INTEREST: Record<string, number> = {
18
19
  onPointerEnter: 8,
19
20
  onPointerLeave: 16,
20
21
  onWheel: 32,
22
+ onPointerCancel: 64,
21
23
  }
22
24
 
23
25
  let interests = new Map<number, number>()
package/src/double-tap.ts CHANGED
@@ -48,8 +48,8 @@ export interface TapSequenceOptions {
48
48
  }
49
49
 
50
50
  /** The tap bookkeeping under a double-tap: one first tap at a time,
51
- * decided by the second down, by travel, by a long hold, by the window
52
- * timer or by cancel(). */
51
+ * decided by the second down, by travel, by a long hold, by a cancelled
52
+ * pointer, by the window timer or by cancel(). */
53
53
  export function createTapSequence(options: TapSequenceOptions) {
54
54
  let first: { id: number; at: PointerPoint; downAt: number; upAt: number | null } | null = null
55
55
  let timer: ReturnType<typeof setTimeout> | null = null
@@ -99,6 +99,9 @@ export function createTapSequence(options: TapSequenceOptions) {
99
99
  settle(false)
100
100
  }, DOUBLE_TAP_MS)
101
101
  },
102
+ cancelled(e: PointerEvent) {
103
+ if (first && first.id === e.pointerId) settle(false)
104
+ },
102
105
  cancel: () => settle(false),
103
106
  }
104
107
  }
@@ -145,6 +148,10 @@ export function createDoubleTap(options: DoubleTapOptions) {
145
148
  if (second === e.pointerId) release()
146
149
  taps.up(e)
147
150
  },
151
+ onPointerCancel: (e: PointerEvent) => {
152
+ if (second === e.pointerId) release()
153
+ taps.cancelled(e)
154
+ },
148
155
  }
149
156
  return { handlers, cancel }
150
157
  }
@@ -133,6 +133,7 @@ export interface PointerFeed extends InputDevice {
133
133
  onPointerDown(event: PointerEvent): void
134
134
  onPointerMove(event: PointerEvent): void
135
135
  onPointerUp(event: PointerEvent): void
136
+ onPointerCancel(event: PointerEvent): void
136
137
  onPointerLeave(event: PointerEvent): void
137
138
  onWheel(event: WheelEvent): void
138
139
  }
@@ -481,6 +482,17 @@ export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed
481
482
  opening = null
482
483
  }
483
484
  },
485
+ onPointerCancel(e) {
486
+ if (!downs.has(e.pointerId)) return
487
+ // The brackets close with no velocity and no swipe: the pointer
488
+ // ended without its lift.
489
+ transform.handlers.onPointerCancel(e)
490
+ released = null
491
+ lifted(e)
492
+ hold.cancelled(e)
493
+ taps.cancelled(e)
494
+ if (opening && opening.id === e.pointerId) opening = null
495
+ },
484
496
  onPointerLeave() {},
485
497
  onWheel(e) {
486
498
  if (e.deltaY === 0) return
package/src/long-press.ts CHANGED
@@ -36,7 +36,8 @@ export interface HoldTimerOptions {
36
36
  }
37
37
 
38
38
  /** The arm / slop / timer machine under a long-press: one pointer at a
39
- * time, disarmed by travel, by a lift, by a second pointer or by cancel(). */
39
+ * time, disarmed by travel, by a lift, by a cancelled pointer, by a second
40
+ * pointer or by cancel(). */
40
41
  export function createHoldTimer(options: HoldTimerOptions) {
41
42
  let armed: { id: number; at: PointerPoint; mods: { shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean; button?: number } } | null = null
42
43
  let timer: ReturnType<typeof setTimeout> | null = null
@@ -69,6 +70,9 @@ export function createHoldTimer(options: HoldTimerOptions) {
69
70
  up(e: PointerEvent) {
70
71
  if (armed && armed.id === e.pointerId) disarm()
71
72
  },
73
+ cancelled(e: PointerEvent) {
74
+ if (armed && armed.id === e.pointerId) disarm()
75
+ },
72
76
  cancel: disarm,
73
77
  }
74
78
  }
@@ -84,6 +88,10 @@ export interface LongPressOptions {
84
88
  onLongPressMove?: (dx: number, dy: number) => void
85
89
  /** The lift after a fire. */
86
90
  onLongPressEnd?: () => void
91
+ /** A fired long press ended without its lift: the system cancelled the
92
+ * pointer (onPointerCancel), or another recognizer took it in the
93
+ * arena. Fired instead of onLongPressEnd. */
94
+ onLongPressCancel?: () => void
87
95
  }
88
96
 
89
97
  export function createLongPress(options: LongPressOptions) {
@@ -99,16 +107,26 @@ export function createLongPress(options: LongPressOptions) {
99
107
  options.onLongPress?.(at)
100
108
  },
101
109
  })
102
- let cancel = () => {
103
- hold.cancel()
110
+ let release = () => {
104
111
  if (fired !== null) {
105
112
  arena.release(fired, owner)
106
113
  fired = null
107
114
  last = null
108
115
  }
109
116
  }
117
+ // The press ends without its lift, whatever took the pointer: the arena
118
+ // (cancel) or the system (onPointerCancel). An unmount releases alone.
119
+ let cancel = () => {
120
+ hold.cancel()
121
+ let won = fired !== null
122
+ release()
123
+ if (won) options.onLongPressCancel?.()
124
+ }
110
125
  let owner: ArenaOwner = { cancel }
111
- onSettled(() => cancel)
126
+ onSettled(() => () => {
127
+ hold.cancel()
128
+ release()
129
+ })
112
130
 
113
131
  let handlers = {
114
132
  onPointerDown: (e: PointerEvent) => {
@@ -126,12 +144,16 @@ export function createLongPress(options: LongPressOptions) {
126
144
  },
127
145
  onPointerUp: (e: PointerEvent) => {
128
146
  if (fired === e.pointerId) {
129
- cancel()
147
+ release()
130
148
  options.onLongPressEnd?.()
131
149
  return
132
150
  }
133
151
  hold.up(e)
134
152
  },
153
+ onPointerCancel: (e: PointerEvent) => {
154
+ if (fired === e.pointerId) cancel()
155
+ else hold.cancelled(e)
156
+ },
135
157
  }
136
158
  return { handlers, cancel }
137
159
  }
package/src/pan.ts CHANGED
@@ -35,6 +35,13 @@ export interface PanOptions {
35
35
  * finger rested before lifting or moved under FLING_MIN_VELOCITY.
36
36
  */
37
37
  onPanEnd?: (velocity: Velocity) => void
38
+ /**
39
+ * The pan ended without its result: the system cancelled the pointer
40
+ * (onPointerCancel), or another recognizer took it in the arena. Fired
41
+ * instead of onPanEnd, only after onPanStart; whoever animates on
42
+ * settles here, nothing flings.
43
+ */
44
+ onPanCancel?: () => void
38
45
  }
39
46
 
40
47
  // The pan recognizer: turns a drag into a movement-delta stream. On a down it
@@ -49,8 +56,9 @@ export interface PanOptions {
49
56
  // fling minimum) for momentum. Moves and the up arrive on the frozen down
50
57
  // path, so an active pan keeps streaming when the pointer leaves the node
51
58
  // or the window.
52
- // cancel() is the external-cancel hook; it ends an active pan without
53
- // onPanEnd. Options are read at event time. Single-pointer by design; for
59
+ // cancel() is the external-cancel hook, the arena's; it and a pointer cancel
60
+ // end an active pan with onPanCancel instead of onPanEnd. Options are read
61
+ // at event time. Single-pointer by design; for
54
62
  // multi-pointer pinch/rotate compose createTransform instead.
55
63
  //
56
64
  // Frames: the slop is finger travel, so it is measured in window pixels
@@ -87,7 +95,13 @@ export function createPan(options: PanOptions) {
87
95
  armed = null
88
96
  origin = null
89
97
  }
90
- let cancel = reset
98
+ // The pan ends without its result, whatever took the pointer: the arena
99
+ // (cancel) or the system (onPointerCancel). An unmount resets alone.
100
+ let cancel = () => {
101
+ let started = active != null
102
+ reset()
103
+ if (started) options.onPanCancel?.()
104
+ }
91
105
  let owner = { cancel }
92
106
 
93
107
  // An unmount mid-drag must not leave a resolved claim behind.
@@ -134,6 +148,10 @@ export function createPan(options: PanOptions) {
134
148
  reset()
135
149
  }
136
150
  },
151
+ onPointerCancel: (e: PointerEvent) => {
152
+ if (active === e.pointerId) cancel()
153
+ else if (armed === e.pointerId) reset()
154
+ },
137
155
  }
138
156
 
139
157
  return { handlers, cancel }
@@ -26,7 +26,8 @@ declare module "*.frag" {
26
26
 
27
27
  // Binary asset imports: `import data from "./pic.png" with { type: "binary" }`.
28
28
  // The bundler inlines the file's bytes as a Uint8Array (see packages/cli
29
- // bundler `inlineImport`); feed it straight into createImage/decodeImage.
29
+ // bundler `inlineImport`); feed it straight into createImage/decodeImage, or a
30
+ // `.wasm` into `new Module` from flux:wasm.
30
31
  declare module "*.png" {
31
32
  const bytes: Uint8Array
32
33
  export default bytes
@@ -55,6 +56,10 @@ declare module "*.sol3m" {
55
56
  const bytes: Uint8Array
56
57
  export default bytes
57
58
  }
59
+ declare module "*.wasm" {
60
+ const bytes: Uint8Array
61
+ export default bytes
62
+ }
58
63
 
59
64
  // UI event bus (lattice), provided by the runtime as a builtin module.
60
65
  // on/once return an unsubscribe function. Notable events: the routed pointer
package/src/swipe.ts CHANGED
@@ -67,6 +67,10 @@ export interface SwipeOptions {
67
67
  onSwipe?: (direction: SwipeDirection, velocity: Velocity) => void
68
68
  /** Every lift of an active swipe, qualified or not. */
69
69
  onSwipeEnd?: (velocity: Velocity) => void
70
+ /** An active swipe ended without its lift: the system cancelled the
71
+ * pointer (onPointerCancel), or another recognizer took it in the
72
+ * arena. Fired instead of onSwipe and onSwipeEnd. */
73
+ onSwipeCancel?: () => void
70
74
  }
71
75
 
72
76
  export function createSwipe(options: SwipeOptions) {
@@ -97,6 +101,11 @@ export function createSwipe(options: SwipeOptions) {
97
101
  if (direction) options.onSwipe?.(direction, velocity)
98
102
  options.onSwipeEnd?.(velocity)
99
103
  },
104
+ onPanCancel: () => {
105
+ down = null
106
+ lift = null
107
+ options.onSwipeCancel?.()
108
+ },
100
109
  })
101
110
 
102
111
  let handlers = {
@@ -120,6 +129,13 @@ export function createSwipe(options: SwipeOptions) {
120
129
  lift = null
121
130
  }
122
131
  },
132
+ onPointerCancel: (e: PointerEvent) => {
133
+ pan.handlers.onPointerCancel(e)
134
+ if (down && down.id === e.pointerId) {
135
+ down = null
136
+ lift = null
137
+ }
138
+ },
123
139
  }
124
140
  let cancel = () => {
125
141
  pan.cancel()
package/src/transform.ts CHANGED
@@ -59,6 +59,10 @@ export interface TransformOptions {
59
59
  * parent-frame pixels per second (velocity.ts; zero after a rest or
60
60
  * under the fling minimum). */
61
61
  onTransformEnd?: (velocity: Velocity) => void
62
+ /** The gesture ended without its result: the system cancelled a finger
63
+ * (onPointerCancel), or another recognizer took the pointers in the
64
+ * arena. Fired instead of onTransformEnd, only after onTransformStart. */
65
+ onTransformCancel?: () => void
62
66
  }
63
67
 
64
68
  // The merged transform recognizer: pan + pinch + rotate as ONE gesture over
@@ -116,7 +120,8 @@ export interface TransformOptions {
116
120
  // configuration, so the change itself never emits a jump delta. The gesture
117
121
  // ends when the last finger lifts. Moves and ups arrive on the frozen down
118
122
  // path, so an active transform survives leaving the node or the window.
119
- // cancel() is the external-cancel hook; it ends an active gesture without
123
+ // cancel() is the external-cancel hook, the arena's; it and a cancelled
124
+ // finger end an active gesture with onTransformCancel instead of
120
125
  // onTransformEnd. Options are read at event time.
121
126
  //
122
127
  // Frames: the slop, the span filter and its rate gates are finger-travel
@@ -206,7 +211,11 @@ export function createTransform(options: TransformOptions) {
206
211
  rebase = false
207
212
  tracker.reset()
208
213
  }
209
- let cancel = reset
214
+ let cancel = () => {
215
+ let started = active
216
+ reset()
217
+ if (started) options.onTransformCancel?.()
218
+ }
210
219
  let owner: ArenaOwner = { cancel }
211
220
 
212
221
  // The per-frame measure point: runs at the pointerFrame terminator, when
@@ -355,6 +364,17 @@ export function createTransform(options: TransformOptions) {
355
364
  pointers.delete(e.pointerId)
356
365
  ref = pointers.size > 0 ? measure() : null
357
366
  },
367
+ onPointerCancel: (e: PointerEvent) => {
368
+ if (!pointers.has(e.pointerId)) return
369
+ // The system cancels the gesture, not a finger: every platform
370
+ // cancels all of them together, so the whole gesture ends.
371
+ if (active) {
372
+ cancel()
373
+ return
374
+ }
375
+ pointers.delete(e.pointerId)
376
+ ref = pointers.size > 0 ? measure() : null
377
+ },
358
378
  }
359
379
 
360
380
  return { handlers, cancel }
package/src/types.d.ts CHANGED
@@ -278,7 +278,8 @@ export interface TransformProps {
278
278
 
279
279
  // Window-relative pointer coordinates are reported as clientX/clientY (matching
280
280
  // the DOM MouseEvent). pointerType distinguishes mouse from touch; button is the
281
- // pressed button on down/up (0 = primary); the modifier flags mirror the DOM.
281
+ // pressed button on down/up (0 = primary) and absent on a cancel; the modifier
282
+ // flags mirror the DOM.
282
283
  export interface PointerEvent {
283
284
  /**
284
285
  * When the event happened, in milliseconds. Every input event carries one,
@@ -408,6 +409,15 @@ export interface NodeProps {
408
409
  export interface PointerProps extends NodeProps {
409
410
  onPointerDown?: (event: PointerEvent) => void
410
411
  onPointerUp?: (event: PointerEvent) => void
412
+ /**
413
+ * The system took the pointer away: a system gesture (the back swipe,
414
+ * the notification shade), palm rejection, the window losing the touch.
415
+ * The last event for that pointer, and no up follows it, so a press
416
+ * ends without firing and a drag ends without a fling. Routed along the
417
+ * pressed path like an up, with the pointer's last known position and
418
+ * no `button`.
419
+ */
420
+ onPointerCancel?: (event: PointerEvent) => void
411
421
  onPointerMove?: (event: PointerEvent) => void
412
422
  onPointerEnter?: (event: PointerEvent) => void
413
423
  onPointerLeave?: (event: PointerEvent) => void
package/src/window.ts CHANGED
@@ -553,6 +553,7 @@ export function attachWindow(nodeId: number) {
553
553
  let unsubscribe: () => void = null!
554
554
  let unsubDown: () => void = null!
555
555
  let unsubUp: () => void = null!
556
+ let unsubCancel: () => void = null!
556
557
  let unsubMove: () => void = null!
557
558
  let unsubEnter: () => void = null!
558
559
  let unsubLeave: () => void = null!
@@ -664,6 +665,10 @@ export function attachWindow(nodeId: number) {
664
665
  bubble(raw, "onPointerUp")
665
666
  })
666
667
 
668
+ unsubCancel = on("pointerCancel", (raw: RawPointer) => {
669
+ bubble(raw, "onPointerCancel")
670
+ })
671
+
667
672
  unsubMove = on("pointerMove", (raw: RawPointer) => {
668
673
  bubble(raw, "onPointerMove")
669
674
  })
@@ -761,6 +766,7 @@ export function attachWindow(nodeId: number) {
761
766
  if (unsubscribe) unsubscribe()
762
767
  if (unsubDown) unsubDown()
763
768
  if (unsubUp) unsubUp()
769
+ if (unsubCancel) unsubCancel()
764
770
  if (unsubMove) unsubMove()
765
771
  if (unsubEnter) unsubEnter()
766
772
  if (unsubLeave) unsubLeave()