@solidrt/core 0.0.60 → 0.0.62

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,137 @@
1
+ // The long-press recognizer: a pointer held LONG_PRESS_MS without leaving
2
+ // TAP_SLOP of its down fires while still down, then streams moves until
3
+ // the lift (drag-after-long-press is the reorder idiom: the finger that
4
+ // held a row now carries it).
5
+ //
6
+ // Arena: the down arms silently, no claim. A claim at the down would be
7
+ // refused wherever a press already holds the pointer (the same node, or
8
+ // an inner pressable), and the wanted behaviour there is the press
9
+ // showing pressed and the long-press winning at the timer, so the steal
10
+ // happens at the timer instead: the press is cancelled (its feedback
11
+ // retracts, it never fires), an ancestor pan can no longer take the
12
+ // finger, moves stream to the consumer. A pan that crossed its slop first
13
+ // resolved the arena, so the timer's steal is refused and the recognizer
14
+ // stands down: scrolling a list of long-pressable rows scrolls. Nested
15
+ // long-presses resolve innermost-first for free: the inner timer was
16
+ // registered first in the leaf-to-root walk. Options are read at event
17
+ // time; cancel() retracts without an end; an unmount clears the timer and
18
+ // the claim. Mouse and touch alike (a held button long-presses).
19
+ //
20
+ // The hold timer itself (createHoldTimer) is shared with the pointer
21
+ // feed's longPress source, which pulses instead of stealing: the feed is
22
+ // one arena claimant (its transform) and must stay so.
23
+
24
+ import { onSettled } from "@solidjs/signals"
25
+ import type { PointerEvent } from "./types"
26
+ import { arena, type ArenaOwner } from "./arena"
27
+ import { pastSlop, pointAt, type PointerPoint } from "./gesture"
28
+
29
+ // Hold before a long-press fires: Flutter, iOS and Android's upper value.
30
+ const LONG_PRESS_MS = 500
31
+
32
+ export interface HoldTimerOptions {
33
+ /** Read when the timer is armed. */
34
+ ms?: () => number | undefined
35
+ onFire: (pointerId: number, at: PointerPoint, event: { shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean; button?: number }) => void
36
+ }
37
+
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(). */
40
+ export function createHoldTimer(options: HoldTimerOptions) {
41
+ let armed: { id: number; at: PointerPoint; mods: { shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean; button?: number } } | null = null
42
+ let timer: ReturnType<typeof setTimeout> | null = null
43
+ let disarm = () => {
44
+ if (timer !== null) clearTimeout(timer)
45
+ timer = null
46
+ armed = null
47
+ }
48
+ return {
49
+ /** The pointer armed right now, if any. */
50
+ armed: () => armed?.id ?? null,
51
+ down(e: PointerEvent) {
52
+ if (armed) {
53
+ // A second finger is not a long-press.
54
+ disarm()
55
+ return
56
+ }
57
+ let ms = options.ms?.() ?? LONG_PRESS_MS
58
+ armed = { id: e.pointerId, at: pointAt(e), mods: { shiftKey: e.shiftKey, ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey, button: e.button } }
59
+ timer = setTimeout(() => {
60
+ timer = null
61
+ let a = armed!
62
+ armed = null
63
+ options.onFire(a.id, a.at, a.mods)
64
+ }, ms)
65
+ },
66
+ move(e: PointerEvent) {
67
+ if (armed && armed.id === e.pointerId && pastSlop(e, armed.at)) disarm()
68
+ },
69
+ up(e: PointerEvent) {
70
+ if (armed && armed.id === e.pointerId) disarm()
71
+ },
72
+ cancel: disarm,
73
+ }
74
+ }
75
+
76
+ export interface LongPressOptions {
77
+ /** Hold before the fire, default 500 ms. */
78
+ ms?: number
79
+ /** Fired at the timer while the pointer is still down, with the down's
80
+ * position. */
81
+ onLongPress?: (at: PointerPoint) => void
82
+ /** Movement since the previous event after the fire, in the handler
83
+ * node's parent frame (createPan's frame rule). */
84
+ onLongPressMove?: (dx: number, dy: number) => void
85
+ /** The lift after a fire. */
86
+ onLongPressEnd?: () => void
87
+ }
88
+
89
+ export function createLongPress(options: LongPressOptions) {
90
+ // The pointer the long-press won, and its last delivered parent position.
91
+ let fired: number | null = null
92
+ let last: { x: number; y: number } | null = null
93
+ let hold = createHoldTimer({
94
+ ms: () => options.ms,
95
+ onFire: (id, at) => {
96
+ if (!arena.steal(id, owner)) return
97
+ fired = id
98
+ last = { x: at.parentX, y: at.parentY }
99
+ options.onLongPress?.(at)
100
+ },
101
+ })
102
+ let cancel = () => {
103
+ hold.cancel()
104
+ if (fired !== null) {
105
+ arena.release(fired, owner)
106
+ fired = null
107
+ last = null
108
+ }
109
+ }
110
+ let owner: ArenaOwner = { cancel }
111
+ onSettled(() => cancel)
112
+
113
+ let handlers = {
114
+ onPointerDown: (e: PointerEvent) => {
115
+ if (e.button != null && e.button !== 0) return
116
+ if (fired !== null) return
117
+ hold.down(e)
118
+ },
119
+ onPointerMove: (e: PointerEvent) => {
120
+ if (fired === e.pointerId && last) {
121
+ options.onLongPressMove?.(e.parentX - last.x, e.parentY - last.y)
122
+ last = { x: e.parentX, y: e.parentY }
123
+ return
124
+ }
125
+ hold.move(e)
126
+ },
127
+ onPointerUp: (e: PointerEvent) => {
128
+ if (fired === e.pointerId) {
129
+ cancel()
130
+ options.onLongPressEnd?.()
131
+ return
132
+ }
133
+ hold.up(e)
134
+ },
135
+ }
136
+ return { handlers, cancel }
137
+ }
package/src/pan.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { onSettled } from "@solidjs/signals"
2
2
  import type { PointerEvent } from "./types"
