@solidrt/core 0.0.18 → 0.0.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "colord": "^2.9.3"
28
28
  },
29
29
  "devDependencies": {
30
- "@solidrt/flux-types": "0.0.0"
30
+ "@solidrt/flux-types": "0.0.20"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@solidjs/signals": "2.0.0-beta.15",
@@ -0,0 +1,48 @@
1
+ import { env } from "./environment"
2
+
3
+ // Capabilities: what interactions are possible in the current environment.
4
+ // Derived from Environment State on every read - computed, never stored - so
5
+ // they are reactive wherever env is. Capabilities describe what is possible,
6
+ // not how the UI should behave; behavior lives in the policy layer on top.
7
+ //
8
+ // Device presence wins when the runtime reports it (so unplugging the mouse
9
+ // drops hover); the traffic-inferred seen-flags are the fallback for runtimes
10
+ // without device enumeration.
11
+
12
+ export type WindowSizeClass = "compact" | "medium" | "expanded"
13
+
14
+ export type Capabilities = {
15
+ /** A pointer can rest over content without pressing (mouse/trackpad). */
16
+ hover: boolean
17
+ /** Pixel-precise pointing (mouse/trackpad), as opposed to a finger. */
18
+ precisePointer: boolean
19
+ /** Direct touch input. */
20
+ touch: boolean
21
+ /** Hardware-key navigation (tab/arrow traversal, shortcuts). */
22
+ keyboardNav: boolean
23
+ /** How much horizontal room the window offers (Material breakpoints). */
24
+ windowSizeClass: WindowSizeClass
25
+ }
26
+
27
+ // Material 3 width breakpoints, in logical pixels.
28
+ const MEDIUM_MIN_WIDTH = 600
29
+ const EXPANDED_MIN_WIDTH = 840
30
+
31
+ export let capabilities: Capabilities = {
32
+ get hover() {
33
+ return env.inputDevices?.mouse ?? env.mouseSeen
34
+ },
35
+ get precisePointer() {
36
+ return env.inputDevices?.mouse ?? env.mouseSeen
37
+ },
38
+ get touch() {
39
+ return env.inputDevices?.touch ?? env.touchSeen
40
+ },
41
+ get keyboardNav() {
42
+ return env.inputDevices?.keyboard ?? env.keyboardSeen
43
+ },
44
+ get windowSizeClass(): WindowSizeClass {
45
+ let w = env.windowSize.width
46
+ return w >= EXPANDED_MIN_WIDTH ? "expanded" : w >= MEDIUM_MIN_WIDTH ? "medium" : "compact"
47
+ },
48
+ }
@@ -0,0 +1,155 @@
1
+ import { createSignal } from "@solidjs/signals"
2
+ import { on } from "srt:events"
3
+ import { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } from "./window"
4
+
5
+ // Environment State: reactive facts about the current execution environment.
6
+ //
7
+ // Device presence comes from the runtime's sticky "inputDevices" event (init +
8
+ // hotplug). Runtimes that do not emit it leave `inputDevices` undefined, and
9
+ // the seen-flags below act as the fallback: a pointer type or key press has
10
+ // been seen this session. Seen-flags only ever go from false to true, so a
11
+ // capability derived from them can appear mid-session (e.g. the first mouse
12
+ // move) but never flickers away.
13
+
14
+ /** Connected input device classes, as reported by the runtime. */
15
+ export interface InputDevices {
16
+ keyboard: boolean
17
+ mouse: boolean
18
+ touch: boolean
19
+ }
20
+
21
+ export type SystemTheme = "dark" | "light" | "unknown"
22
+
23
+ export type Orientation = "portrait" | "portraitFlipped" | "landscape" | "landscapeFlipped" | "unknown"
24
+
25
+ let devicesAccessor: (() => InputDevices | undefined) | undefined
26
+
27
+ function ensureDevicesState() {
28
+ if (devicesAccessor) return
29
+ let [devices, setDevices] = createSignal<InputDevices | undefined>(undefined)
30
+ // Sticky: the current state replays on subscribe, so the first read already
31
+ // sees it on runtimes that report devices.
32
+ on("inputDevices", (d: InputDevices) => {
33
+ setDevices({ keyboard: !!d.keyboard, mouse: !!d.mouse, touch: !!d.touch })
34
+ })
35
+ devicesAccessor = devices
36
+ }
37
+
38
+ let systemThemeAccessor: (() => SystemTheme) | undefined
39
+
40
+ function ensureSystemThemeState() {
41
+ if (systemThemeAccessor) return
42
+ let [theme, setTheme] = createSignal<SystemTheme>("unknown")
43
+ on("systemTheme", (e: { theme?: SystemTheme }) => setTheme(e.theme ?? "unknown"))
44
+ systemThemeAccessor = theme
45
+ }
46
+
47
+ let orientationAccessor: (() => Orientation) | undefined
48
+
49
+ function ensureOrientationState() {
50
+ if (orientationAccessor) return
51
+ let [orientation, setOrientation] = createSignal<Orientation>("unknown")
52
+ on("displayOrientation", (e: { orientation?: Orientation }) => {
53
+ setOrientation(e.orientation ?? "unknown")
54
+ })
55
+ orientationAccessor = orientation
56
+ }
57
+
58
+ let mouseSeenAccessor: (() => boolean) | undefined
59
+ let touchSeenAccessor: (() => boolean) | undefined
60
+
61
+ function ensurePointerState() {
62
+ if (mouseSeenAccessor) return
63
+ let [mouse, setMouse] = createSignal(false)
64
+ let [touch, setTouch] = createSignal(false)
65
+ let sawMouse = false
66
+ let sawTouch = false
67
+ let unsubs: (() => void)[] = []
68
+ let note = (e: { pointerType?: string }) => {
69
+ if (e.pointerType === "mouse" && !sawMouse) {
70
+ sawMouse = true
71
+ setMouse(true)
72
+ } else if (e.pointerType === "touch" && !sawTouch) {
73
+ sawTouch = true
74
+ setTouch(true)
75
+ }
76
+ // Both types observed: nothing left to learn, stop listening.
77
+ if (sawMouse && sawTouch) for (let u of unsubs) u()
78
+ }
79
+ unsubs.push(on("pointerMove", note), on("pointerDown", note))
80
+ mouseSeenAccessor = mouse
81
+ touchSeenAccessor = touch
82
+ }
83
+
84
+ let keyboardSeenAccessor: (() => boolean) | undefined
85
+
86
+ function ensureKeyboardState() {
87
+ if (keyboardSeenAccessor) return
88
+ let [keyboard, setKeyboard] = createSignal(false)
89
+ // Soft keyboards also deliver some keydowns (Backspace, Return), so this can
90
+ // read true on a touch-only device once the user types in a field. Only a
91
+ // fallback: capabilities prefer runtime-reported device presence.
92
+ let unsub = on("keydown", () => {
93
+ setKeyboard(true)
94
+ unsub()
95
+ })
96
+ keyboardSeenAccessor = keyboard
97
+ }
98
+
99
+ /**
100
+ * Reactive Environment State: what the framework observes about where it is
101
+ * running. Read properties inside a tracked scope (JSX, memo, effect) to re-run
102
+ * when they change. Behavior decisions should go through `capabilities` and the
103
+ * policy layer; read `env` directly only when the raw fact itself is needed.
104
+ */
105
+ export let env = {
106
+ get windowSize() {
107
+ return windowSize()
108
+ },
109
+ get safeArea() {
110
+ return safeArea()
111
+ },
112
+ get displayScale() {
113
+ return displayScale()
114
+ },
115
+ get windowFocused() {
116
+ return windowFocused()
117
+ },
118
+ get keyboardHeight() {
119
+ return keyboardHeight()
120
+ },
121
+ /**
122
+ * Connected input device classes, or undefined until the runtime reports
123
+ * them (it does so at startup, so undefined normally means the runtime has
124
+ * no device enumeration).
125
+ */
126
+ get inputDevices(): InputDevices | undefined {
127
+ ensureDevicesState()
128
+ return devicesAccessor!()
129
+ },
130
+ /** The OS-level dark/light preference. */
131
+ get systemTheme(): SystemTheme {
132
+ ensureSystemThemeState()
133
+ return systemThemeAccessor!()
134
+ },
135
+ /** Orientation of the display the window is on. */
136
+ get orientation(): Orientation {
137
+ ensureOrientationState()
138
+ return orientationAccessor!()
139
+ },
140
+ /** A mouse (or trackpad) pointer has produced events this session. */
141
+ get mouseSeen(): boolean {
142
+ ensurePointerState()
143
+ return mouseSeenAccessor!()
144
+ },
145
+ /** A touch pointer has produced events this session. */
146
+ get touchSeen(): boolean {
147
+ ensurePointerState()
148
+ return touchSeenAccessor!()
149
+ },
150
+ /** A key press has been delivered this session. */
151
+ get keyboardSeen(): boolean {
152
+ ensureKeyboardState()
153
+ return keyboardSeenAccessor!()
154
+ },
155
+ }
package/src/index.ts CHANGED
@@ -4,7 +4,12 @@ export type { BoundingBox } from "./core"
4
4
  export { parseColor, createLinearGradient, createRadialGradient } from "./color"
5
5
  export type { Gradient, GradientStop } from "./color"
6
6
  export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur } from "./window"
