@solidrt/core 0.0.41 → 0.0.43

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 CHANGED
@@ -24,6 +24,15 @@ hardcode desktop pixels.
24
24
  (<600), `medium` (600-840), `expanded` (>=840). Drive column counts / layout
25
25
  switches off it (see the responsive-grid example).
26
26
 
27
+ Exception - fixed-aspect content. For content with fixed internal geometry
28
+ (diagrams, slides, dashboards, games, emulators), do not branch on window size
29
+ at all: author everything in one design space and let `viewBox` fit it.
30
+ `<view flex={1} viewBox={[1280, 800]}>` uniformly scales and centers the
31
+ children (letterboxed), pointer events on them arrive in design coordinates,
32
+ and the same code runs unchanged from a desktop window to a phone. Reach for
33
+ `windowSizeClass` branching only when the layout genuinely reflows across form
34
+ factors.
35
+
27
36
  `env` and `capabilities` (both exported from `@solidrt/core`) are the two
28
37
  objects that expose this. They are plain objects with REACTIVE GETTERS, not
29
38
  functions - read them as `capabilities.windowSizeClass`, `env.displayScale`
@@ -104,10 +113,11 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
104
113
  Outlines: `drawStyle="stroke"` (or "stroke-and-fill") plus `strokeWidth`.
105
114
  Corner radius on draw primitives: `radius` (number or [tl, tr, br, bl]).
106
115
 
107
- - Registered JSX intrinsics: `window`, `view`, `text`, `rect`, `oval`, `path`,
108
- `texture`, `audio`, plus the `d-` variants `d-view`, `d-rect`, `d-oval`,
109
- `d-path`, `d-texture`, `d-text`. NOTE: `<line>` has a LineProps type but is
110
- NOT a registered intrinsic - it will not typecheck.
116
+ - Registered JSX intrinsics: `window`, `view`, `text`, `rect`, `oval`, `line`,
117
+ `path`, `texture`, `audio`, plus the `d-` variants `d-view`, `d-rect`,
118
+ `d-oval`, `d-line`, `d-path`, `d-texture`, `d-text`. Line endpoints
119
+ (`x1`/`y1`/`x2`/`y2`) exist only on `d-line`; a laid-out `<line>` has no
120
+ endpoint props and spans its layout box corner to corner.
111
121
 