3
3
  import { arena } from "./arena"
4
+ import { createVelocityTracker, flingVelocity } from "./velocity"
5
+ import type { Velocity } from "./velocity"
4
6
 
5
7
  // Movement in logical pixels before a pan activates. Below this a drag still
6
8
  // reads as a press (tap wiggle); crossing it is the positive evidence that the
@@ -26,7 +28,13 @@ export interface PanOptions {
26
28
  * Positive dx is rightward, positive dy downward.
27
29
  */
28
30
  onPanMove?: (dx: number, dy: number) => void
29
- onPanEnd?: () => void
31
+ /**
32
+ * The lift, with the pointer's velocity at it in the same parent-frame
33
+ * pixels per second (a least-squares fit over the last 100 ms, see
34
+ * velocity.ts): the fling fact for whoever animates on. Zero when the
35
+ * finger rested before lifting or moved under FLING_MIN_VELOCITY.
36
+ */
37
+ onPanEnd?: (velocity: Velocity) => void
30
38
  }
31
39
 
32
40
  // The pan recognizer: turns a drag into a movement-delta stream. On a down it
@@ -36,8 +44,11 @@ export interface PanOptions {
36
44
  // resolving it so no other recognizer can take the drag over. If the arena is
37
45
  // already resolved (an inner pan won first) the recognizer disarms and stays
38
46
  // out. The slop distance itself is swallowed: deltas stream from the
39
- // activation point on. Moves and the up arrive on the frozen down path, so an
40
- // active pan keeps streaming when the pointer leaves the node or the window.
47
+ // activation point on, and the up carries the lift velocity (velocity.ts: a
48
+ // fit over the last 100 ms of positions, zero after a rest or under the
49
+ // fling minimum) for momentum. Moves and the up arrive on the frozen down
50
+ // path, so an active pan keeps streaming when the pointer leaves the node
51
+ // or the window.
41
52
  // cancel() is the external-cancel hook; it ends an active pan without
42
53
  // onPanEnd. Options are read at event time. Single-pointer by design; for
43
54
  // multi-pointer pinch/rotate compose createTransform instead.
@@ -55,6 +66,8 @@ export function createPan(options: PanOptions) {
55
66
  let origin: { x: number; y: number } | null = null
56
67
  let active: number | null = null
57
68
  let armed: number | null = null
69
+ // Parent-frame positions of the active pointer, for the lift's velocity.
70
+ let tracker = createVelocityTracker()
58
71
 
59
72
  let past = (e: PointerEvent) => {
60
73
  if (!origin) return false
@@ -94,6 +107,8 @@ export function createPan(options: PanOptions) {
94
107
  active = e.pointerId
95
108
  armed = null
96
109
  origin = { x: e.parentX, y: e.parentY }
110
+ tracker.reset()
111
+ tracker.push(e.parentX, e.parentY)
97
112
  options.onPanStart?.()
98
113
  } else {
99
114
  // The arena is resolved against us; the drag belongs elsewhere.
@@ -102,14 +117,18 @@ export function createPan(options: PanOptions) {
102
117
  return
103
118
  }
104
119
  if (active === e.pointerId && origin) {
120
+ tracker.push(e.parentX, e.parentY)
105
121
  options.onPanMove?.(e.parentX - origin.x, e.parentY - origin.y)
106
122
  origin = { x: e.parentX, y: e.parentY }
107
123
  }
108
124
  },
109
125
  onPointerUp: (e: PointerEvent) => {
110
126
  if (active === e.pointerId) {
127
+ // The up is not a sample: it sits where the last move left the
128
+ // pointer, and its age is what tells a rest from a flick.
129
+ let velocity = flingVelocity(tracker.velocity())
111
130
  reset()
112
- options.onPanEnd?.()
131
+ options.onPanEnd?.(velocity)
113
132
  } else if (armed === e.pointerId) {
114
133
  reset()
115
134
  }
package/src/renderer.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { createRoot, onCleanup, NotReadyError, untrack } from "@solidjs/signals"
1
+ // createErrorBoundary comes from signals, not solid-js: solid-js marks it
2
+ // @internal (an app writes <Errored>, a renderer reaches for the primitive)
3
+ // and strips it from its .d.ts, so the solid-js re-export types as any.
4
+ import { createErrorBoundary, createRoot, onCleanup, NotReadyError, untrack } from "@solidjs/signals"
2
5
  import { createRenderer } from "@solidjs/universal"
3
- import { createErrorBoundary } from "solid-js"
4
6
  import type { Element } from "solid-js"
5
7
  import * as tree from "flux:rendertree"
6
8
  import { attachWindow, setWindowRoot } from "./window"
@@ -116,6 +118,10 @@ function removeNode(parent: ProxyNode, node: ProxyNode): void {
116
118
  const SENTINEL_INTERVAL_MS = 5000
117
119
  let sentinelDue = 0
118
120
  let warnedLeakTypes = new Set<string>()
121
+ // The orphan total's order of magnitude (floor of log10) at the last
122
+ // warning: a leak that keeps growing at a stable set of types warns again
123
+ // each time the total crosses the next power of ten.
124
+ let warnedMagnitude = -1
119
125
 
120
126
  export function scanForOrphans(now: number): void {
121
127
  if (!import.meta.env.DEV) return
@@ -129,18 +135,22 @@ export function scanForOrphans(now: number): void {
129
135
  counts.set(node.elementType, (counts.get(node.elementType) ?? 0) + 1)
130
136
  }
131
137
  if (total === 0) return
132
- // Warn only when a new element type joins the orphans, but list every type
133
- // with its count so the breakdown always adds up to the total.
138
+ // Warn when a new element type joins the orphans or the total crosses the
139
+ // next order of magnitude, but list every type with its count so the
140
+ // breakdown always adds up to the total.
134
141
  let fresh = [...counts].filter(([type]) => !warnedLeakTypes.has(type))
135
- if (fresh.length === 0) return
142
+ let magnitude = Math.floor(Math.log10(total))
143
+ if (fresh.length === 0 && magnitude <= warnedMagnitude) return
136
144
  for (let [type] of fresh) warnedLeakTypes.add(type)
145
+ warnedMagnitude = magnitude
137
146
  let list = [...counts].map(([type, n]) => `<${type}> x${n}`).join(", ")
138
147
  console.warn(
139
148
  `Leak sentinel: ${total} nodes are unreachable and will never be freed: ${list}. ` +
140
149
  `The usual cause is reading an element-valued prop more than once (every read ` +
141
150
  `builds a new subtree); read it once where it mounts, or resolve it with ` +
142
151
  `children(). If these nodes are intentionally kept for later mounting, ignore ` +
143
- `this. The next warning comes when a new element type joins the list.`,
152
+ `this. The next warning comes when a new element type joins the list or the ` +
153
+ `total passes ${10 ** (magnitude + 1)}.`,
144
154
  )
145
155
  }
146
156
 
@@ -51,6 +51,10 @@ declare module "*.glb" {
51
51
  const bytes: Uint8Array
52
52
  export default bytes
53
53
  }
54
+ declare module "*.srtm" {
55
+ const bytes: Uint8Array
56
+ export default bytes
57
+ }
54
58
 
55
59
  // UI event bus (lattice), provided by the runtime as a builtin module.
56
60
  // on/once return an unsubscribe function. Notable events: the routed pointer
@@ -79,6 +83,18 @@ declare module "srt:app" {
79
83
  * `back` on Android; exposed there as `background`.
80
84
  */
81
85
  export function background(): void
86
+ /**
87
+ * Make this install the handler for the app's own scheme (its appId, as
88
+ * in `com.example.app://settings`), so links of it open the app: on Linux
89
+ * a desktop entry plus the mimeapps.list default, on Windows the
90
+ * HKCU\Software\Classes protocol key, both for the current user. Throws
91
+ * when the registration cannot be written. Android and macOS packages
92
+ * declare the scheme themselves, so the call has nothing to do there; a
93
+ * dev client registers nothing and logs a warning. Prefer the
94
+ * @solidrt/core `registerProtocolHandler`, the same call documented with
95
+ * the rest of the link surface.
96
+ */
97
+ export function registerProtocolHandler(): void
82
98
  }
83
99
 
84
100
  // Dev-server control surface (lattice). Present only in dev/go builds; in other
@@ -106,6 +122,17 @@ declare module "srt:dev" {
106
122
  * clients ever invoke commands.
107
123
  */
108
124
  export function registerDebug(name: string, fn: (args?: any) => unknown): void
125
+ /**
126
+ * Report where the app is, for tooling: a string naming the current
127
+ * location (a router's path; an app routing by hand may report its own),
128
+ * called again on every change. The runtime stores the value and never
129
+ * interprets it. The dev server reads it (`GET /__control__/link`, the
130
+ * get_location MCP tool), and a hot reload of the same app starts with the
131
+ * last reported location as its `env.launchLink`, so a rebuild comes back
132
+ * to the screen it left. null withdraws it. Callable in every build, but
133
+ * only dev clients ever read it.
134
+ */
135
+ export function reportLocation(location: string | null): void
109
136
  }
110
137
 
111
138
  // Installed-app management (lattice), the player's surface over the client's
package/src/svg.ts CHANGED
@@ -27,6 +27,7 @@ export type SvgDraw = {
27
27
  color: string | Gradient
28
28
  drawStyle: "fill" | "stroke"
29
29
  fillRule?: "nonzero" | "evenodd"
30
+ /** Absent when the source omits it: the d-path default of 1 applies, SVG's own. */
30
31
  strokeWidth?: number
31
32
  strokeCap?: StrokeCap
32
33
  strokeJoin?: StrokeJoin
package/src/swipe.ts ADDED
@@ -0,0 +1,130 @@
1
+ // The swipe recognizer: a one-pointer drag that, at the lift, has
2
+ // travelled SWIPE_MIN_DISTANCE from its down at SWIPE_MIN_VELOCITY or
3
+ // more, within SWIPE_ANGLE_TOLERANCE of one of the four axes. Discrete:
4
+ // onSwipe fires once, at the lift, with the direction and the velocity.
5
+ // Direction is the dominant axis of the VELOCITY, not of the travel (a
6
+ // finger that wandered then flicked swipes where it flicked). A drag
7
+ // that ends without qualifying gets onSwipeEnd alone, so a consumer that
8
+ // followed the finger snaps back.
9
+ //
10
+ // Built on createPan with the pan's axis derived from the allowed
11
+ // directions: a horizontal-only swipe inside a vertical ScrollView takes
12
+ // only horizontal drags, exactly as nested scrollers share drags today,
13
+ // and it steals at the pan's slop, so a pressable under it retracts. The
14
+ // pan measures the parent-frame velocity it hands the consumer; the
15
+ // thresholds are finger travel, so the swipe keeps a second tracker in
16
+ // window pixels over the events it wraps. The classifier is shared with
17
+ // the pointer feed's swipe sources. Mouse and touch alike.
18
+
19
+ import { createPan } from "./pan"
20
+ import type { PointerEvent } from "./types"
21
+ import { createVelocityTracker } from "./velocity"
22
+ import type { Velocity } from "./velocity"
23
+
24
+ export type SwipeDirection = "Left" | "Right" | "Up" | "Down"
25
+
26
+ export const SWIPE_DIRECTIONS: readonly SwipeDirection[] = ["Left", "Right", "Up", "Down"]
27
+
28
+ // Least finger travel (window px) from the down to the lift.
29
+ const SWIPE_MIN_DISTANCE = 24
30
+ // Least finger speed at the lift (window px/s): Android's usual
31
+ // minimumFlingVelocity of 50 dp/s at the density it assumes.
32
+ const SWIPE_MIN_VELOCITY = 300
33
+ // How far off an axis (degrees) the velocity may point.
34
+ const SWIPE_ANGLE_TOLERANCE = 30
35
+ const OFF_AXIS_RATIO = Math.tan((SWIPE_ANGLE_TOLERANCE * Math.PI) / 180)
36
+
37
+ /**
38
+ * The direction a lift swipes in, or null: `finger` is the velocity in
39
+ * window px/s, `travel` the window-px offset from the down.
40
+ */
41
+ export function classifySwipe(finger: Velocity, travel: { dx: number; dy: number }, directions: readonly SwipeDirection[] = SWIPE_DIRECTIONS): SwipeDirection | null {
42
+ if (Math.hypot(travel.dx, travel.dy) < SWIPE_MIN_DISTANCE) return null
43
+ let ax = Math.abs(finger.vx)
44
+ let ay = Math.abs(finger.vy)
45
+ if (Math.hypot(ax, ay) < SWIPE_MIN_VELOCITY) return null
46
+ let direction: SwipeDirection
47
+ if (ax >= ay) {
48
+ if (ay > ax * OFF_AXIS_RATIO) return null
49
+ direction = finger.vx < 0 ? "Left" : "Right"
50
+ } else {
51
+ if (ax > ay * OFF_AXIS_RATIO) return null
52
+ direction = finger.vy < 0 ? "Up" : "Down"
53
+ }
54
+ return directions.includes(direction) ? direction : null
55
+ }
56
+
57
+ export interface SwipeOptions {
58
+ /** Which directions count; default all four. Decides the pan's axis:
59
+ * only Left/Right takes horizontal drags, only Up/Down vertical ones. */
60
+ directions?: SwipeDirection[]
61
+ onSwipeStart?: () => void
62
+ /** Movement since the previous event in the handler node's parent frame,
63
+ * so a card follows the finger (createPan's frame rule). */
64
+ onSwipeMove?: (dx: number, dy: number) => void
65
+ /** The lift qualified: fired once, before onSwipeEnd. `velocity` is the
66
+ * pan's, parent-frame px/s. */
67
+ onSwipe?: (direction: SwipeDirection, velocity: Velocity) => void
68
+ /** Every lift of an active swipe, qualified or not. */
69
+ onSwipeEnd?: (velocity: Velocity) => void
70
+ }
71
+
72
+ export function createSwipe(options: SwipeOptions) {
73
+ let directions = () => options.directions ?? SWIPE_DIRECTIONS
74
+ let axisOf = (): "horizontal" | "vertical" | "both" => {
75
+ let d = directions()
76
+ let h = d.includes("Left") || d.includes("Right")
77
+ let v = d.includes("Up") || d.includes("Down")
78
+ return h && !v ? "horizontal" : v && !h ? "vertical" : "both"
79
+ }
80
+ // The down's window position and the finger's window-px velocity, over
81
+ // every event the wrapped pan sees.
82
+ let down: { id: number; x: number; y: number } | null = null
83
+ // The lift's window position, set just before the pan's up runs.
84
+ let lift: { x: number; y: number } | null = null
85
+ let finger = createVelocityTracker()
86
+ let pan = createPan({
87
+ get axis() {
88
+ return axisOf()
89
+ },
90
+ onPanStart: () => options.onSwipeStart?.(),
91
+ onPanMove: (dx, dy) => options.onSwipeMove?.(dx, dy),
92
+ onPanEnd: velocity => {
93
+ let direction: SwipeDirection | null = null
94
+ if (down && lift) {
95
+ direction = classifySwipe(finger.velocity(), { dx: lift.x - down.x, dy: lift.y - down.y }, directions())
96
+ }
97
+ if (direction) options.onSwipe?.(direction, velocity)
98
+ options.onSwipeEnd?.(velocity)
99
+ },
100
+ })
101
+
102
+ let handlers = {
103
+ onPointerDown: (e: PointerEvent) => {
104
+ if (down === null && (e.button == null || e.button === 0)) {
105
+ down = { id: e.pointerId, x: e.clientX, y: e.clientY }
106
+ finger.reset()
107
+ finger.push(e.clientX, e.clientY)
108
+ }
109
+ pan.handlers.onPointerDown(e)
110
+ },
111
+ onPointerMove: (e: PointerEvent) => {
112
+ if (down && down.id === e.pointerId) finger.push(e.clientX, e.clientY)
113
+ pan.handlers.onPointerMove(e)
114
+ },
115
+ onPointerUp: (e: PointerEvent) => {
116
+ if (down && down.id === e.pointerId) lift = { x: e.clientX, y: e.clientY }
117
+ pan.handlers.onPointerUp(e)
118
+ if (down && down.id === e.pointerId) {
119
+ down = null
120
+ lift = null
121
+ }
122
+ },
123
+ }
124
+ let cancel = () => {
125
+ pan.cancel()
126
+ down = null
127
+ lift = null
128
+ }
129
+ return { handlers, cancel }
130
+ }