@solidrt/core 0.0.40 → 0.0.42

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/core.ts CHANGED
@@ -1,10 +1,113 @@
1
1
  import * as tree from "flux:rendertree"
2
+ import { createSignal, onCleanup } from "@solidjs/signals"
3
+ import { on } from "srt:events"
2
4
 
3
5
  let handlers = new Map<number, Map<string, Function>>()
4
6
 
7
+ // Pointer-handler presence mirrored into the render tree, so the runtime can
8
+ // skip building deliveries that would reach nobody (moves over static content
9
+ // are the flood case). Bits match alloy's EventInterest (rendertree/hit.rs);
10
+ // keep the two in sync. Down/up are recorded but the runtime never gates
11
+ // them: focus and gesture side effects hang off them regardless of handlers.
12
+ const MOVE_BIT = 1
13
+ const POINTER_INTEREST: Record<string, number> = {
14
+ onPointerMove: MOVE_BIT,
15
+ onPointerDown: 2,
16
+ onPointerUp: 4,
17
+ onPointerEnter: 8,
18
+ onPointerLeave: 16,
19
+ onWheel: 32,
20
+ }
21
+
22
+ let interests = new Map<number, number>()
23
+
24
+ function syncInterest(nodeId: number): void {
25
+ let mask = 0
26
+ let nodeHandlers = handlers.get(nodeId)
27
+ if (nodeHandlers) for (let name of nodeHandlers.keys()) mask |= POINTER_INTEREST[name] ?? 0
28
+ // Ambient onPointerMove subscribers park a move bit on the window root: it
29
+ // sits on every hit path, so moves keep emitting wherever the pointer is.
30
+ if (nodeId === interestRoot && globalMoveSubs.size > 0) mask |= MOVE_BIT
31
+ if ((interests.get(nodeId) ?? 0) === mask) return
32
+ if (mask === 0) interests.delete(nodeId)
33
+ else interests.set(nodeId, mask)
34
+ tree.setEventInterest(nodeId, mask)
35
+ }
36
+
37
+ /**
38
+ * A pointer fact with no per-node fields: window coordinates plus pointer
39
+ * identity and modifiers. `target` is the deepest node under the pointer
40
+ * (0 when nothing is hit).
41
+ */
42
+ export interface GlobalPointerEvent {
43
+ clientX: number
44
+ clientY: number
45
+ target: number
46
+ pointerId: number
47
+ pointerType: "mouse" | "touch" | "pen" | (string & {})
48
+ shiftKey: boolean
49
+ ctrlKey: boolean
50
+ altKey: boolean
51
+ metaKey: boolean
52
+ }
53
+
54
+ let globalMoveSubs = new Set<(e: GlobalPointerEvent) => void>()
55
+ let globalMoveUnsub: (() => void) | null = null
56
+ // The window root's node id while a window is attached (see attachWindow):
57
+ // where the ambient move-interest bit lands.
58
+ let interestRoot: number | null = null
59
+
60
+ /**
61
+ * Observe every pointer move, unattached to any node - for ambient tracking
62
+ * (cursor followers, idle detection, overlays). Element interaction belongs
63
+ * in per-node handlers: they carry exact local coordinates, and during a drag
64
+ * moves already follow the frozen down-path off-element. Cleans up with the
65
+ * owning scope; also returns its unsubscribe.
66
+ */
67
+ export function onPointerMove(fn: (e: GlobalPointerEvent) => void): () => void {
68
+ globalMoveSubs.add(fn)
69
+ if (globalMoveSubs.size === 1) {
70
+ globalMoveUnsub = on("pointerMove", (raw: any) => {
71
+ let e: GlobalPointerEvent = {
72
+ clientX: raw.clientX,
73
+ clientY: raw.clientY,
74
+ target: raw.target,
75
+ pointerId: raw.pointerId,
76
+ pointerType: raw.pointerType,
77
+ shiftKey: raw.shiftKey,
78
+ ctrlKey: raw.ctrlKey,
79
+ altKey: raw.altKey,
80
+ metaKey: raw.metaKey,
81
+ }
82
+ // Copy first: a subscriber may unsubscribe (itself or others) mid-dispatch.
83
+ for (let sub of [...globalMoveSubs]) sub(e)
84
+ })
85
+ if (interestRoot != null) syncInterest(interestRoot)
86
+ }
87
+ let cleanup = () => {
88
+ if (!globalMoveSubs.delete(fn)) return
89
+ if (globalMoveSubs.size === 0) {
90
+ globalMoveUnsub?.()
91
+ globalMoveUnsub = null
92
+ if (interestRoot != null) syncInterest(interestRoot)
93
+ }
94
+ }
95
+ onCleanup(cleanup)
96
+ return cleanup
97
+ }
98
+
99
+ // Called by attachWindow with the window root's id (null on teardown). No
100
+ // interest write on clear: the root node is being destroyed with its window,
101
+ // and cleanupNode drops the cached mask.
102
+ export function setInterestRoot(nodeId: number | null): void {
103
+ interestRoot = nodeId
104
+ if (nodeId != null) syncInterest(nodeId)
105
+ }
106
+
5
107
  export function setEventHandler(nodeId: number, name: string, fn: Function | null | undefined): void {
6
108
  if (fn == null) {
7
109
  handlers.get(nodeId)?.delete(name)
110
+ if (name in POINTER_INTEREST) syncInterest(nodeId)
8
111
  return
9
112
  }
10
113
  let nodeHandlers = handlers.get(nodeId)
@@ -13,49 +116,185 @@ export function setEventHandler(nodeId: number, name: string, fn: Function | nul
13
116
  handlers.set(nodeId, nodeHandlers)
14
117
  }
15
118
  nodeHandlers.set(name, fn)
119
+ if (name in POINTER_INTEREST) syncInterest(nodeId)
16
120
  }