112
122
  - Plain vs `d-` variant (the `d-` prefix means "detached" - detached from the
113
123
  layout engine, Taffy): a plain element (e.g. `rect`) is `RectProps &
@@ -119,6 +129,13 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
119
129
  directly-positioned, often-animating elements (e.g. hundreds of balls), `d-`
120
130
  skips the per-element layout that plain elements would incur.
121
131
 
132
+ - Transform origin on a `d-view`: with `originX`/`originY` unset, scale/rotate
133
+ pivot at the view's local (0,0) - the origin its children's coordinates are
134
+ authored against - not at a box center (a laid-out view pivots at its own
135
+ box center; a d-view has no box). To scale a detached group around its
136
+ content's center, set the origin explicitly in pixels, e.g.
137
+ `originX={100} originY={50}` for content drawn in a 200x100 local space.
138
+
122
139
  - Layout-affecting vs not (this matters for per-frame work). Props fall in three
123
140
  buckets, split by where they take effect:
124
141
  - `LayoutProps` - width/height, min/max sizes, margin, padding, `position` and
@@ -137,10 +154,25 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
137
154
  `position:absolute` at `left:0,top:0`, or just let normal flow place it) and
138
155
  then translate it with `x`/`y`.
139
156
 
157
+ - JSX text children collapse whitespace (ordinary JSX semantics): runs of
158
+ spaces become one, so space-padding a mono label collapses silently. An
159
+ expression container preserves it - `<d-text>{"one two"}</d-text>` - and
160
+ `\n` inside one produces a hard line break.
161
+
140
162
  - Events: there is NO `onClick`/`onPress`. A "button" is a `<view>`/`<rect>`
141
163
  with `onPointerDown`. Handlers: onPointerDown/Up/Move/Enter/Leave, onWheel,
142
164
  onKeyDown/Up, onTextInput, onFocus/onBlur. Text entry: focus a node with an
143
- `onTextInput` handler (setFocus activates the on-screen keyboard).
165
+ `onTextInput` handler. Focus alone never raises the on-screen keyboard: a
166
+ tap on the focused node (or explicit startTextInput()) does, and never
167
+ while a physical keyboard is attached; on keyboard-equipped platforms the
168
+ session starts invisibly at focus so text flows immediately.
169
+ `textInputHints` on the node configures the IME (type/capitalize/
170
+ autocorrect) - identifier fields and terminals want
171
+ `{ capitalize: "none", autocorrect: false }` (OS default auto-capitalizes). Key
172
+ events start at the focused node and bubble leaf->root to the window (with
173
+ nothing focused, the window alone), so `<window onKeyDown>` is the
174
+ app-global shortcut point; `stopPropagation()` ends the walk. `focusable`
175
+ declares focus-navigation candidacy (enumerate via getFocusables()).
144
176
 
145
177
  - Reactivity is SolidJS 2.0 (`@solidjs/signals`), NOT Solid 1.x. `createSignal`
146
178
  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.41",
3
+ "version": "0.0.43",
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.41"
30
+ "@solidrt/flux-types": "0.0.43"
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
- 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
  }
package/src/gpu.ts CHANGED
@@ -60,6 +60,13 @@ export type CreateOptions = { manual?: boolean; label?: string }
60
60
  export type SamplerOptions = { filter?: gpu.FilterMode; wrap?: gpu.WrapMode }
61
61
  export type { FilterMode, WrapMode } from "flux:gpu"
62
62
 
63
+ // Pixel format option for the pixel-upload creates (createTexture,
64
+ // createMutableTexture), fixed for the id's lifetime like the sampler state.
65
+ // "rgba8" (default) or "r8" - see TextureFormat in flux:gpu for the r8
66
+ // contract (1 byte/pixel, sampled as `(v, 0, 0, 1)`, any width).
67
+ export type TextureFormatOptions = { format?: gpu.TextureFormat }
68
+ export type { TextureFormat } from "flux:gpu"
69
+
63
70
  // The branded id types, one per id space (see flux:gpu): plain numbers at
64
71
  // runtime, distinct types to the checker, so a cross-space slip like
65
72
  // destroyBuffer(textureId) fails to compile. Exported so apps can annotate
@@ -166,21 +173,22 @@ export let glsl = String.raw
166
173
  export { captureSnapshot, readTexture } from "flux:gpu"
167
174
 
168
175
  /**
169
- * Uploads raw RGBA8 pixels to an immutable GPU texture and returns its id (use
170
- * it as `<texture src={id} />`). `data` must be exactly `width * height * 4`
171
- * bytes; a mismatch throws. For pixels you intend to mutate and re-upload, use
172
- * `createMutableTexture` instead. When called inside a reactive scope the
173
- * texture is freed automatically once that owner is disposed; when called
174
- * outside one (e.g. after an `await`, where the owner is no longer current)
175
- * nothing is registered and you must call `destroyTexture` (from flux:gpu)
176
- * yourself. Pass `{ manual: true }` to skip the auto-free and own the
177
- * disposal yourself even inside a reactive scope.
176
+ * Uploads raw pixels to an immutable GPU texture and returns its id (use it
177
+ * as `<texture src={id} />`). `data` must be exactly `width * height` pixels
178
+ * at the declared format's size (`* 4` bytes for the default "rgba8", `* 1`
179
+ * for "r8"); a mismatch throws. For pixels you intend to mutate and
180
+ * re-upload, use `createMutableTexture` instead. When called inside a
181
+ * reactive scope the texture is freed automatically once that owner is
182
+ * disposed; when called outside one (e.g. after an `await`, where the owner
183
+ * is no longer current) nothing is registered and you must call
184
+ * `destroyTexture` (from flux:gpu) yourself. Pass `{ manual: true }` to skip
185
+ * the auto-free and own the disposal yourself even inside a reactive scope.
178
186
  */
179
187
  export function createTexture(
180
188
  data: Uint8Array,
181
189
  width: number,
182
190
  height: number,
183
- opts?: CreateOptions & SamplerOptions,
191
+ opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
184
192
  ): gpu.TextureId {
185
193
  let id = gpu.createTexture(data, width, height, opts)
186
194
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
@@ -189,18 +197,19 @@ export function createTexture(
189
197
 
190
198
  /**
191
199
  * Creates a GPU texture you intend to update over time: seed it with `data`,
192
- * then call `uploadTexture(id, data)` (from flux:gpu) to push new pixels. `data`
193
- * is RGBA8 and must hold at least `width * height * 4` bytes (it may hold several
194
- * frames). Like `createTexture`, the texture is freed automatically when the
195
- * reactive owner is disposed (opt out with `{ manual: true }`); created
196
- * outside a reactive scope you must call `destroyTexture` (from flux:gpu)
197
- * yourself.
200
+ * then call `uploadTexture(id, data)` (from flux:gpu) to push new pixels.
201
+ * `data` must hold at least `width * height` pixels at the declared format's
202
+ * size (`* 4` bytes for the default "rgba8", `* 1` for "r8"; it may hold
203
+ * several frames). Like `createTexture`, the texture is freed automatically
204
+ * when the reactive owner is disposed (opt out with `{ manual: true }`);
205
+ * created outside a reactive scope you must call `destroyTexture` (from
206
+ * flux:gpu) yourself.
198
207
  */
199
208
  export function createMutableTexture(
200
209
  data: Uint8Array,
201
210
  width: number,
202
211
  height: number,
203
- opts?: CreateOptions & SamplerOptions,
212
+ opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
204
213
  ): gpu.TextureId {
205
214
  let id = gpu.createMutableTexture(data, width, height, opts)
206
215
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
@@ -237,7 +246,9 @@ export function createMutableTexture(
237
246
  * as written, so a shader carrying its own uniform names - one ported from
238
247
  * elsewhere - runs unchanged here without dropping to compileShader /
239
248
  * linkProgram. The built-in vertex stage still supplies `vUV`; declare
240
- * `in vec2 vUV;` yourself to read it.
249
+ * `in vec2 vUV;` yourself to read it. One naming trap: GLSL ES reserves
250
+ * `packed` as a keyword, so `vec4 packed = texture(...)` fails with a syntax
251
+ * error that does not name the identifier - pick another name.
241
252
  */
242
253
  export function createShaderTexture(
243
254
  fragmentSrc: string,
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export * from "./renderer"
2
- export { setFocus, getFocusedNodeId, measureText, getBoundingBox, getBoundingBoxViewport } from "./core"
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, cleanupNodeHandlers, getFocusedNodeId, setFocus } from "./core"
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 === getFocusedNodeId()) setFocus(null)
56
+ if (n.id === focusedNode()) setFocus(null)
47
57
  nodes.delete(n.id)
48
- cleanupNodeHandlers(n.id)
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/sound.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // a large track from a file path on demand instead of decoding it into memory.
6
6
  //
7
7
  // The imperative primitive lives in the `flux:audio` module; import
8
- // { play, load, stream } from "flux:audio" for non-reactive use.
8
+ // { play, load, loadPcm, stream } from "flux:audio" for non-reactive use.
9
9
 
10
10
  import { createSignal, onCleanup } from "@solidjs/signals"
11
11
  import { load, stream } from "flux:audio"
@@ -13,13 +13,19 @@ import { file } from "flux:fs"
13
13
 
14
14
  type FluxFile = ReturnType<typeof file>
15
15
 
16
- type LoadedSound = ReturnType<typeof load>
16
+ type Clip = ReturnType<typeof load>
17
+ type Playback = ReturnType<Clip["play"]>
17
18
 
18
19
  export type SoundOptions = {
19
20
  /** Repeat the clip until stopped. Defaults to false. */
20
21
  loop?: boolean
21
22
  /** Volume scale, 1.0 leaves the clip unchanged. Defaults to 1.0. */
22
23
  gain?: number
24
+ /**
25
+ * Stereo position in [-1, 1], -1 = left, 0 = center, 1 = right (equal-power).
26
+ * Omitted means unspatialized.
27
+ */
28
+ pan?: number
23
29
  /**
24
30
  * Let play() stack overlapping voices instead of restarting. Defaults to
25
31
  * true: rapid triggers overlap. Set false for a single-voice sound where each
@@ -34,6 +40,8 @@ export type SoundStreamOptions = {
34
40
  loop?: boolean
35
41
  /** Volume scale, 1.0 leaves the track unchanged. Defaults to 1.0. */
36
42
  gain?: number
43
+ /** Stereo position in [-1, 1] (see {@link SoundOptions.pan}). */
44
+ pan?: number
37
45
  }
38
46
 
39
47
  /** A decoded sound with reactive lifecycle. */
@@ -42,30 +50,45 @@ export type Sound = {
42
50
  play(): void
43
51
  /** Stop every voice started from this sound. */
44
52
  stop(): void
53
+ /** Set the volume of every live voice, and of voices started later. */
54
+ setGain(gain: number): void
55
+ /** Set the stereo position of every live voice, and of voices started later. */
56
+ setPan(pan: number): void
45
57
  /** True after play() until stop() (does not track natural completion). */
46
58
  playing(): boolean
47
59
  /** Set if loading failed. */
48
60
  error(): Error | undefined
49
61
  }
50
62
 
51
- // Shared reactive wrapper: owns the loaded handle, tracks live voices, and
63
+ // Shared reactive wrapper: owns the loaded clip, tracks live voices, and
52
64
  // disposes both on cleanup. `loader` runs once (may throw -> error signal).
65
+ // Gain and pan are remembered so later voices start where setGain/setPan left
66
+ // the sound, not back at the initial options.
53
67
  function reactiveSound(
54
- loader: () => LoadedSound,
68
+ loader: () => Clip,
55
69
  overlap: boolean,
56
- playOptions: { loop?: boolean; gain?: number },
70
+ initial: { loop?: boolean; gain?: number; pan?: number },
57
71
  ): Sound {
58
72
  let [error, setError] = createSignal<Error | undefined>(undefined, { ownedWrite: true })
59
73
  let [playing, setPlaying] = createSignal(false, { ownedWrite: true })
60
74
 
61
- let handle: LoadedSound | undefined
62
- let voices: { stop(): void }[] = []
75
+ let clip: Clip | undefined
76
+ let voices: Playback[] = []
77
+ let loop = initial.loop
78
+ let gain = initial.gain
79
+ let pan = initial.pan
63
80
  try {
64
- handle = loader()
81
+ clip = loader()
65
82
  } catch (e) {
66
83
  setError(e instanceof Error ? e : new Error(String(e)))
67
84
  }
68
85
 
86
+ // Voices that finished on their own keep a dead handle in `voices` until the
87
+ // next call here; ended() lets each touch point clear them out.
88
+ let prune = () => {
89
+ voices = voices.filter((v) => !v.ended())
90
+ }
91
+
69
92
  let stopAll = () => {
70
93
  for (let v of voices) v.stop()
71
94
  voices = []
@@ -74,20 +97,31 @@ function reactiveSound(
74
97
 
75
98
  onCleanup(() => {
76
99
  stopAll()
77
- if (handle) {
78
- handle.unload()
79
- handle = undefined
100
+ if (clip) {
101
+ clip.unload()
102
+ clip = undefined
80
103
  }
81
104
  })
82
105
 
83
106
  return {
84
107
  play() {
85
- if (!handle) return
86
- if (!overlap) stopAll()
87
- voices.push(handle.play(playOptions))
108
+ if (!clip) return
109
+ if (overlap) prune()
110
+ else stopAll()
111
+ voices.push(clip.play({ loop, gain, pan }))
88
112
  setPlaying(true)
89
113
  },
90
114
  stop: stopAll,
115
+ setGain(value) {
116
+ gain = value
117
+ prune()
118
+ for (let v of voices) v.setGain(value)
119
+ },
120
+ setPan(value) {
121
+ pan = value
122
+ prune()
123
+ for (let v of voices) v.setPan(value)
124
+ },
91
125
  playing,
92
126
  error,
93
127
  }
@@ -102,6 +136,7 @@ export function createSound(source: Uint8Array, options: SoundOptions = {}): Sou
102
136
  return reactiveSound(() => load(source), options.overlap ?? true, {
103
137
  loop: options.loop,
104
138
  gain: options.gain,
139
+ pan: options.pan,
105
140
  })
106
141
  }
107
142
 
@@ -115,5 +150,5 @@ export function createSound(source: Uint8Array, options: SoundOptions = {}): Sou
115
150
  */
116
151
  export function createSoundStream(source: string | FluxFile, options: SoundStreamOptions = {}): Sound {
117
152
  let src = typeof source === "string" ? file(source) : source
118
- return reactiveSound(() => stream(src), false, { loop: options.loop, gain: options.gain })
153
+ return reactiveSound(() => stream(src), false, { loop: options.loop, gain: options.gain, pan: options.pan })
119
154
  }
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.
@@ -154,7 +157,12 @@ export type Pct = { readonly __unit: "pct"; v: number }
154
157
  // One axis of the transform origin (the point rotate/scale/3D pivot around),
155
158
  // split per axis to match the engine's x/y prop convention. A bare number is
156
159
  // pixels; `pct(50)` is a fraction of the box, so a percentage origin tracks the
157
- // layout size with no reactive wiring. Unset defaults to the axis center.
160
+ // layout size with no reactive wiring. Unset defaults to the axis center on a
161
+ // laid-out view; on a d-view (no box of its own) it defaults to the view's
162
+ // local (0,0) - the origin its children's coordinates are authored against, so
163
+ // the pivot never depends on the inherited box. To pivot a d-view around its
164
+ // content's center, set the origin explicitly in pixels; pct()/keywords on a
165
+ // d-view resolve against the inherited box, which is rarely what you want.
158
166
  type OriginX = number | Pct | "left" | "center" | "right"
159
167
  type OriginY = number | Pct | "top" | "center" | "bottom"
160
168
 
@@ -182,7 +190,11 @@ export interface TransformProps {
182
190
  originX?: OriginX
183
191
  originY?: OriginY
184
192
  // Group opacity in 0..1: children are composited together, then faded as a
185
- // whole (CSS `opacity`). Does not affect hit testing.
193
+ // whole (CSS `opacity`). Does not affect hit testing. Costs a compositing
194
+ // layer (save_layer around the subtree) while below 1, except on a
195
+ // repaintBoundary view, where it is hoisted to composite time for free. To
196
+ // fade a single primitive, put the alpha in its `color` (rgba) instead -
197
+ // paint alpha costs nothing.
186
198
  opacity?: number
187
199
  scrollX?: number
188
200
  scrollY?: number
@@ -239,6 +251,10 @@ export interface WheelEvent extends PointerEvent {
239
251
  // layout-dependent value ("a", "!", "Enter", "ArrowLeft"); `code` is the
240
252
  // physical, layout-independent key position ("KeyA", "Digit1", "NumpadEnter").
241
253
  // Printable characters for text entry arrive via onTextInput, not here.
254
+ // Routing: keydown/keyup dispatch along the focused node's ancestor chain,
255
+ // leaf->root, always ending at the window root; with nothing focused they go
256
+ // to the window root alone. <window onKeyDown> is therefore the app-global
257
+ // shortcut point.
242
258
  export interface KeyEvent {
243
259
  key: string
244
260
  code: string
@@ -247,6 +263,16 @@ export interface KeyEvent {
247
263
  ctrlKey: boolean
248
264
  altKey: boolean
249
265
  metaKey: boolean
266
+ /** Node id whose handler is currently running (bubbling changes it per call). */
267
+ currentTarget: number
268
+ /** Node id the dispatch started at: the focused node, or the window root when nothing is focused. */
269
+ target: number
270
+ /**
271
+ * Stops the event from reaching ancestor handlers. A component that consumed
272
+ * the key calls this so enclosing handlers (and app-global shortcuts on the
273
+ * window) do not also act on it.
274
+ */
275
+ stopPropagation: () => void
250
276
  }
251
277
 
252
278
  export interface TextEvent {
@@ -265,6 +291,20 @@ export interface PointerProps {
265
291
  onKeyDown?: (event: KeyEvent) => void
266
292
  onKeyUp?: (event: KeyEvent) => void
267
293
  onTextInput?: (event: TextEvent) => void
294
+ /**
295
+ * IME behavior for this node's text-entry sessions (keyboard type,
296
+ * capitalization, autocorrect); read when a session starts. Without it the
297
+ * OS defaults apply - notably sentence auto-capitalization, which
298
+ * identifier fields and terminals want off:
299
+ * `textInputHints={{ capitalize: "none", autocorrect: false }}`.
300
+ */
301
+ textInputHints?: TextInputHints
302
+ /**
303
+ * Declares the element a candidate for focus navigation, enumerable via
304
+ * getFocusables(). Candidacy only - it changes no behavior by itself; focus
305
+ * still moves through setFocus.
306
+ */
307
+ focusable?: boolean
268
308
  pointerEvents?: "auto" | "none" | "all"
269
309
  }
270
310
 
@@ -413,14 +453,27 @@ export interface ViewProps extends ViewOwnProps, LayoutProps {}
413
453
 
414
454
  // draw primitives
415
455
 
456
+ // A stroked rect paints inside its box, like a CSS border: the stroke's outer
457
+ // edge sits on the box edge rather than straddling it, so nothing bleeds past
458
+ // the box for a clip to cut. `path` and `line` strokes stay centered on their
459
+ // geometry - there the geometry is the stroke, not a box.
416
460
  export interface RectProps extends PaintProps, PointerProps {
417
- // Corner radius. A single number applies to all four corners; an array is
418
- // [top-left, top-right, bottom-right, bottom-left] (CSS border-radius order).
461
+ // Corner radius, measured on the box (the stroke's outer edge). A single
462
+ // number applies to all four corners; an array is [top-left, top-right,
463
+ // bottom-right, bottom-left] (CSS border-radius order).
419
464
  radius?: number | [number, number, number, number]
420
465
  }
421
466
 
467
+ // Strokes paint inside the box, same as `RectProps`.
422
468
  export interface OvalProps extends PaintProps, PointerProps {}
423
469
 
470
+ // A line's geometry is numbers, not a path string: the segment primitive to
471
+ // reach for when endpoints move (each endpoint is one property write; a path
472
+ // animates by rebuilding its `d` string). Endpoints (x1/y1/x2/y2) exist on
473
+ // the detached `d-line` only. A laid-out `<line>` is practically a rule -
474
+ // give it a thin box (length x strokeWidth); in general it draws its layout
475
+ // box's top-left-to-bottom-right diagonal. For arbitrary angles and
476
+ // connectors use `d-line`; for polylines and curves, a path.
424
477
  export interface LineProps extends PaintProps, PointerProps {
425
478
  /** Dash pattern in local units: the drawn segment length. Both onLength and offLength must be set to dash; with either unset the line is solid. */
426
479
  onLength?: number
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, getFocusedNodeId, setFocus } from "./core"
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(_nodeId: number) {
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
- // Outside-tap blur. Read focus AFTER per-node handlers so a tap that
308
- // moves focus to a new node is not immediately blurred again.
309
- let focused = getFocusedNodeId()
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
- unsubKeyDown = on("keydown", (e: any) => {
336
- let id = getFocusedNodeId()
337
- if (id != null) {
338
- getEventHandler(id, "onKeyDown")?.(e)
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
- unsubKeyUp = on("keyup", (e: any) => {
343
- let id = getFocusedNodeId()
344
- if (id != null) {
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 = getFocusedNodeId()
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()