7
+ export { setPointerCapture, releasePointerCapture } from "./window"
7
8
  export { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } from "./window"
9
+ export { env } from "./environment"
10
+ export type { InputDevices, SystemTheme, Orientation } from "./environment"
11
+ export { capabilities } from "./capabilities"
12
+ export type { Capabilities, WindowSizeClass } from "./capabilities"
8
13
  export { createTexture } from "./gpu"
9
14
  export { createImage, decodeImage } from "./image"
10
15
  export type { DecodedImage, ImageSource } from "./image"
package/src/renderer.ts CHANGED
@@ -30,9 +30,42 @@ function createProxyNode(elementType: ElementType): ProxyNode {
30
30
  return node
31
31
  }
32
32
 
33
- // Detaches `node` from `parent` and destroys it (and all descendants) on both
34
- // the JS and native sides. Hoisted out of the renderer config so createPortal
35
- // can reuse it (createRenderer does not return its removeNode hook).
33
+ // Nodes detached this tick and awaiting the destroy sweep, keyed by id so a
34
+ // re-insert can cancel one. See removeNode / flushDestroy.
35
+ let pendingDestroy = new Map<number, ProxyNode>()
36
+ let destroyScheduled = false
37
+
38
+ // Frees a detached node and its subtree on both sides. Descendants that were
39
+ // moved out (their parent no longer points here) are left alone. Clear focus
40
+ // before dropping handlers so onBlur still fires for a focused descendant.
41
+ function destroyNode(node: ProxyNode): void {
42
+ tree.destroyNode(node.id)
43
+ let cleanup = (n: ProxyNode) => {
44
+ for (let child of n.children) if (child.parent === n) cleanup(child)
45
+ if (n.id === getFocusedNodeId()) setFocus(null)
46
+ nodes.delete(n.id)
47
+ cleanupNodeHandlers(n.id)
48
+ }
49
+ cleanup(node)
50
+ }
51
+
52
+ // End-of-tick sweep: destroy every still-detached pending node. A node that was
53
+ // re-inserted this tick had its entry removed by insertNode (it moved, not
54
+ // died); one still parentless here is genuinely gone.
55
+ function flushDestroy(): void {
56
+ destroyScheduled = false
57
+ let batch = pendingDestroy
58
+ pendingDestroy = new Map()
59
+ for (let node of batch.values()) {
60
+ if (node.parent === undefined) destroyNode(node)
61
+ }
62
+ }
63
+
64
+ // Detaches `node` from `parent`, keeping the subtree alive so it can be
65
+ // re-inserted elsewhere (a move) - matching DOM removeChild. Destruction is
66
+ // deferred to an end-of-tick sweep; if nothing re-attaches the node by then it
67
+ // is freed. Hoisted out of the renderer config so createPortal can reuse it
68
+ // (createRenderer does not return its removeNode hook).
36
69
  function removeNode(parent: ProxyNode, node: ProxyNode): void {
37
70
  if (!node || !parent) return
38
71
 
@@ -45,17 +78,13 @@ function removeNode(parent: ProxyNode, node: ProxyNode): void {
45
78
  }
46
79
  node.parent = undefined
47
80
 
48
- tree.deleteNode(parent.id, node.id)
81
+ tree.detachNode(parent.id, node.id)
49
82
 
50
- // Recursively clean up node and all descendants. Clear focus before
51
- // dropping handlers so onBlur still fires for a focused descendant.
52
- let cleanup = (n: ProxyNode) => {
53
- for (let child of n.children) cleanup(child)
54
- if (n.id === getFocusedNodeId()) setFocus(null)
55
- nodes.delete(n.id)
56
- cleanupNodeHandlers(n.id)
83
+ pendingDestroy.set(node.id, node)
84
+ if (!destroyScheduled) {
85
+ destroyScheduled = true
86
+ Promise.resolve().then(flushDestroy)
57
87
  }
58
- cleanup(node)
59
88
  }
60
89
 
61
90
  export let {
@@ -123,6 +152,10 @@ export let {
123
152
  insertNode: (parent: ProxyNode, node: ProxyNode, anchor?: ProxyNode): void => {
124
153
  if (!node) return
125
154
 
155
+ // A re-inserted node is being moved, not destroyed: cancel its pending
156
+ // destroy so the end-of-tick sweep leaves it (and its subtree) alone.
157
+ pendingDestroy.delete(node.id)
158
+
126
159
  if (parent) {
127
160
  node.parent = parent
128
161
 
package/src/types.d.ts CHANGED
@@ -153,6 +153,10 @@ export interface PointerEvent {
153
153
  ctrlKey: boolean
154
154
  altKey: boolean
155
155
  metaKey: boolean
156
+ // Stops the event from reaching ancestor handlers. Events dispatch leaf->root
157
+ // (bubbling), so calling this in a child prevents the enclosing node from
158
+ // seeing it (e.g. a slider claiming a drag so an ancestor scroller ignores it).
159
+ stopPropagation: () => void
156
160
  }
157
161
 
158
162
  export interface WheelEvent extends PointerEvent {
@@ -264,8 +268,13 @@ export interface SvgProps extends Position, PointerProps {
264
268
  color?: Color
265
269
  }
266
270
 
267
- export interface TextProps extends PaintProps, PointerProps {
271
+ export interface TextProps extends Position, PaintProps, PointerProps {
268
272
  children?: Children
273
+ // Shaping (wrap) width. Detached text wraps at the inherited ancestor size
274
+ // by default; set w for an unwrapped natural line or an explicit wrap width.
275
+ w?: number
276
+ // Reported-bounds height only; paragraph height always falls out of the text.
277
+ h?: number
269
278
  fontFamily?: "sans" | "mono" | (string & {})
270
279
  fontSize?: number
271
280
  lineHeight?: number
package/src/window.ts CHANGED
@@ -4,6 +4,28 @@ import { renderFrame } from "srt:render"
4
4
  import { on, once } from "srt:events"
5
5
  import { getEventHandler, getFocusedNodeId, setFocus } from "./core"
6
6
 
7
+ // ------ Pointer capture -----------------
8
+
9
+ // Active pointer captures, pointerId -> nodeId. While a pointer is captured, its
10
+ // move/up events dispatch straight to the captured node (bypassing hit testing),
11
+ // so a drag keeps working when the pointer drifts off the element. Modeled on the
12
+ // web setPointerCapture; capture auto-releases on pointerUp.
13
+ let pointerCaptures = new Map<number, number>()
14
+
15
+ /**
16
+ * Routes all further move/up events for `pointerId` to `nodeId` until release,
17
+ * regardless of what is under the pointer. Call from a pointerdown handler to own
18
+ * a drag (e.g. a slider). Auto-releases on the matching pointerup.
19
+ */
20
+ export function setPointerCapture(nodeId: number, pointerId: number) {
21
+ pointerCaptures.set(pointerId, nodeId)
22
+ }
23
+
24
+ /** Ends a capture early. Not needed for the common case (pointerup releases it). */
25
+ export function releasePointerCapture(pointerId: number) {
26
+ pointerCaptures.delete(pointerId)
27
+ }
28
+
7
29
  // ------ Animation frames ----------------
8
30
 
9
31
  let nextFrameId = 1
@@ -201,12 +223,24 @@ export function attachWindow(_nodeId: number) {
201
223
  runFrame(time * 1000, frame)
202
224
  })
203
225
 
226
+ // Dispatch an event to every node on the hit path, leaf->root (bubbling), so
227
+ // a child handler can call e.stopPropagation() to keep the event from
228
+ // reaching its ancestors. `targets` arrives root->leaf, hence the reverse.
229
+ let bubble = (targets: number[], handler: string, e: any) => {
230
+ let stopped = false
231
+ e.stopPropagation = () => {
232
+ stopped = true
233
+ }
234
+ for (let i = targets.length - 1; i >= 0; i--) {
235
+ getEventHandler(targets[i]!, handler)?.(e)
236
+ if (stopped) break
237
+ }
238
+ }
239
+
204
240
  unsubDown = on(
205
241
  "pointerDown",
206
242
  ({ targets, ...e }: { targets: number[]; [k: string]: any }) => {
207
- for (let nodeId of targets) {
208
- getEventHandler(nodeId, "onPointerDown")?.(e)
209
- }
243
+ bubble(targets, "onPointerDown", e)
210
244
  // Outside-tap blur. Read focus AFTER per-node handlers so a tap that
211
245
  // moves focus to a new node is not immediately blurred again.
212
246
  let focused = getFocusedNodeId()
@@ -217,42 +251,61 @@ export function attachWindow(_nodeId: number) {
217
251
  )
218
252
 
219
253
  unsubUp = on("pointerUp", ({ targets, ...e }: { targets: number[]; [k: string]: any }) => {
220
- for (let nodeId of targets) {
221
- getEventHandler(nodeId, "onPointerUp")?.(e)
254
+ let captured = pointerCaptures.get(e.pointerId)
255
+ if (captured != null) {
256
+ // Deliver to the captured node, then release (web auto-release on up).
257
+ e.stopPropagation = () => {}
258
+ getEventHandler(captured, "onPointerUp")?.(e)
259
+ pointerCaptures.delete(e.pointerId)
260
+ return
222
261
  }
262
+ bubble(targets, "onPointerUp", e)
223
263
  })
224
264
 
225
265
  unsubMove = on(
226
266
  "pointerMove",
227
267
  ({ targets, ...e }: { targets: number[]; [k: string]: any }) => {
228
- for (let nodeId of targets) {
229
- getEventHandler(nodeId, "onPointerMove")?.(e)
268
+ let captured = pointerCaptures.get(e.pointerId)
269
+ if (captured != null) {
270
+ // Captured drags get moves anywhere over the window, bypassing hit test.
271
+ e.stopPropagation = () => {}
272
+ getEventHandler(captured, "onPointerMove")?.(e)
273
+ return
230
274
  }
275
+ bubble(targets, "onPointerMove", e)
231
276
  },
232
277
  )
233
278
 
279
+ // Enter/leave keep their hover-diff order (already leaf->root for leave,
280
+ // root->leaf for enter), but still honor stopPropagation for a consistent
281
+ // event shape.
282
+ let dispatchOrdered = (targets: number[], handler: string, e: any) => {
283
+ let stopped = false
284
+ e.stopPropagation = () => {
285
+ stopped = true
286
+ }
287
+ for (let nodeId of targets) {
288
+ getEventHandler(nodeId, handler)?.(e)
289
+ if (stopped) break
290
+ }
291
+ }
292
+
234
293
  unsubEnter = on(
235
294
  "pointerEnter",
236
295
  ({ targets, ...e }: { targets: number[]; [k: string]: any }) => {
237
- for (let nodeId of targets) {
238
- getEventHandler(nodeId, "onPointerEnter")?.(e)
239
- }
296
+ dispatchOrdered(targets, "onPointerEnter", e)
240
297
  },
241
298
  )
242
299
 
243
300
  unsubLeave = on(
244
301
  "pointerLeave",
245
302
  ({ targets, ...e }: { targets: number[]; [k: string]: any }) => {
246
- for (let nodeId of targets) {
247
- getEventHandler(nodeId, "onPointerLeave")?.(e)
248
- }
303
+ dispatchOrdered(targets, "onPointerLeave", e)
249
304
  },
250
305
  )
251
306
 
252
307
  unsubWheel = on("wheel", ({ targets, ...e }: { targets: number[]; [k: string]: any }) => {
253
- for (let nodeId of targets) {
254
- getEventHandler(nodeId, "onWheel")?.(e)
255
- }
308
+ bubble(targets, "onWheel", e)
256
309
  })
257
310
 
258
311
  unsubKeyDown = on("keydown", (e: any) => {