@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 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 (setFocus activates the on-screen keyboard).
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.41",
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.41"
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
- 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/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/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. A single number applies to all four corners; an array is
418
- // [top-left, top-right, bottom-right, bottom-left] (CSS border-radius order).
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, 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()