@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 +37 -5
- package/package.json +2 -2
- package/src/core.ts +254 -15
- package/src/environment.ts +16 -3
- package/src/gpu.ts +29 -18
- package/src/index.ts +3 -2
- package/src/renderer.ts +23 -3
- package/src/sound.ts +50 -15
- package/src/types.d.ts +57 -4
- package/src/window.ts +39 -18
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`, `
|
|
108
|
-
`texture`, `audio`, plus the `d-` variants `d-view`, `d-rect`,
|
|
109
|
-
`d-path`, `d-texture`, `d-text`.
|
|
110
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
126
|
+
// Cleans up every per-node registry entry (handlers, focus candidacy, text
|
|
127
|
+
// hints) when a node is destroyed.
|
|
128
|
+
export function cleanupNode(nodeId: number): void {
|
|
23
129
|
handlers.delete(nodeId)
|
|
130
|
+
// No setEventInterest call: the tree node is being destroyed with us.
|
|
131
|
+
interests.delete(nodeId)
|
|
132
|
+
focusables.delete(nodeId)
|
|
133
|
+
textHints.delete(nodeId)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Per-node IME hints, declared by the `textInputHints` prop (renderer.ts)
|
|
137
|
+
// and read when the node's text session starts.
|
|
138
|
+
let textHints = new Map<number, tree.TextInputHints>()
|
|
139
|
+
|
|
140
|
+
export function setTextInputHints(nodeId: number, hints: tree.TextInputHints | null | undefined): void {
|
|
141
|
+
if (hints == null) textHints.delete(nodeId)
|
|
142
|
+
else textHints.set(nodeId, hints)
|
|
24
143
|
}
|
|
25
144
|
|
|
26
|
-
// Currently-focused node id.
|
|
27
|
-
//
|
|
145
|
+
// Currently-focused node id and text-session state. Each is a plain field
|
|
146
|
+
// paired with a signal: signal writes flush on the microtask, so a read
|
|
147
|
+
// through the signal alone is stale within the very dispatch that wrote it -
|
|
148
|
+
// and focus logic reads what it just moved (a tap handler focusing a field,
|
|
149
|
+
// then the dispatcher deciding on that focus). The plain field is the
|
|
150
|
+
// always-current truth; the signal exists only to make tracked scopes re-run.
|
|
151
|
+
// Reset across engine reloads for free: the JS environment is rebuilt.
|
|
28
152
|
let focusedNodeId: number | null = null
|
|
29
|
-
let
|
|
153
|
+
let [trackFocusedNode, setFocusedNodeSignal] = createSignal<number | null>(null)
|
|
154
|
+
let textInputActiveNow = false
|
|
155
|
+
let [trackTextInputActive, setTextInputActiveSignal] = createSignal(false)
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The focused node id, or null - as a reactive accessor: read it inside a
|
|
159
|
+
* tracked scope (JSX, memo, effect) to re-run when focus moves; any read
|
|
160
|
+
* (including in the same dispatch as a setFocus) sees the current value.
|
|
161
|
+
* setFocus is the only writer.
|
|
162
|
+
*/
|
|
163
|
+
export function focusedNode(): number | null {
|
|
164
|
+
trackFocusedNode()
|
|
165
|
+
return focusedNodeId
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Whether a text-entry session is active on the focused node (text events
|
|
170
|
+
* flowing; the on-screen keyboard up, where one is used), as a reactive
|
|
171
|
+
* accessor. Distinct from focus: a field focused by navigation is not
|
|
172
|
+
* editing until a tap or startTextInput() begins the session. Lets a text
|
|
173
|
+
* field tell its focused and editing states apart (Enter starts editing in
|
|
174
|
+
* the former, submits in the latter).
|
|
175
|
+
*/
|
|
176
|
+
export function textInputActive(): boolean {
|
|
177
|
+
trackTextInputActive()
|
|
178
|
+
return textInputActiveNow
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The native window outlives engine reloads, so a previous session may have
|
|
182
|
+
// left its text input active; assert the known boot state.
|
|
183
|
+
tree.setTextInputActive(false)
|
|
184
|
+
|
|
185
|
+
// Facts for the text-session policy, from the sticky inputDevices event
|
|
186
|
+
// (init + hotplug). Conservative defaults until it arrives: assume a screen
|
|
187
|
+
// keyboard could appear, so an eager session start is never visibly wrong.
|
|
188
|
+
let screenKeyboard = true
|
|
189
|
+
let physicalKeyboard = false
|
|
190
|
+
on("inputDevices", (d: { keyboard?: boolean; screenKeyboard?: boolean }) => {
|
|
191
|
+
physicalKeyboard = !!d.keyboard
|
|
192
|
+
screenKeyboard = !!d.screenKeyboard
|
|
193
|
+
// Facts can arrive after a node was focused (boot ordering) or change under
|
|
194
|
+
// it (keyboard hotplug): re-evaluate the eager session so e.g. a
|
|
195
|
+
// mount-focused terminal starts receiving text without a tap.
|
|
196
|
+
syncTextInput(textInputEligible() && (textInputActive() || textInputInvisible()))
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// Focus candidacy, declared by the `focusable` prop (routed in renderer.ts).
|
|
200
|
+
// Candidacy only: navigation schemes enumerate candidates and move focus
|
|
201
|
+
// themselves via setFocus.
|
|
202
|
+
let focusables = new Set<number>()
|
|
203
|
+
|
|
204
|
+
export function setFocusable(nodeId: number, focusable: boolean): void {
|
|
205
|
+
if (focusable) focusables.add(nodeId)
|
|
206
|
+
else focusables.delete(nodeId)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Node ids currently declaring `focusable`, for building focus navigation
|
|
211
|
+
* (spatial/D-pad movement, tab order). A snapshot, not reactive; pair with
|
|
212
|
+
* getBoundingBoxViewport for their geometry.
|
|
213
|
+
*/
|
|
214
|
+
export function getFocusables(): number[] {
|
|
215
|
+
return [...focusables]
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function textInputEligible(): boolean {
|
|
219
|
+
return focusedNodeId != null && getEventHandler(focusedNodeId, "onTextInput") != null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Whether starting a text session shows nothing on screen: the platform has
|
|
223
|
+
// no screen keyboard, or a physical keyboard is attached (the runtime keeps
|
|
224
|
+
// the screen keyboard down natively then too). Invisible sessions start
|
|
225
|
+
// eagerly at focus, so desktops and keyboard-equipped devices deliver text
|
|
226
|
+
// from the moment a node is focused; visible ones wait for an interaction so
|
|
227
|
+
// a keyboard never appears without one (the pointerDown dispatch in
|
|
228
|
+
// window.ts, or an explicit startTextInput).
|
|
229
|
+
function textInputInvisible(): boolean {
|
|
230
|
+
return !screenKeyboard || physicalKeyboard
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The node whose session is running: a session that stays active while focus
|
|
234
|
+
// hops between text fields must restart on the new node so its IME hints
|
|
235
|
+
// (keyboard type, capitalization) take effect.
|
|
236
|
+
let sessionNodeId: number | null = null
|
|
237
|
+
|
|
238
|
+
function syncTextInput(active: boolean): void {
|
|
239
|
+
let target = active ? focusedNodeId : null
|
|
240
|
+
if (active === textInputActiveNow && target === sessionNodeId) return
|
|
241
|
+
textInputActiveNow = active
|
|
242
|
+
sessionNodeId = target
|
|
243
|
+
setTextInputActiveSignal(active)
|
|
244
|
+
if (active) tree.setTextInputActive(true, textHints.get(target!))
|
|
245
|
+
else tree.setTextInputActive(false)
|
|
246
|
+
}
|
|
30
247
|
|
|
31
248
|
/**
|
|
32
249
|
* Moves keyboard focus to `nodeId`, or clears it with `null`. Fires `onBlur` on
|
|
33
|
-
* the previously focused node and `onFocus` on the new one.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
250
|
+
* the previously focused node and `onFocus` on the new one. No-op if the node
|
|
251
|
+
* is already focused.
|
|
252
|
+
*
|
|
253
|
+
* Focus also scopes the text-entry session of a node with an `onTextInput`
|
|
254
|
+
* handler: where starting one is invisible (no screen keyboard, or a physical
|
|
255
|
+
* keyboard attached) it begins at focus; where it would raise an on-screen
|
|
256
|
+
* keyboard it waits for an interaction - a tap on the focused node, or
|
|
257
|
+
* startTextInput() - so focus alone never summons a keyboard. Focus moving
|
|
258
|
+
* between text nodes carries an active session along; focus leaving them ends
|
|
259
|
+
* it (and hides the keyboard).
|
|
37
260
|
*/
|
|
38
261
|
export function setFocus(nodeId: number | null): void {
|
|
39
262
|
if (nodeId === focusedNodeId) return
|
|
40
263
|
let oldId = focusedNodeId
|
|
41
264
|
focusedNodeId = nodeId
|
|
265
|
+
setFocusedNodeSignal(nodeId)
|
|
42
266
|
if (oldId != null) {
|
|
43
267
|
getEventHandler(oldId, "onBlur")?.()
|
|
44
268
|
}
|
|
45
269
|
if (nodeId != null) {
|
|
46
270
|
getEventHandler(nodeId, "onFocus")?.()
|
|
47
271
|
}
|
|
48
|
-
|
|
49
|
-
if (wantActive !== textInputActive) {
|
|
50
|
-
textInputActive = wantActive
|
|
51
|
-
tree.setTextInputActive(wantActive)
|
|
52
|
-
}
|
|
272
|
+
syncTextInput(textInputEligible() && (textInputActiveNow || textInputInvisible()))
|
|
53
273
|
}
|
|
54
274
|
|
|
55
|
-
|
|
56
|
-
|
|
275
|
+
// Interactive trigger: pointer dispatch calls this when a tap lands on the
|
|
276
|
+
// focused node (window.ts), the moment a pending session may raise the
|
|
277
|
+
// on-screen keyboard.
|
|
278
|
+
export function activateTextInput(): void {
|
|
279
|
+
if (textInputEligible()) syncTextInput(true)
|
|
57
280
|
}
|
|
58
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Begins text entry on the focused node: enables text-event delivery and, on
|
|
284
|
+
* platforms that use one (and with no physical keyboard attached), raises the
|
|
285
|
+
* on-screen keyboard. Focus alone never raises it; a tap on the focused node
|
|
286
|
+
* triggers this automatically, so call it only for other interactions that
|
|
287
|
+
* should (a remote's select on a focused field, a search button). Throws when
|
|
288
|
+
* the focused node has no onTextInput handler.
|
|
289
|
+
*/
|
|
290
|
+
export function startTextInput(): void {
|
|
291
|
+
if (!textInputEligible()) {
|
|
292
|
+
throw new Error("startTextInput: no focused node with an onTextInput handler")
|
|
293
|
+
}
|
|
294
|
+
syncTextInput(true)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
59
298
|
export interface BoundingBox {
|
|
60
299
|
x: number
|
|
61
300
|
y: number
|
package/src/environment.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { createSignal } from "@solidjs/signals"
|
|
1
|
+
import { createRoot, createSignal } from "@solidjs/signals"
|
|
2
2
|
import { on } from "srt:events"
|
|
3
|
+
import { onPointerMove } from "./core"
|
|
3
4
|
import { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } from "./window"
|
|
4
5
|
|
|
5
6
|
// Environment State: reactive facts about the current execution environment.
|
|
@@ -24,6 +25,8 @@ export interface InputDevices {
|
|
|
24
25
|
keyboard: boolean
|
|
25
26
|
mouse: boolean
|
|
26
27
|
touch: boolean
|
|
28
|
+
/** Whether the platform can present an on-screen keyboard. */
|
|
29
|
+
screenKeyboard: boolean
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export type SystemTheme = "dark" | "light" | "unknown"
|
|
@@ -40,7 +43,7 @@ function ensureDevicesState() {
|
|
|
40
43
|
// Sticky: the current state replays on subscribe, so the first read already
|
|
41
44
|
// sees it on runtimes that report devices.
|
|
42
45
|
on("inputDevices", (d: InputDevices) => {
|
|
43
|
-
setDevices({ keyboard: !!d.keyboard, mouse: !!d.mouse, touch: !!d.touch })
|
|
46
|
+
setDevices({ keyboard: !!d.keyboard, mouse: !!d.mouse, touch: !!d.touch, screenKeyboard: !!d.screenKeyboard })
|
|
44
47
|
})
|
|
45
48
|
devicesAccessor = devices
|
|
46
49
|
}
|
|
@@ -100,10 +103,15 @@ function ensurePointerState() {
|
|
|
100
103
|
let sawMouse = false
|
|
101
104
|
let sawTouch = false
|
|
102
105
|
let unsubs: (() => void)[] = []
|
|
106
|
+
let unsubMove: () => void = null!
|
|
103
107
|
let note = (e: { pointerType?: string }) => {
|
|
104
108
|
if (e.pointerType === "mouse" && !sawMouse) {
|
|
105
109
|
sawMouse = true
|
|
106
110
|
setMouse(true)
|
|
111
|
+
// Moves have nothing left to teach: touch is learned from downs (a
|
|
112
|
+
// touch never moves without one), so drop the move subscription and
|
|
113
|
+
// with it the ambient interest bit that forces move deliveries.
|
|
114
|
+
unsubMove()
|
|
107
115
|
} else if (e.pointerType === "touch" && !sawTouch) {
|
|
108
116
|
sawTouch = true
|
|
109
117
|
setTouch(true)
|
|
@@ -111,7 +119,12 @@ function ensurePointerState() {
|
|
|
111
119
|
// Both types observed: nothing left to learn, stop listening.
|
|
112
120
|
if (sawMouse && sawTouch) for (let u of unsubs) u()
|
|
113
121
|
}
|
|
114
|
-
|
|
122
|
+
// Downs always deliver, so the raw bus tap suffices; moves are gated when
|
|
123
|
+
// nobody listens, so they go through onPointerMove, whose subscription
|
|
124
|
+
// keeps them flowing. The probe is app-lifetime: createRoot detaches its
|
|
125
|
+
// scope cleanup from whatever computation first read the accessor.
|
|
126
|
+
unsubMove = createRoot(() => onPointerMove(note))
|
|
127
|
+
unsubs.push(unsubMove, on("pointerDown", note))
|
|
115
128
|
mouseSeenAccessor = mouse
|
|
116
129
|
touchSeenAccessor = touch
|
|
117
130
|
}
|
package/src/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
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
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.
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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,
|
|
3
|
-
export type { BoundingBox } from "./core"
|
|
2
|
+
export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, getBoundingBox, getBoundingBoxViewport, onPointerMove } from "./core"
|
|
3
|
+
export type { BoundingBox, GlobalPointerEvent } from "./core"
|
|
4
4
|
export { parseColor, mixColors, brightness, createLinearGradient, createRadialGradient } from "./color"
|
|
5
5
|
export type { Gradient, GradientStop } from "./color"
|
|
6
6
|
export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, exit } from "./window"
|
|
@@ -28,6 +28,7 @@ export type {
|
|
|
28
28
|
WheelEvent,
|
|
29
29
|
KeyEvent,
|
|
30
30
|
TextEvent,
|
|
31
|
+
TextInputHints,
|
|
31
32
|
PaintProps,
|
|
32
33
|
WindowProps,
|
|
33
34
|
WindowShaderProps,
|
package/src/renderer.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createRenderer } from "@solidjs/universal"
|
|
|
3
3
|
import type { Element } from "solid-js"
|
|
4
4
|
import * as tree from "flux:rendertree"
|
|
5
5
|
import { attachWindow } from "./window"
|
|
6
|
-
import { setEventHandler,
|
|
6
|
+
import { setEventHandler, setFocusable, setTextInputHints, cleanupNode, focusedNode, setFocus } from "./core"
|
|
7
7
|
import { parseColor, isGradient } from "./color"
|
|
8
8
|
|
|
9
9
|
export { getEventHandler } from "./core"
|
|
@@ -31,6 +31,16 @@ function createProxyNode(elementType: ElementType): ProxyNode {
|
|
|
31
31
|
return node
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Ancestor chain for event dispatch: node ids from `id` (inclusive) to the
|
|
35
|
+
// root, following the mount tree (a portaled node reports its mount point's
|
|
36
|
+
// chain, not its lexical one). Empty when the id is unknown.
|
|
37
|
+
export function getNodePath(id: number): number[] {
|
|
38
|
+
let path: number[] = []
|
|
39
|
+
let node: ProxyNode | undefined = nodes.get(id)
|
|
40
|
+
for (; node; node = node.parent) path.push(node.id)
|
|
41
|
+
return path
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
// Nodes detached this tick and awaiting the destroy sweep, keyed by id so a
|
|
35
45
|
// re-insert can cancel one. See removeNode / flushDestroy.
|
|
36
46
|
let pendingDestroy = new Map<number, ProxyNode>()
|
|
@@ -43,9 +53,9 @@ function destroyNode(node: ProxyNode): void {
|
|
|
43
53
|
tree.destroyNode(node.id)
|
|
44
54
|
let cleanup = (n: ProxyNode) => {
|
|
45
55
|
for (let child of n.children) if (child.parent === n) cleanup(child)
|
|
46
|
-
if (n.id ===
|
|
56
|
+
if (n.id === focusedNode()) setFocus(null)
|
|
47
57
|
nodes.delete(n.id)
|
|
48
|
-
|
|
58
|
+
cleanupNode(n.id)
|
|
49
59
|
}
|
|
50
60
|
cleanup(node)
|
|
51
61
|
}
|
|
@@ -163,6 +173,16 @@ function applyProp<T>(node: ProxyNode, name: string, value: T): void {
|
|
|
163
173
|
return
|
|
164
174
|
}
|
|
165
175
|
|
|
176
|
+
if (name === "focusable") {
|
|
177
|
+
setFocusable(node.id, value === true)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (name === "textInputHints") {
|
|
182
|
+
setTextInputHints(node.id, value as any)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
166
186
|
if (name === "color" && isGradient(value)) {
|
|
167
187
|
setTreeProperty(node, name, value)
|
|
168
188
|
return
|
package/src/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
|
|
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
|
|
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: () =>
|
|
68
|
+
loader: () => Clip,
|
|
55
69
|
overlap: boolean,
|
|
56
|
-
|
|
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
|
|
62
|
-
let voices:
|
|
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
|
-
|
|
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 (
|
|
78
|
-
|
|
79
|
-
|
|
100
|
+
if (clip) {
|
|
101
|
+
clip.unload()
|
|
102
|
+
clip = undefined
|
|
80
103
|
}
|
|
81
104
|
})
|
|
82
105
|
|
|
83
106
|
return {
|
|
84
107
|
play() {
|
|
85
|
-
if (!
|
|
86
|
-
if (
|
|
87
|
-
|
|
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
|
|
418
|
-
// [top-left, top-right,
|
|
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,
|
|
7
|
-
import { scanForOrphans } from "./renderer"
|
|
6
|
+
import { getEventHandler, focusedNode, setFocus, activateTextInput, setInterestRoot } from "./core"
|
|
7
|
+
import { scanForOrphans, getNodePath } from "./renderer"
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Leaves the current app, unconditionally: back to the launcher in a dev
|
|
@@ -228,7 +228,10 @@ export function onBack(fn: (e: BackEvent) => void) {
|
|
|
228
228
|
|
|
229
229
|
// ------ Window ----------------
|
|
230
230
|
|
|
231
|
-
export function attachWindow(
|
|
231
|
+
export function attachWindow(nodeId: number) {
|
|
232
|
+
// The root carries the ambient move-interest bit for global onPointerMove
|
|
233
|
+
// subscribers (it is on every hit path); see core.setInterestRoot.
|
|
234
|
+
setInterestRoot(nodeId)
|
|
232
235
|
let unsubscribe: () => void = null!
|
|
233
236
|
let unsubDown: () => void = null!
|
|
234
237
|
let unsubUp: () => void = null!
|
|
@@ -304,11 +307,16 @@ export function attachWindow(_nodeId: number) {
|
|
|
304
307
|
|
|
305
308
|
unsubDown = on("pointerDown", (raw: RawPointer) => {
|
|
306
309
|
bubble(raw, "onPointerDown")
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
let focused =
|
|
310
|
+
// Read focus AFTER per-node handlers so a tap that moves focus to a new
|
|
311
|
+
// node is not immediately blurred again.
|
|
312
|
+
let focused = focusedNode()
|
|
310
313
|
if (focused != null && !raw.targets.includes(focused)) {
|
|
314
|
+
// Outside-tap blur.
|
|
311
315
|
setFocus(null)
|
|
316
|
+
} else if (focused != null) {
|
|
317
|
+
// A tap on the focused node is the interaction that lets a pending
|
|
318
|
+
// text session raise the on-screen keyboard.
|
|
319
|
+
activateTextInput()
|
|
312
320
|
}
|
|
313
321
|
})
|
|
314
322
|
|
|
@@ -332,19 +340,31 @@ export function attachWindow(_nodeId: number) {
|
|
|
332
340
|
bubble(raw, "onWheel")
|
|
333
341
|
})
|
|
334
342
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
343
|
+
// Key events dispatch along the focused node's ancestor chain, leaf->root
|
|
344
|
+
// (the pointer bubbling contract), so a container hears keys from focused
|
|
345
|
+
// descendants and the window root hears everything: <window onKeyDown> is
|
|
346
|
+
// the app-global shortcut point. With nothing focused the path is the
|
|
347
|
+
// window root alone - key events are never dropped. The path is resolved
|
|
348
|
+
// at dispatch time from current focus (nothing to freeze: keyup follows
|
|
349
|
+
// focus, as in the DOM).
|
|
350
|
+
let dispatchKey = (raw: any, handler: string) => {
|
|
351
|
+
let target = focusedNode() ?? nodeId
|
|
352
|
+
let stopped = false
|
|
353
|
+
let e = { ...raw, target, stopPropagation: () => (stopped = true) }
|
|
354
|
+
let path = getNodePath(target)
|
|
355
|
+
// A focused node detached this tick has no chain to the root; the
|
|
356
|
+
// window root must still hear the key.
|
|
357
|
+
if (path[path.length - 1] !== nodeId) path.push(nodeId)
|
|
358
|
+
for (let id of path) {
|
|
359
|
+
e.currentTarget = id
|
|
360
|
+
getEventHandler(id, handler)?.(e)
|
|
361
|
+
if (stopped) break
|
|
339
362
|
}
|
|
340
|
-
}
|
|
363
|
+
}
|
|
341
364
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
getEventHandler(id, "onKeyUp")?.(e)
|
|
346
|
-
}
|
|
347
|
-
})
|
|
365
|
+
unsubKeyDown = on("keydown", (raw: any) => dispatchKey(raw, "onKeyDown"))
|
|
366
|
+
|
|
367
|
+
unsubKeyUp = on("keyup", (raw: any) => dispatchKey(raw, "onKeyUp"))
|
|
348
368
|
|
|
349
369
|
unsubBack = on("back", () => {
|
|
350
370
|
let prevented = false
|
|
@@ -361,7 +381,7 @@ export function attachWindow(_nodeId: number) {
|
|
|
361
381
|
})
|
|
362
382
|
|
|
363
383
|
unsubTextInput = on("textInput", (e: any) => {
|
|
364
|
-
let id =
|
|
384
|
+
let id = focusedNode()
|
|
365
385
|
if (id != null) {
|
|
366
386
|
getEventHandler(id, "onTextInput")?.(e)
|
|
367
387
|
}
|
|
@@ -386,6 +406,7 @@ export function attachWindow(_nodeId: number) {
|
|
|
386
406
|
})
|
|
387
407
|
|
|
388
408
|
onCleanup(() => {
|
|
409
|
+
setInterestRoot(null)
|
|
389
410
|
if (unsubscribe) unsubscribe()
|
|
390
411
|
if (unsubDown) unsubDown()
|
|
391
412
|
if (unsubUp) unsubUp()
|