@solidrt/core 0.0.41 → 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/AGENTS.md +11 -1
- package/package.json +2 -2
- package/src/core.ts +254 -15
- package/src/environment.ts +16 -3
- package/src/index.ts +3 -2
- package/src/renderer.ts +23 -3
- package/src/types.d.ts +39 -2
- package/src/window.ts +39 -18
package/AGENTS.md
CHANGED
|
@@ -140,7 +140,17 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
|
|
|
140
140
|
- Events: there is NO `onClick`/`onPress`. A "button" is a `<view>`/`<rect>`
|
|
141
141
|
with `onPointerDown`. Handlers: onPointerDown/Up/Move/Enter/Leave, onWheel,
|
|
142
142
|
onKeyDown/Up, onTextInput, onFocus/onBlur. Text entry: focus a node with an
|
|
143
|
-
`onTextInput` handler
|
|
143
|
+
`onTextInput` handler. Focus alone never raises the on-screen keyboard: a
|
|
144
|
+
tap on the focused node (or explicit startTextInput()) does, and never
|
|
145
|
+
while a physical keyboard is attached; on keyboard-equipped platforms the
|
|
146
|
+
session starts invisibly at focus so text flows immediately.
|
|
147
|
+
`textInputHints` on the node configures the IME (type/capitalize/
|
|
148
|
+
autocorrect) - identifier fields and terminals want
|
|
149
|
+
`{ capitalize: "none", autocorrect: false }` (OS default auto-capitalizes). Key
|
|
150
|
+
events start at the focused node and bubble leaf->root to the window (with
|
|
151
|
+
nothing focused, the window alone), so `<window onKeyDown>` is the
|
|
152
|
+
app-global shortcut point; `stopPropagation()` ends the walk. `focusable`
|
|
153
|
+
declares focus-navigation candidacy (enumerate via getFocusables()).
|
|
144
154
|
|
|
145
155
|
- Reactivity is SolidJS 2.0 (`@solidjs/signals`), NOT Solid 1.x. `createSignal`
|
|
146
156
|
is as you expect, but `createEffect` takes the 2.0 two-function shape: a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.42",
|
|
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.
|
|
30
|
+
"@solidrt/flux-types": "0.0.42"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"@solidjs/signals": "2.0.0-beta.26",
|
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
|
-
|
|
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.
|
|
27
|
-
//
|
|
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
|
|
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.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
49
|
-
if (wantActive !== textInputActive) {
|
|
50
|
-
textInputActive = wantActive
|
|
51
|
-
tree.setTextInputActive(wantActive)
|
|
52
|
-
}
|
|
272
|
+
syncTextInput(textInputEligible() && (textInputActiveNow || textInputInvisible()))
|
|
53
273
|
}
|
|
54
274
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
package/src/environment.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export * from "./renderer"
|
|
2
|
-
export { setFocus,
|
|
3
|
-
export type { BoundingBox } from "./core"
|
|
2
|
+
export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, getBoundingBox, getBoundingBoxViewport, onPointerMove } from "./core"
|
|
3
|
+
export type { BoundingBox, GlobalPointerEvent } from "./core"
|
|
4
4
|
export { parseColor, mixColors, brightness, createLinearGradient, createRadialGradient } from "./color"
|
|
5
5
|
export type { Gradient, GradientStop } from "./color"
|
|
6
6
|
export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, exit } from "./window"
|
|
@@ -28,6 +28,7 @@ export type {
|
|
|
28
28
|
WheelEvent,
|
|
29
29
|
KeyEvent,
|
|
30
30
|
TextEvent,
|
|
31
|
+
TextInputHints,
|
|
31
32
|
PaintProps,
|
|
32
33
|
WindowProps,
|
|
33
34
|
WindowShaderProps,
|
package/src/renderer.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createRenderer } from "@solidjs/universal"
|
|
|
3
3
|
import type { Element } from "solid-js"
|
|
4
4
|
import * as tree from "flux:rendertree"
|
|
5
5
|
import { attachWindow } from "./window"
|
|
6
|
-
import { setEventHandler,
|
|
6
|
+
import { setEventHandler, setFocusable, setTextInputHints, cleanupNode, focusedNode, setFocus } from "./core"
|
|
7
7
|
import { parseColor, isGradient } from "./color"
|
|
8
8
|
|
|
9
9
|
export { getEventHandler } from "./core"
|
|
@@ -31,6 +31,16 @@ function createProxyNode(elementType: ElementType): ProxyNode {
|
|
|
31
31
|
return node
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Ancestor chain for event dispatch: node ids from `id` (inclusive) to the
|
|
35
|
+
// root, following the mount tree (a portaled node reports its mount point's
|
|
36
|
+
// chain, not its lexical one). Empty when the id is unknown.
|
|
37
|
+
export function getNodePath(id: number): number[] {
|
|
38
|
+
let path: number[] = []
|
|
39
|
+
let node: ProxyNode | undefined = nodes.get(id)
|
|
40
|
+
for (; node; node = node.parent) path.push(node.id)
|
|
41
|
+
return path
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
// Nodes detached this tick and awaiting the destroy sweep, keyed by id so a
|
|
35
45
|
// re-insert can cancel one. See removeNode / flushDestroy.
|
|
36
46
|
let pendingDestroy = new Map<number, ProxyNode>()
|
|
@@ -43,9 +53,9 @@ function destroyNode(node: ProxyNode): void {
|
|
|
43
53
|
tree.destroyNode(node.id)
|
|
44
54
|
let cleanup = (n: ProxyNode) => {
|
|
45
55
|
for (let child of n.children) if (child.parent === n) cleanup(child)
|
|
46
|
-
if (n.id ===
|
|
56
|
+
if (n.id === focusedNode()) setFocus(null)
|
|
47
57
|
nodes.delete(n.id)
|
|
48
|
-
|
|
58
|
+
cleanupNode(n.id)
|
|
49
59
|
}
|
|
50
60
|
cleanup(node)
|
|
51
61
|
}
|
|
@@ -163,6 +173,16 @@ function applyProp<T>(node: ProxyNode, name: string, value: T): void {
|
|
|
163
173
|
return
|
|
164
174
|
}
|
|
165
175
|
|
|
176
|
+
if (name === "focusable") {
|
|
177
|
+
setFocusable(node.id, value === true)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (name === "textInputHints") {
|
|
182
|
+
setTextInputHints(node.id, value as any)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
166
186
|
if (name === "color" && isGradient(value)) {
|
|
167
187
|
setTreeProperty(node, name, value)
|
|
168
188
|
return
|
package/src/types.d.ts
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
import type { Gradient } from "./color"
|
|
5
5
|
import type { ProgramId, TextureId } from "flux:gpu"
|
|
6
|
+
import type { TextInputHints } from "flux:rendertree"
|
|
6
7
|
import type { Element } from "solid-js"
|
|
7
8
|
|
|
9
|
+
export type { TextInputHints }
|
|
10
|
+
|
|
8
11
|
// The "srt:*" lattice runner modules are declared in ./runtime-modules.d.ts
|
|
9
12
|
// (referenced above) - ambient `declare module` only reaches consumers from a
|
|
10
13
|
// non-module declaration file, and this file is a module.
|
|
@@ -239,6 +242,10 @@ export interface WheelEvent extends PointerEvent {
|
|
|
239
242
|
// layout-dependent value ("a", "!", "Enter", "ArrowLeft"); `code` is the
|
|
240
243
|
// physical, layout-independent key position ("KeyA", "Digit1", "NumpadEnter").
|
|
241
244
|
// Printable characters for text entry arrive via onTextInput, not here.
|
|
245
|
+
// Routing: keydown/keyup dispatch along the focused node's ancestor chain,
|
|
246
|
+
// leaf->root, always ending at the window root; with nothing focused they go
|
|
247
|
+
// to the window root alone. <window onKeyDown> is therefore the app-global
|
|
248
|
+
// shortcut point.
|
|
242
249
|
export interface KeyEvent {
|
|
243
250
|
key: string
|
|
244
251
|
code: string
|
|
@@ -247,6 +254,16 @@ export interface KeyEvent {
|
|
|
247
254
|
ctrlKey: boolean
|
|
248
255
|
altKey: boolean
|
|
249
256
|
metaKey: boolean
|
|
257
|
+
/** Node id whose handler is currently running (bubbling changes it per call). */
|
|
258
|
+
currentTarget: number
|
|
259
|
+
/** Node id the dispatch started at: the focused node, or the window root when nothing is focused. */
|
|
260
|
+
target: number
|
|
261
|
+
/**
|
|
262
|
+
* Stops the event from reaching ancestor handlers. A component that consumed
|
|
263
|
+
* the key calls this so enclosing handlers (and app-global shortcuts on the
|
|
264
|
+
* window) do not also act on it.
|
|
265
|
+
*/
|
|
266
|
+
stopPropagation: () => void
|
|
250
267
|
}
|
|
251
268
|
|
|
252
269
|
export interface TextEvent {
|
|
@@ -265,6 +282,20 @@ export interface PointerProps {
|
|
|
265
282
|
onKeyDown?: (event: KeyEvent) => void
|
|
266
283
|
onKeyUp?: (event: KeyEvent) => void
|
|
267
284
|
onTextInput?: (event: TextEvent) => void
|
|
285
|
+
/**
|
|
286
|
+
* IME behavior for this node's text-entry sessions (keyboard type,
|
|
287
|
+
* capitalization, autocorrect); read when a session starts. Without it the
|
|
288
|
+
* OS defaults apply - notably sentence auto-capitalization, which
|
|
289
|
+
* identifier fields and terminals want off:
|
|
290
|
+
* `textInputHints={{ capitalize: "none", autocorrect: false }}`.
|
|
291
|
+
*/
|
|
292
|
+
textInputHints?: TextInputHints
|
|
293
|
+
/**
|
|
294
|
+
* Declares the element a candidate for focus navigation, enumerable via
|
|
295
|
+
* getFocusables(). Candidacy only - it changes no behavior by itself; focus
|
|
296
|
+
* still moves through setFocus.
|
|
297
|
+
*/
|
|
298
|
+
focusable?: boolean
|
|
268
299
|
pointerEvents?: "auto" | "none" | "all"
|
|
269
300
|
}
|
|
270
301
|
|
|
@@ -413,12 +444,18 @@ export interface ViewProps extends ViewOwnProps, LayoutProps {}
|
|
|
413
444
|
|
|
414
445
|
// draw primitives
|
|
415
446
|
|
|
447
|
+
// A stroked rect paints inside its box, like a CSS border: the stroke's outer
|
|
448
|
+
// edge sits on the box edge rather than straddling it, so nothing bleeds past
|
|
449
|
+
// the box for a clip to cut. `path` and `line` strokes stay centered on their
|
|
450
|
+
// geometry - there the geometry is the stroke, not a box.
|
|
416
451
|
export interface RectProps extends PaintProps, PointerProps {
|
|
417
|
-
// Corner radius
|
|
418
|
-
// [top-left, top-right,
|
|
452
|
+
// Corner radius, measured on the box (the stroke's outer edge). A single
|
|
453
|
+
// number applies to all four corners; an array is [top-left, top-right,
|
|
454
|
+
// bottom-right, bottom-left] (CSS border-radius order).
|
|
419
455
|
radius?: number | [number, number, number, number]
|
|
420
456
|
}
|
|
421
457
|
|
|
458
|
+
// Strokes paint inside the box, same as `RectProps`.
|
|
422
459
|
export interface OvalProps extends PaintProps, PointerProps {}
|
|
423
460
|
|
|
424
461
|
export interface LineProps extends PaintProps, PointerProps {
|
package/src/window.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { requestFrame } from "flux:rendertree"
|
|
|
3
3
|
import { renderFrame } from "srt:render"
|
|
4
4
|
import { on, once } from "srt:events"
|
|
5
5
|
import { exit } from "srt:app"
|
|
6
|
-
import { getEventHandler,
|
|
7
|
-
import { scanForOrphans } from "./renderer"
|
|
6
|
+
import { getEventHandler, focusedNode, setFocus, activateTextInput, setInterestRoot } from "./core"
|
|
7
|
+
import { scanForOrphans, getNodePath } from "./renderer"
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Leaves the current app, unconditionally: back to the launcher in a dev
|
|
@@ -228,7 +228,10 @@ export function onBack(fn: (e: BackEvent) => void) {
|
|
|
228
228
|
|
|
229
229
|
// ------ Window ----------------
|
|
230
230
|
|
|
231
|
-
export function attachWindow(
|
|
231
|
+
export function attachWindow(nodeId: number) {
|
|
232
|
+
// The root carries the ambient move-interest bit for global onPointerMove
|
|
233
|
+
// subscribers (it is on every hit path); see core.setInterestRoot.
|
|
234
|
+
setInterestRoot(nodeId)
|
|
232
235
|
let unsubscribe: () => void = null!
|
|
233
236
|
let unsubDown: () => void = null!
|
|
234
237
|
let unsubUp: () => void = null!
|
|
@@ -304,11 +307,16 @@ export function attachWindow(_nodeId: number) {
|
|
|
304
307
|
|
|
305
308
|
unsubDown = on("pointerDown", (raw: RawPointer) => {
|
|
306
309
|
bubble(raw, "onPointerDown")
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
let focused =
|
|
310
|
+
// Read focus AFTER per-node handlers so a tap that moves focus to a new
|
|
311
|
+
// node is not immediately blurred again.
|
|
312
|
+
let focused = focusedNode()
|
|
310
313
|
if (focused != null && !raw.targets.includes(focused)) {
|
|
314
|
+
// Outside-tap blur.
|
|
311
315
|
setFocus(null)
|
|
316
|
+
} else if (focused != null) {
|
|
317
|
+
// A tap on the focused node is the interaction that lets a pending
|
|
318
|
+
// text session raise the on-screen keyboard.
|
|
319
|
+
activateTextInput()
|
|
312
320
|
}
|
|
313
321
|
})
|
|
314
322
|
|
|
@@ -332,19 +340,31 @@ export function attachWindow(_nodeId: number) {
|
|
|
332
340
|
bubble(raw, "onWheel")
|
|
333
341
|
})
|
|
334
342
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
343
|
+
// Key events dispatch along the focused node's ancestor chain, leaf->root
|
|
344
|
+
// (the pointer bubbling contract), so a container hears keys from focused
|
|
345
|
+
// descendants and the window root hears everything: <window onKeyDown> is
|
|
346
|
+
// the app-global shortcut point. With nothing focused the path is the
|
|
347
|
+
// window root alone - key events are never dropped. The path is resolved
|
|
348
|
+
// at dispatch time from current focus (nothing to freeze: keyup follows
|
|
349
|
+
// focus, as in the DOM).
|
|
350
|
+
let dispatchKey = (raw: any, handler: string) => {
|
|
351
|
+
let target = focusedNode() ?? nodeId
|
|
352
|
+
let stopped = false
|
|
353
|
+
let e = { ...raw, target, stopPropagation: () => (stopped = true) }
|
|
354
|
+
let path = getNodePath(target)
|
|
355
|
+
// A focused node detached this tick has no chain to the root; the
|
|
356
|
+
// window root must still hear the key.
|
|
357
|
+
if (path[path.length - 1] !== nodeId) path.push(nodeId)
|
|
358
|
+
for (let id of path) {
|
|
359
|
+
e.currentTarget = id
|
|
360
|
+
getEventHandler(id, handler)?.(e)
|
|
361
|
+
if (stopped) break
|
|
339
362
|
}
|
|
340
|
-
}
|
|
363
|
+
}
|
|
341
364
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
getEventHandler(id, "onKeyUp")?.(e)
|
|
346
|
-
}
|
|
347
|
-
})
|
|
365
|
+
unsubKeyDown = on("keydown", (raw: any) => dispatchKey(raw, "onKeyDown"))
|
|
366
|
+
|
|
367
|
+
unsubKeyUp = on("keyup", (raw: any) => dispatchKey(raw, "onKeyUp"))
|
|
348
368
|
|
|
349
369
|
unsubBack = on("back", () => {
|
|
350
370
|
let prevented = false
|
|
@@ -361,7 +381,7 @@ export function attachWindow(_nodeId: number) {
|
|
|
361
381
|
})
|
|
362
382
|
|
|
363
383
|
unsubTextInput = on("textInput", (e: any) => {
|
|
364
|
-
let id =
|
|
384
|
+
let id = focusedNode()
|
|
365
385
|
if (id != null) {
|
|
366
386
|
getEventHandler(id, "onTextInput")?.(e)
|
|
367
387
|
}
|
|
@@ -386,6 +406,7 @@ export function attachWindow(_nodeId: number) {
|
|
|
386
406
|
})
|
|
387
407
|
|
|
388
408
|
onCleanup(() => {
|
|
409
|
+
setInterestRoot(null)
|
|
389
410
|
if (unsubscribe) unsubscribe()
|
|
390
411
|
if (unsubDown) unsubDown()
|
|
391
412
|
if (unsubUp) unsubUp()
|