17
121
 
18
122
  export function getEventHandler(nodeId: number, name: string): Function | undefined {
19
123
  return handlers.get(nodeId)?.get(name)
20
124
  }
21
125
 
22
- export function cleanupNodeHandlers(nodeId: number): void {
126
+ // Cleans up every per-node registry entry (handlers, focus candidacy, text
127
+ // hints) when a node is destroyed.
128
+ export function cleanupNode(nodeId: number): void {
23
129
  handlers.delete(nodeId)
130
+ // No setEventInterest call: the tree node is being destroyed with us.
131
+ interests.delete(nodeId)
132
+ focusables.delete(nodeId)
133
+ textHints.delete(nodeId)
134
+ }
135
+
136
+ // Per-node IME hints, declared by the `textInputHints` prop (renderer.ts)
137
+ // and read when the node's text session starts.
138
+ let textHints = new Map<number, tree.TextInputHints>()
139
+
140
+ export function setTextInputHints(nodeId: number, hints: tree.TextInputHints | null | undefined): void {
141
+ if (hints == null) textHints.delete(nodeId)
142
+ else textHints.set(nodeId, hints)
24
143
  }
25
144
 
26
- // Currently-focused node id. Reset to null automatically across engine
27
- // reloads because the JS environment is rebuilt from scratch.
145
+ // Currently-focused node id and text-session state. Each is a plain field
146
+ // paired with a signal: signal writes flush on the microtask, so a read
147
+ // through the signal alone is stale within the very dispatch that wrote it -
148
+ // and focus logic reads what it just moved (a tap handler focusing a field,
149
+ // then the dispatcher deciding on that focus). The plain field is the
150
+ // always-current truth; the signal exists only to make tracked scopes re-run.
151
+ // Reset across engine reloads for free: the JS environment is rebuilt.
28
152
  let focusedNodeId: number | null = null
29
- let textInputActive = false
153
+ let [trackFocusedNode, setFocusedNodeSignal] = createSignal<number | null>(null)
154
+ let textInputActiveNow = false
155
+ let [trackTextInputActive, setTextInputActiveSignal] = createSignal(false)
156
+
157
+ /**
158
+ * The focused node id, or null - as a reactive accessor: read it inside a
159
+ * tracked scope (JSX, memo, effect) to re-run when focus moves; any read
160
+ * (including in the same dispatch as a setFocus) sees the current value.
161
+ * setFocus is the only writer.
162
+ */
163
+ export function focusedNode(): number | null {
164
+ trackFocusedNode()
165
+ return focusedNodeId
166
+ }
167
+
168
+ /**
169
+ * Whether a text-entry session is active on the focused node (text events
170
+ * flowing; the on-screen keyboard up, where one is used), as a reactive
171
+ * accessor. Distinct from focus: a field focused by navigation is not
172
+ * editing until a tap or startTextInput() begins the session. Lets a text
173
+ * field tell its focused and editing states apart (Enter starts editing in
174
+ * the former, submits in the latter).
175
+ */
176
+ export function textInputActive(): boolean {
177
+ trackTextInputActive()
178
+ return textInputActiveNow
179
+ }
180
+
181
+ // The native window outlives engine reloads, so a previous session may have
182
+ // left its text input active; assert the known boot state.
183
+ tree.setTextInputActive(false)
184
+
185
+ // Facts for the text-session policy, from the sticky inputDevices event
186
+ // (init + hotplug). Conservative defaults until it arrives: assume a screen
187
+ // keyboard could appear, so an eager session start is never visibly wrong.
188
+ let screenKeyboard = true
189
+ let physicalKeyboard = false
190
+ on("inputDevices", (d: { keyboard?: boolean; screenKeyboard?: boolean }) => {
191
+ physicalKeyboard = !!d.keyboard
192
+ screenKeyboard = !!d.screenKeyboard
193
+ // Facts can arrive after a node was focused (boot ordering) or change under
194
+ // it (keyboard hotplug): re-evaluate the eager session so e.g. a
195
+ // mount-focused terminal starts receiving text without a tap.
196
+ syncTextInput(textInputEligible() && (textInputActive() || textInputInvisible()))
197
+ })
198
+
199
+ // Focus candidacy, declared by the `focusable` prop (routed in renderer.ts).
200
+ // Candidacy only: navigation schemes enumerate candidates and move focus
201
+ // themselves via setFocus.
202
+ let focusables = new Set<number>()
203
+
204
+ export function setFocusable(nodeId: number, focusable: boolean): void {
205
+ if (focusable) focusables.add(nodeId)
206
+ else focusables.delete(nodeId)
207
+ }
208
+
209
+ /**
210
+ * Node ids currently declaring `focusable`, for building focus navigation
211
+ * (spatial/D-pad movement, tab order). A snapshot, not reactive; pair with
212
+ * getBoundingBoxViewport for their geometry.
213
+ */
214
+ export function getFocusables(): number[] {
215
+ return [...focusables]
216
+ }
217
+
218
+ function textInputEligible(): boolean {
219
+ return focusedNodeId != null && getEventHandler(focusedNodeId, "onTextInput") != null
220
+ }
221
+
222
+ // Whether starting a text session shows nothing on screen: the platform has
223
+ // no screen keyboard, or a physical keyboard is attached (the runtime keeps
224
+ // the screen keyboard down natively then too). Invisible sessions start
225
+ // eagerly at focus, so desktops and keyboard-equipped devices deliver text
226
+ // from the moment a node is focused; visible ones wait for an interaction so
227
+ // a keyboard never appears without one (the pointerDown dispatch in
228
+ // window.ts, or an explicit startTextInput).
229
+ function textInputInvisible(): boolean {
230
+ return !screenKeyboard || physicalKeyboard
231
+ }
232
+
233
+ // The node whose session is running: a session that stays active while focus
234
+ // hops between text fields must restart on the new node so its IME hints
235
+ // (keyboard type, capitalization) take effect.
236
+ let sessionNodeId: number | null = null
237
+
238
+ function syncTextInput(active: boolean): void {
239
+ let target = active ? focusedNodeId : null
240
+ if (active === textInputActiveNow && target === sessionNodeId) return
241
+ textInputActiveNow = active
242
+ sessionNodeId = target
243
+ setTextInputActiveSignal(active)
244
+ if (active) tree.setTextInputActive(true, textHints.get(target!))
245
+ else tree.setTextInputActive(false)
246
+ }
30
247
 
31
248
  /**
32
249
  * Moves keyboard focus to `nodeId`, or clears it with `null`. Fires `onBlur` on
33
- * the previously focused node and `onFocus` on the new one. As a side effect,
34
- * the on-screen keyboard is activated when the newly focused node has an
35
- * `onTextInput` handler and deactivated otherwise. No-op if the node is already
36
- * focused.
250
+ * the previously focused node and `onFocus` on the new one. No-op if the node
251
+ * is already focused.
252
+ *
253
+ * Focus also scopes the text-entry session of a node with an `onTextInput`
254
+ * handler: where starting one is invisible (no screen keyboard, or a physical
255
+ * keyboard attached) it begins at focus; where it would raise an on-screen
256
+ * keyboard it waits for an interaction - a tap on the focused node, or
257
+ * startTextInput() - so focus alone never summons a keyboard. Focus moving
258
+ * between text nodes carries an active session along; focus leaving them ends
259
+ * it (and hides the keyboard).
37
260
  */
38
261
  export function setFocus(nodeId: number | null): void {
39
262
  if (nodeId === focusedNodeId) return
40
263
  let oldId = focusedNodeId
41
264
  focusedNodeId = nodeId
265
+ setFocusedNodeSignal(nodeId)
42
266
  if (oldId != null) {
43
267
  getEventHandler(oldId, "onBlur")?.()
44
268
  }
45
269
  if (nodeId != null) {
46
270
  getEventHandler(nodeId, "onFocus")?.()
47
271
  }
48
- let wantActive = nodeId != null && getEventHandler(nodeId, "onTextInput") != null
49
- if (wantActive !== textInputActive) {
50
- textInputActive = wantActive
51
- tree.setTextInputActive(wantActive)
52
- }
272
+ syncTextInput(textInputEligible() && (textInputActiveNow || textInputInvisible()))
53
273
  }
54
274
 
55
- export function getFocusedNodeId(): number | null {
56
- return focusedNodeId
275
+ // Interactive trigger: pointer dispatch calls this when a tap lands on the
276
+ // focused node (window.ts), the moment a pending session may raise the
277
+ // on-screen keyboard.
278
+ export function activateTextInput(): void {
279
+ if (textInputEligible()) syncTextInput(true)
57
280
  }
58
281
 
282
+ /**
283
+ * Begins text entry on the focused node: enables text-event delivery and, on
284
+ * platforms that use one (and with no physical keyboard attached), raises the
285
+ * on-screen keyboard. Focus alone never raises it; a tap on the focused node
286
+ * triggers this automatically, so call it only for other interactions that
287
+ * should (a remote's select on a focused field, a search button). Throws when
288
+ * the focused node has no onTextInput handler.
289
+ */
290
+ export function startTextInput(): void {
291
+ if (!textInputEligible()) {
292
+ throw new Error("startTextInput: no focused node with an onTextInput handler")
293
+ }
294
+ syncTextInput(true)
295
+ }
296
+
297
+
59
298
  export interface BoundingBox {
60
299
  x: number
61
300
  y: number
@@ -1,5 +1,6 @@
1
- import { createSignal } from "@solidjs/signals"
1
+ import { createRoot, createSignal } from "@solidjs/signals"
2
2
  import { on } from "srt:events"
3
+ import { onPointerMove } from "./core"
3
4
  import { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } from "./window"
4
5
 
5
6
  // Environment State: reactive facts about the current execution environment.
@@ -24,6 +25,8 @@ export interface InputDevices {
24
25
  keyboard: boolean
25
26
  mouse: boolean
26
27
  touch: boolean
28
+ /** Whether the platform can present an on-screen keyboard. */
29
+ screenKeyboard: boolean
27
30
  }
28
31
 
29
32
  export type SystemTheme = "dark" | "light" | "unknown"
@@ -40,7 +43,7 @@ function ensureDevicesState() {
40
43
  // Sticky: the current state replays on subscribe, so the first read already
41
44
  // sees it on runtimes that report devices.
42
45
  on("inputDevices", (d: InputDevices) => {
43
- setDevices({ keyboard: !!d.keyboard, mouse: !!d.mouse, touch: !!d.touch })
46
+ setDevices({ keyboard: !!d.keyboard, mouse: !!d.mouse, touch: !!d.touch, screenKeyboard: !!d.screenKeyboard })
44
47
  })
45
48
  devicesAccessor = devices
46
49
  }
@@ -100,10 +103,15 @@ function ensurePointerState() {
100
103
  let sawMouse = false
101
104
  let sawTouch = false
102
105
  let unsubs: (() => void)[] = []
106
+ let unsubMove: () => void = null!
103
107
  let note = (e: { pointerType?: string }) => {
104
108
  if (e.pointerType === "mouse" && !sawMouse) {
105
109
  sawMouse = true
106
110
  setMouse(true)
111
+ // Moves have nothing left to teach: touch is learned from downs (a
112
+ // touch never moves without one), so drop the move subscription and
113
+ // with it the ambient interest bit that forces move deliveries.
114
+ unsubMove()
107
115
  } else if (e.pointerType === "touch" && !sawTouch) {
108
116
  sawTouch = true
109
117
  setTouch(true)
@@ -111,7 +119,12 @@ function ensurePointerState() {
111
119
  // Both types observed: nothing left to learn, stop listening.
112
120
  if (sawMouse && sawTouch) for (let u of unsubs) u()
113
121
  }
114
- unsubs.push(on("pointerMove", note), on("pointerDown", note))
122
+ // Downs always deliver, so the raw bus tap suffices; moves are gated when
123
+ // nobody listens, so they go through onPointerMove, whose subscription
124
+ // keeps them flowing. The probe is app-lifetime: createRoot detaches its
125
+ // scope cleanup from whatever computation first read the accessor.
126
+ unsubMove = createRoot(() => onPointerMove(note))
127
+ unsubs.push(unsubMove, on("pointerDown", note))
115
128
  mouseSeenAccessor = mouse
116
129
  touchSeenAccessor = touch
117
130
  }