@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/AGENTS.md +14 -3
- package/README.md +1 -1
- package/examples/README.md +5 -4
- package/examples/gpu-instancing.tsx +70 -0
- package/examples/gpu-particles.tsx +35 -35
- package/examples/gpu-pipeline.tsx +40 -37
- package/examples/gpu-raw-program.tsx +59 -40
- package/examples/gpu-shader.tsx +48 -41
- package/examples/gpu-texture-blend.tsx +20 -19
- package/examples/inline-image.tsx +1 -1
- package/examples/parse-svg.tsx +94 -0
- package/examples/text-import.tsx +2 -2
- package/examples/wave.glsl +5 -3
- package/examples/window-shader-history.tsx +23 -23
- package/examples/window-shader.tsx +30 -28
- package/jsx-runtime.d.ts +17 -9
- package/package.json +2 -2
- package/src/camera.ts +8 -4
- package/src/color.ts +14 -2
- package/src/core.ts +254 -15
- package/src/environment.ts +16 -3
- package/src/gpu.ts +176 -75
- package/src/image.ts +12 -11
- package/src/index.ts +6 -2
- package/src/renderer.ts +32 -11
- package/src/runtime-modules.d.ts +2 -2
- package/src/speech-recognition.ts +2 -1
- package/src/svg.ts +71 -0
- package/src/types.d.ts +111 -38
- package/src/window.ts +39 -18
- package/examples/svg.tsx +0 -49
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
|
}
|