@solidrt/core 0.0.58 → 0.0.60
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 +31 -6
- package/docs/index.md +23 -0
- package/examples/README.md +1 -0
- package/examples/gpu-draw-list.tsx +7 -7
- package/examples/gpu-instancing.tsx +15 -11
- package/examples/gpu-particles.tsx +8 -4
- package/examples/gpu-pipeline.tsx +8 -4
- package/examples/gpu-shared-params.tsx +6 -6
- package/examples/gpu-sprites.tsx +11 -7
- package/examples/layout-slide.tsx +78 -0
- package/examples/stagger.tsx +35 -19
- package/package.json +5 -5
- package/src/audio.ts +4 -3
- package/src/environment.ts +34 -4
- package/src/gpu.ts +25 -35
- package/src/index.ts +3 -3
- package/src/renderer.ts +17 -2
- package/src/runtime-modules.d.ts +11 -4
- package/src/types.d.ts +70 -24
- package/src/video.ts +81 -15
- package/src/window.ts +146 -10
package/AGENTS.md
CHANGED
|
@@ -111,12 +111,18 @@ tsconfig.json - the two load-bearing lines are jsx + jsxImportSource:
|
|
|
111
111
|
```
|
|
112
112
|
|
|
113
113
|
Peer deps @solidjs/signals and @solidjs/universal must match (currently
|
|
114
|
-
2.0.0-rc.
|
|
114
|
+
2.0.0-rc.8); bun resolves them from peerDependencies. On a version bump,
|
|
115
115
|
`bun install` only warns ("incorrect peer dependency") and keeps the old
|
|
116
116
|
ones, and `bun update solid-js @solidjs/signals @solidjs/universal` does
|
|
117
117
|
not read the pin either: it adds all three to package.json at registry
|
|
118
|
-
latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works
|
|
119
|
-
|
|
118
|
+
latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works
|
|
119
|
+
deletes the installed copies and leaves the lockfile alone:
|
|
120
|
+
`rm -rf node_modules/solid-js node_modules/@solidjs && bun install`
|
|
121
|
+
(delete any nested `node_modules/@solidjs` too, or the old copy keeps
|
|
122
|
+
resolving). Removing the directories is what forces re-resolution. Do not
|
|
123
|
+
delete `bun.lock` to force it: that re-resolves every unrelated dependency
|
|
124
|
+
as well, and a package that floats elsewhere fails the typecheck in a way
|
|
125
|
+
that reads exactly like Solid fallout.
|
|
120
126
|
|
|
121
127
|
## Element model (the parts that are easy to get wrong)
|
|
122
128
|
|
|
@@ -455,13 +461,32 @@ latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works is
|
|
|
455
461
|
write targets, and the runtime animates natively with no per-frame JS.
|
|
456
462
|
Enter and exit are per-property entries on that same spec: `from` is the
|
|
457
463
|
value the property animates in from at first attach, `exit` the value it
|
|
458
|
-
animates to on removal
|
|
459
|
-
|
|
464
|
+
animates to on removal. An exit reaches the whole unmounted subtree: every
|
|
465
|
+
`exit` declared under the node that unmounts plays, and the node stays
|
|
466
|
+
painted until the last of them settles, then frees - a panel's contents
|
|
467
|
+
leave with `exit` beside their `from`, no closing signal, held mount or
|
|
468
|
+
settle cue (no AnimatePresence equivalent needed). One rule in both
|
|
469
|
+
trees: a node animating out is a ghost, painted and nothing else -
|
|
470
|
+
hit-test invisible here, and in the spatial arena (a @solidrt/2d sprite,
|
|
471
|
+
a @solidrt/3d node, whose `destroy` verbs carry the same `exit`)
|
|
472
|
+
invisible to every pick, raycast, overlap and sweep - while an entering
|
|
473
|
+
node is live from its first frame, whatever `from` it passes through.
|
|
474
|
+
Either endpoint takes
|
|
475
|
+
`{ value, curve?, duration?, bounce?, delay? }` to own its direction's
|
|
476
|
+
motion (an ease-out enter, an ease-in exit). `stagger` (ms) goes on an
|
|
460
477
|
ANCESTOR, never on the animating elements: it delays each descendant's
|
|
461
478
|
enter/exit by `index * stagger`, and cascades nothing unless the
|
|
462
479
|
descendants declare `from`/`exit`. An enter plays once per mount; to
|
|
463
480
|
replay it, remount the subtree (`<Show when={epoch()} keyed>` around it,
|
|
464
|
-
then bump `epoch`). See examples/stagger.tsx.
|
|
481
|
+
then bump `epoch`). See examples/stagger.tsx. The companion to the exit
|
|
482
|
+
pop-out is `layout` on the same spec: an element declaring it slides
|
|
483
|
+
from where it was painted to the box a layout gives it (a row removed
|
|
484
|
+
above it, one inserted, a `<For>` reorder) instead of jumping, retargets
|
|
485
|
+
if the layout changes again mid-slide, and is hit-tested where it is
|
|
486
|
+
drawn. It is parent-relative, so declare it on every level that should
|
|
487
|
+
glide; `layout: true` borrows the `all` motion, and `all` alone never
|
|
488
|
+
slides. Position only today, a size change snaps. See
|
|
489
|
+
examples/layout-slide.tsx.
|
|
465
490
|
Per-frame work is for genuinely procedural motion, and a game or a
|
|
466
491
|
simulation is that in full: its natural shape is ONE `onFrame` doing one
|
|
467
492
|
sim step and N property (or sprite) writes, which is not a smell but the
|
package/docs/index.md
CHANGED
|
@@ -146,6 +146,29 @@ let camera = createCamera()
|
|
|
146
146
|
The same pattern covers `@solidrt/core/microphone`, `/sound`,
|
|
147
147
|
`/speech-recognition`, `/text-input`, `/image`, `/color`, and `/gpu`.
|
|
148
148
|
|
|
149
|
+
## Lifecycle
|
|
150
|
+
|
|
151
|
+
The runtime reports the platform's lifecycle facts and holds the platform
|
|
152
|
+
open while the app acts on them; what to do with them is the app's policy.
|
|
153
|
+
`onBack` is the user's back intent (prevent it for in-app navigation, or let
|
|
154
|
+
it fall through to the platform's default: `background()` on Android, where
|
|
155
|
+
the system keeps the app behind the others, `exit()` elsewhere). `onSuspend` fires when the app is being
|
|
156
|
+
suspended and may be killed without notice (switching away on Android or
|
|
157
|
+
iOS; never on desktop): persist session state there, async is fine, the
|
|
158
|
+
runtime waits for the returned promise up to its deadline. `onQuit` fires
|
|
159
|
+
when this app instance is ending (`exit()`, the window closing, the Android
|
|
160
|
+
activity finishing) and waits the same way, inside the platform's close
|
|
161
|
+
budget. `env.launch` says how this process came to run: "restored" when the
|
|
162
|
+
system recreated the app from a session it ended on its own, "fresh" for a
|
|
163
|
+
launch the user started. Whether to load what `onSuspend` saved is the app's
|
|
164
|
+
call. `env.visibility` is only whether anything is on screen, for pausing
|
|
165
|
+
animation and audio, not the persistence moment.
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
onSuspend(() => file("session.json").write(JSON.stringify(state)))
|
|
169
|
+
if (env.launch === "restored") state = JSON.parse(await file("session.json").text())
|
|
170
|
+
```
|
|
171
|
+
|
|
149
172
|
## Reference
|
|
150
173
|
|
|
151
174
|
The [reference](/core/reference/) covers the API by subject: the element
|
package/examples/README.md
CHANGED
|
@@ -21,6 +21,7 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
|
|
|
21
21
|
- `frame-animation.tsx` - `onFrame` driving a transform animation each frame.
|
|
22
22
|
- `on-layout-connect.tsx` - `onLayout` + `getBoundingBox` connecting laid-out boxes with a `d-path`.
|
|
23
23
|
- `stagger.tsx` - enter/exit/stagger with no per-frame JS: `from` and `exit` per property on each row's own `transition`, `stagger` on the ANCESTOR cascading them in occurrence order, and the replay idiom - a keyed `<Show>` remounts the subtree when its `when` value changes, so bumping an epoch replays the whole cascade. Exiting rows stay painted until they settle.
|
|
24
|
+
- `layout-slide.tsx` - the `layout` transition, the companion to exit: rows declaring it slide from the box they had to the box a layout gives them instead of jumping - tap a row to remove it (its exit plays where it was while the rows below slide up), the header to shuffle the order (a `<For>` reorder), the footer to add one back. No per-frame JS, no hand-animated `y`.
|
|
24
25
|
|
|
25
26
|
## Pointer input
|
|
26
27
|
- `pointer-local-coords.tsx` - the three pointer coordinate frames (`clientX` window, `localX` the handling node's own frame, `parentX` its path-parent's frame - where the node's x/y live) and the transform-proof drag idiom: grab offset from `localX` at down, place with `parentX - offset` on moves. Exact inside rotated/scaled ancestors and when the pointer leaves the node mid-drag.
|
|
@@ -60,22 +60,22 @@ let FRAGMENT_PULSE = glsl`
|
|
|
60
60
|
function App() {
|
|
61
61
|
let triangle = createBuffer(new Float32Array([0, 0.6, -0.5, -0.4, 0.5, -0.4]), { label: "tri" })
|
|
62
62
|
let vs = compileShader("vertex", VERTEX, { header: true })
|
|
63
|
-
let attrs = [{ name: "aPos", format: "
|
|
63
|
+
let attrs = [{ name: "aPos", format: "float32x2" as const }]
|
|
64
64
|
let warm = createRenderPipeline(linkProgram(vs, compileShader("fragment", FRAGMENT_WARM, { header: true })), {
|
|
65
|
-
attributes: attrs,
|
|
65
|
+
buffers: [{ attributes: attrs }],
|
|
66
66
|
depth: true,
|
|
67
67
|
})
|
|
68
68
|
let cool = createRenderPipeline(linkProgram(vs, compileShader("fragment", FRAGMENT_COOL, { header: true })), {
|
|
69
|
-
attributes: attrs,
|
|
69
|
+
buffers: [{ attributes: attrs }],
|
|
70
70
|
depth: true,
|
|
71
71
|
})
|
|
72
72
|
let pulse = createRenderPipeline(linkProgram(vs, compileShader("fragment", FRAGMENT_PULSE, { header: true })), {
|
|
73
|
-
attributes: attrs,
|
|
73
|
+
buffers: [{ attributes: attrs }],
|
|
74
74
|
})
|
|
75
75
|
|
|
76
76
|
let target = createDrawTarget(512, 512, null, { depth: true, clearColor: [0.04, 0.04, 0.08, 1], label: "orbits" })
|
|
77
|
-
let warmDraw = addDraw(target, warm, { uAngle: 0 }, {
|
|
78
|
-
let coolDraw = addDraw(target, cool, { uAngle: Math.PI }, {
|
|
77
|
+
let warmDraw = addDraw(target, warm, { uAngle: 0 }, { buffers: [triangle] })
|
|
78
|
+
let coolDraw = addDraw(target, cool, { uAngle: Math.PI }, { buffers: [triangle] })
|
|
79
79
|
|
|
80
80
|
// A third entry blinks in and out every second: add/remove are ordinary
|
|
81
81
|
// per-frame-affordable writes, and a removed DrawId simply retires. Its
|
|
@@ -91,7 +91,7 @@ function App() {
|
|
|
91
91
|
setDrawParams(target, coolDraw, { uAngle: t + Math.PI })
|
|
92
92
|
let wantPulse = Math.floor(t) % 2 === 0
|
|
93
93
|
if (wantPulse && pulseDraw === null) {
|
|
94
|
-
pulseDraw = addDraw(target, pulse, { uAngle: t * 0.3, uPhase: t * 5 }, {
|
|
94
|
+
pulseDraw = addDraw(target, pulse, { uAngle: t * 0.3, uPhase: t * 5 }, { buffers: [triangle], before: warmDraw })
|
|
95
95
|
} else if (!wantPulse && pulseDraw !== null) {
|
|
96
96
|
removeDraw(target, pulseDraw)
|
|
97
97
|
pulseDraw = null
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Instanced drawing with per-instance attributes: one 3-vertex triangle in
|
|
2
2
|
// the vertex buffer, drawn hundreds of times, each copy reading its own
|
|
3
|
-
// record from an instance buffer (
|
|
4
|
-
//
|
|
3
|
+
// record from an instance buffer (a `stepMode: "instance"` layout on the
|
|
4
|
+
// pipeline, vertex divisor 1 underneath). Here each
|
|
5
5
|
// record is one petal of a phyllotaxis spiral - its angle, radius, size, and
|
|
6
6
|
// tint, computed once in JS - so the vertex stage just reads state instead
|
|
7
7
|
// of re-deriving it from gl_InstanceID every frame, and the geometry buffer
|
|
@@ -45,7 +45,7 @@ let FRAGMENT = glsl`
|
|
|
45
45
|
}
|
|
46
46
|
`
|
|
47
47
|
|
|
48
|
-
// One record per petal (matching
|
|
48
|
+
// One record per petal (matching the instance layout: 6 floats): radius grows
|
|
49
49
|
// with sqrt(index), the angle steps by the golden angle (~2.39996 rad) - the
|
|
50
50
|
// sunflower layout - petals shrink toward the rim, and the tint walks a
|
|
51
51
|
// cosine palette.
|
|
@@ -70,15 +70,19 @@ function App() {
|
|
|
70
70
|
let instanceId = createBuffer(petalRecords(), { label: "petal-records" })
|
|
71
71
|
let id = createPipelineTexture(VERTEX, FRAGMENT, 512, 512, { uTime: 0 }, {
|
|
72
72
|
label: "petals",
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
73
|
+
buffers: [
|
|
74
|
+
{ attributes: [{ name: "aPos", format: "float32x2" }], buffer: bufferId },
|
|
75
|
+
{
|
|
76
|
+
stepMode: "instance",
|
|
77
|
+
attributes: [
|
|
78
|
+
{ name: "iAngle", format: "float32" },
|
|
79
|
+
{ name: "iRadius", format: "float32" },
|
|
80
|
+
{ name: "iScale", format: "float32" },
|
|
81
|
+
{ name: "iTint", format: "float32x3" },
|
|
82
|
+
],
|
|
83
|
+
buffer: instanceId,
|
|
84
|
+
},
|
|
80
85
|
],
|
|
81
|
-
instanceBuffer: instanceId,
|
|
82
86
|
instanceCount: 1,
|
|
83
87
|
clearColor: [0.03, 0.03, 0.06, 1],
|
|
84
88
|
})
|
|
@@ -74,11 +74,15 @@ function App() {
|
|
|
74
74
|
let bufferId = createBuffer(particles(1500), { label: "particle-verts" })
|
|
75
75
|
let id = createPipelineTexture(VERTEX, FRAGMENT, 512, 512, { uTime: 0, uTintA: [1.0, 0.45, 0.15], uTintB: [0.25, 0.5, 1.0] }, {
|
|
76
76
|
label: "particles",
|
|
77
|
-
|
|
78
|
-
{
|
|
79
|
-
|
|
77
|
+
buffers: [
|
|
78
|
+
{
|
|
79
|
+
attributes: [
|
|
80
|
+
{ name: "aPos", format: "float32x3" },
|
|
81
|
+
{ name: "aSeed", format: "float32" },
|
|
82
|
+
],
|
|
83
|
+
buffer: bufferId,
|
|
84
|
+
},
|
|
80
85
|
],
|
|
81
|
-
buffer: bufferId,
|
|
82
86
|
topology: "points",
|
|
83
87
|
blend: "add",
|
|
84
88
|
clearColor: [0.02, 0.02, 0.05, 1],
|
|
@@ -87,11 +87,15 @@ function App() {
|
|
|
87
87
|
let indexId = createBuffer(cubeIndices(), { label: "cube-indices" })
|
|
88
88
|
let id = createPipelineTexture(VERTEX, FRAGMENT, 1024, 1024, { uTime: 0 }, {
|
|
89
89
|
label: "cube",
|
|
90
|
-
|
|
91
|
-
{
|
|
92
|
-
|
|
90
|
+
buffers: [
|
|
91
|
+
{
|
|
92
|
+
attributes: [
|
|
93
|
+
{ name: "aPos", format: "float32x3" },
|
|
94
|
+
{ name: "aColor", format: "float32x3" },
|
|
95
|
+
],
|
|
96
|
+
buffer: bufferId,
|
|
97
|
+
},
|
|
93
98
|
],
|
|
94
|
-
buffer: bufferId,
|
|
95
99
|
indexBuffer: indexId,
|
|
96
100
|
indexFormat: "uint16",
|
|
97
101
|
depth: true,
|
|
@@ -65,12 +65,12 @@ let FRAGMENT_MAP = glsl`
|
|
|
65
65
|
function App() {
|
|
66
66
|
let quad = createBuffer(new Float32Array([-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]), { label: "quad" })
|
|
67
67
|
let vs = compileShader("vertex", VERTEX, { header: true })
|
|
68
|
-
let attrs = [{ name: "aPos", format: "
|
|
68
|
+
let attrs = [{ name: "aPos", format: "float32x2" as const }]
|
|
69
69
|
let tint = createRenderPipeline(linkProgram(vs, compileShader("fragment", FRAGMENT_TINT, { header: true })), {
|
|
70
|
-
attributes: attrs,
|
|
70
|
+
buffers: [{ attributes: attrs }],
|
|
71
71
|
})
|
|
72
72
|
let mapped = createRenderPipeline(linkProgram(vs, compileShader("fragment", FRAGMENT_MAP, { header: true })), {
|
|
73
|
-
attributes: attrs,
|
|
73
|
+
buffers: [{ attributes: attrs }],
|
|
74
74
|
})
|
|
75
75
|
|
|
76
76
|
// 2x2 patterns, nearest + repeat, so gl_FragCoord tiling shows hard cells.
|
|
@@ -99,12 +99,12 @@ function App() {
|
|
|
99
99
|
if (i === 1 || i === 5) {
|
|
100
100
|
// Patterned entries read the shared uMap; the one at i === 5 brings
|
|
101
101
|
// its own binding and keeps its stripes through every shared swap.
|
|
102
|
-
addDraw(target, mapped, { uCenter }, i === 5 ? {
|
|
102
|
+
addDraw(target, mapped, { uCenter }, i === 5 ? { buffers: [quad], textures: { uMap: stripes } } : { buffers: [quad] })
|
|
103
103
|
} else if (i === 3) {
|
|
104
104
|
// The override quad: its own uTint beats the shared color cycle.
|
|
105
|
-
addDraw(target, tint, { uCenter, uTint: [1, 0.62, 0.1, 1] }, {
|
|
105
|
+
addDraw(target, tint, { uCenter, uTint: [1, 0.62, 0.1, 1] }, { buffers: [quad] })
|
|
106
106
|
} else {
|
|
107
|
-
addDraw(target, tint, { uCenter }, {
|
|
107
|
+
addDraw(target, tint, { uCenter }, { buffers: [quad] })
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
package/examples/gpu-sprites.tsx
CHANGED
|
@@ -60,14 +60,18 @@ function App() {
|
|
|
60
60
|
label: "sprites",
|
|
61
61
|
topology: "triangle-strip",
|
|
62
62
|
vertexCount: 4,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
buffers: [
|
|
64
|
+
{ attributes: [{ name: "aPos", format: "float32x2" }], buffer: quad },
|
|
65
|
+
{
|
|
66
|
+
stepMode: "instance",
|
|
67
|
+
attributes: [
|
|
68
|
+
{ name: "iCenter", format: "float32x2" },
|
|
69
|
+
{ name: "iSize", format: "float32" },
|
|
70
|
+
{ name: "iTint", format: "float32x3" },
|
|
71
|
+
],
|
|
72
|
+
buffer: records,
|
|
73
|
+
},
|
|
69
74
|
],
|
|
70
|
-
instanceBuffer: records,
|
|
71
75
|
instanceCount: MAX_SPRITES,
|
|
72
76
|
clearColor: [0.03, 0.03, 0.06, 1],
|
|
73
77
|
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The `layout` transition, the companion to `exit`: a row declaring it
|
|
2
|
+
// slides from the box it had to the box the next layout gives it, so the
|
|
3
|
+
// list never jumps when a row leaves, arrives or moves. Nothing here
|
|
4
|
+
// animates `y` by hand and there is no per-frame JS: the rows keep their
|
|
5
|
+
// flex layout, and the runtime slides each one from where it was painted.
|
|
6
|
+
// Tap a row to remove it - its exit plays where it was (the pop-out) while
|
|
7
|
+
// the rows below slide up past it. Tap the header to shuffle: a `<For>`
|
|
8
|
+
// reorder moves the surviving nodes, so every displaced row slides. Tap
|
|
9
|
+
// the footer to add a row back: the rows below its slot slide apart and
|
|
10
|
+
// the new row enters through its `from`. A reflow that lands mid-slide
|
|
11
|
+
// retargets, and a spring keeps its momentum. `layout` is parent-relative
|
|
12
|
+
// (declare it on every level that should glide) and position only: a size
|
|
13
|
+
// change snaps.
|
|
14
|
+
import { render, createSignal, For } from "@solidrt/core"
|
|
15
|
+
import type { Transition } from "@solidrt/core"
|
|
16
|
+
|
|
17
|
+
const ALL_ROWS = ["Signals", "Effects", "Memos", "Stores", "Boundaries", "Resources"]
|
|
18
|
+
// Rows the list starts with; the rest come back through the footer.
|
|
19
|
+
const INITIAL_ROWS = 4
|
|
20
|
+
// Every reflow slides on this spring.
|
|
21
|
+
const SLIDE = { duration: 400, bounce: 0.15 } satisfies Transition
|
|
22
|
+
// Enter from the left, leave to the right, each direction on its own curve.
|
|
23
|
+
const ARRIVE = {
|
|
24
|
+
duration: 300,
|
|
25
|
+
curve: "ease-out",
|
|
26
|
+
from: -80,
|
|
27
|
+
exit: { value: 80, curve: "ease-in", duration: 200 },
|
|
28
|
+
} satisfies Transition
|
|
29
|
+
const FADE = {
|
|
30
|
+
duration: 300,
|
|
31
|
+
curve: "ease-out",
|
|
32
|
+
from: 0,
|
|
33
|
+
exit: { value: 0, curve: "ease-in", duration: 200 },
|
|
34
|
+
} satisfies Transition
|
|
35
|
+
|
|
36
|
+
function shuffled(items: string[]): string[] {
|
|
37
|
+
let pool = items.slice()
|
|
38
|
+
let out: string[] = []
|
|
39
|
+
while (pool.length > 0) out.push(...pool.splice(Math.floor(Math.random() * pool.length), 1))
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function App() {
|
|
44
|
+
let [rows, setRows] = createSignal(ALL_ROWS.slice(0, INITIAL_ROWS))
|
|
45
|
+
let addRow = () => {
|
|
46
|
+
let next = ALL_ROWS.find((label) => !rows().includes(label))
|
|
47
|
+
if (next) setRows([...rows(), next])
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<window padding={40} gap={12}>
|
|
52
|
+
<view height={40} justifyContent="center" onPointerDown={() => setRows(shuffled(rows()))}>
|
|
53
|
+
<text color="#8899aa" fontSize={14}>Tap here to shuffle, a row to remove it</text>
|
|
54
|
+
</view>
|
|
55
|
+
<For each={rows()}>
|
|
56
|
+
{(label) => (
|
|
57
|
+
<view
|
|
58
|
+
x={0}
|
|
59
|
+
opacity={1}
|
|
60
|
+
transition={{ layout: SLIDE, x: ARRIVE, opacity: FADE }}
|
|
61
|
+
height={48}
|
|
62
|
+
justifyContent="center"
|
|
63
|
+
paddingLeft={16}
|
|
64
|
+
onPointerDown={() => setRows(rows().filter((row) => row !== label))}
|
|
65
|
+
>
|
|
66
|
+
<d-rect color="#2a3a55" radius={10} />
|
|
67
|
+
<text color="#e8eef6" fontSize={18}>{label}</text>
|
|
68
|
+
</view>
|
|
69
|
+
)}
|
|
70
|
+
</For>
|
|
71
|
+
<view height={40} justifyContent="center" transition={{ layout: SLIDE }} onPointerDown={addRow}>
|
|
72
|
+
<text color="#8899aa" fontSize={14}>Tap here to add a row back</text>
|
|
73
|
+
</view>
|
|
74
|
+
</window>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
render(() => <App />)
|
package/examples/stagger.tsx
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
// Enter, exit and stagger are declared on elements; the runtime plays them
|
|
2
2
|
// with no per-frame JS. `from` is the value a property animates in from at
|
|
3
|
-
// first attach, `exit` the value it animates to on removal
|
|
4
|
-
//
|
|
5
|
-
// the
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
3
|
+
// first attach, `exit` the value it animates to on removal. Both are
|
|
4
|
+
// per-property entries on the element's own transition spec. An exit
|
|
5
|
+
// reaches the whole removed subtree: every `exit` declared under the node
|
|
6
|
+
// that unmounts plays, and that node stays painted until the last of them
|
|
7
|
+
// settles, then frees. It leaves the layout at once, so a fresh panel
|
|
8
|
+
// mounted mid-exit takes its place while the old one fades over it. The
|
|
9
|
+
// rows below never unmount one by one - the panel around them does - and
|
|
10
|
+
// they still leave the way they arrived. Each direction owns its motion:
|
|
11
|
+
// `from`/`exit` take a bare value (the entry's curve, duration and delay)
|
|
12
|
+
// or `{ value, curve?, duration?, bounce?, delay? }`, so the fade below
|
|
13
|
+
// eases out on the way in and eases in, shorter, on the way out.
|
|
14
|
+
// `stagger` (ms) goes on an ANCESTOR, never on the animating elements: every
|
|
15
|
+
// descendant enter or exit that begins in the same frame gets index * stagger
|
|
16
|
+
// of extra delay, in tree order. It cascades nothing on its own - the
|
|
17
|
+
// descendants must declare from/exit.
|
|
10
18
|
//
|
|
11
19
|
// An enter plays once per mount. To replay it, remount the subtree: a keyed
|
|
12
20
|
// <Show> re-creates its child whenever `when` changes value, so bumping an
|
|
13
|
-
// epoch replays the whole cascade. Tap anywhere: odd taps unmount the
|
|
21
|
+
// epoch replays the whole cascade. Tap anywhere: odd taps unmount the panel
|
|
14
22
|
// (staggered exits), even taps mount a fresh one (staggered enters).
|
|
15
23
|
import { render, createSignal, Show } from "@solidrt/core"
|
|
16
24
|
import type { Transition } from "@solidrt/core"
|
|
@@ -18,25 +26,33 @@ import type { Transition } from "@solidrt/core"
|
|
|
18
26
|
const ROWS = ["Signals", "Effects", "Memos", "Stores", "Boundaries"]
|
|
19
27
|
// Extra delay per row before its enter or exit starts.
|
|
20
28
|
const STAGGER_MS = 70
|
|
21
|
-
// Slide in from the left, out to the right; fade both ways
|
|
29
|
+
// Slide in from the left, out to the right; fade both ways, the exit on
|
|
30
|
+
// its own curve and clock.
|
|
22
31
|
const SLIDE = { duration: 500, bounce: 0.2, from: -80, exit: 80 } satisfies Transition
|
|
23
|
-
const FADE = {
|
|
32
|
+
const FADE = {
|
|
33
|
+
duration: 350,
|
|
34
|
+
curve: "ease-out",
|
|
35
|
+
from: 0,
|
|
36
|
+
exit: { value: 0, curve: "ease-in", duration: 200 },
|
|
37
|
+
} satisfies Transition
|
|
24
38
|
|
|
25
39
|
function App() {
|
|
26
40
|
let [epoch, setEpoch] = createSignal(1)
|
|
27
41
|
|
|
28
42
|
return (
|
|
29
|
-
<window padding={40} gap={12} onPointerDown={() => setEpoch((e) => e + 1)}
|
|
43
|
+
<window padding={40} gap={12} onPointerDown={() => setEpoch((e) => e + 1)}>
|
|
30
44
|
<text color="#8899aa" fontSize={14}>Tap to replay</text>
|
|
31
45
|
<Show when={epoch() % 2 === 1 ? epoch() : 0} keyed>
|
|
32
|
-
{(_epoch) =>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
{(_epoch) => (
|
|
47
|
+
<view gap={12} transition={{ stagger: STAGGER_MS }}>
|
|
48
|
+
{ROWS.map((label) => (
|
|
49
|
+
<view x={0} opacity={1} transition={{ x: SLIDE, opacity: FADE }} height={48} justifyContent="center" paddingLeft={16}>
|
|
50
|
+
<d-rect color="#2a3a55" radius={10} />
|
|
51
|
+
<text color="#e8eef6" fontSize={18}>{label}</text>
|
|
52
|
+
</view>
|
|
53
|
+
))}
|
|
54
|
+
</view>
|
|
55
|
+
)}
|
|
40
56
|
</Show>
|
|
41
57
|
</window>
|
|
42
58
|
)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.60",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"funding": "https://github.com/sponsors/wellawaretech",
|
|
6
6
|
"author": "Antoine van Wel",
|
|
@@ -31,11 +31,11 @@
|
|
|
31
31
|
"AGENTS.md"
|
|
32
32
|
],
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@solidrt/flux-types": "0.0.
|
|
34
|
+
"@solidrt/flux-types": "0.0.60"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@solidjs/signals": "2.0.0-rc.
|
|
38
|
-
"@solidjs/universal": "2.0.0-rc.
|
|
39
|
-
"solid-js": "2.0.0-rc.
|
|
37
|
+
"@solidjs/signals": "2.0.0-rc.8",
|
|
38
|
+
"@solidjs/universal": "2.0.0-rc.8",
|
|
39
|
+
"solid-js": "2.0.0-rc.8"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/audio.ts
CHANGED
|
@@ -218,9 +218,10 @@ export function createPcmSound(
|
|
|
218
218
|
* Streams a large track, decoding on demand instead of loading it into memory.
|
|
219
219
|
* Single-voice: each play() restarts it. Pass a path (resolved like flux:fs,
|
|
220
220
|
* relative to the process cwd) or a `file()` from flux:fs; a path is wrapped in
|
|
221
|
-
* `file()` for you
|
|
222
|
-
*
|
|
223
|
-
* disposed. For imperative use, call
|
|
221
|
+
* `file()` for you. The track is read from local storage (the app's assets in
|
|
222
|
+
* a packed app), not over the network. Owns the stream's lifecycle: stopped
|
|
223
|
+
* and released when the reactive owner is disposed. For imperative use, call
|
|
224
|
+
* stream()/play() from "flux:audio".
|
|
224
225
|
*/
|
|
225
226
|
export function createSoundStream(source: string | FluxFile, options: SoundStreamOptions = {}): Sound {
|
|
226
227
|
let src = typeof source === "string" ? file(source) : source
|
package/src/environment.ts
CHANGED
|
@@ -37,6 +37,8 @@ export type SystemTheme = "dark" | "light" | "unknown"
|
|
|
37
37
|
|
|
38
38
|
export type Visibility = "visible" | "hidden"
|
|
39
39
|
|
|
40
|
+
export type Launch = "fresh" | "restored"
|
|
41
|
+
|
|
40
42
|
export type Orientation = "portrait" | "portraitFlipped" | "landscape" | "landscapeFlipped" | "unknown"
|
|
41
43
|
|
|
42
44
|
let devicesAccessor: (() => InputDevices | undefined) | undefined
|
|
@@ -65,6 +67,22 @@ function ensureSystemThemeState() {
|
|
|
65
67
|
})
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
// Fixed for the process, so a plain value rather than a signal: the sticky
|
|
71
|
+
// `launch` event replays synchronously on subscribe, and the runner emits it
|
|
72
|
+
// before any app code runs.
|
|
73
|
+
let launchValue: Launch | undefined
|
|
74
|
+
|
|
75
|
+
function ensureLaunchState() {
|
|
76
|
+
if (launchValue) return
|
|
77
|
+
runWithOwner(null, () => {
|
|
78
|
+
on("launch", (e: { state?: Launch }) => {
|
|
79
|
+
launchValue = e.state === "restored" ? "restored" : "fresh"
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
// A runtime that reports no launch fact (desktop today) launches fresh.
|
|
83
|
+
launchValue ??= "fresh"
|
|
84
|
+
}
|
|
85
|
+
|
|
68
86
|
let visibilityAccessor: (() => Visibility) | undefined
|
|
69
87
|
|
|
70
88
|
function ensureVisibilityState() {
|
|
@@ -218,15 +236,27 @@ export let env = {
|
|
|
218
236
|
* `visibilityState` vocabulary without the `document` machinery - react
|
|
219
237
|
* to it in a tracked scope (JSX, memo, effect).
|
|
220
238
|
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* running but no frames are produced.
|
|
239
|
+
* A rendering and UI fact (nothing is on screen: stop animating, pause
|
|
240
|
+
* the audio), not the persistence moment - that is `onSuspend`, which the
|
|
241
|
+
* runtime waits for. While hidden no frames are produced.
|
|
225
242
|
*/
|
|
226
243
|
get visibility(): Visibility {
|
|
227
244
|
ensureVisibilityState()
|
|
228
245
|
return visibilityAccessor!()
|
|
229
246
|
},
|
|
247
|
+
/**
|
|
248
|
+
* How this process came to run. "restored": the system recreated the app
|
|
249
|
+
* from a session it ended on its own (a suspended app reclaimed in the
|
|
250
|
+
* background; Android's saved instance state). "fresh": launched anew, a
|
|
251
|
+
* first start or one after `exit()` or a close ended the previous
|
|
252
|
+
* instance, and always on desktop. Reported, not interpreted: whether to
|
|
253
|
+
* load what `onSuspend` saved is the app's decision. Fixed for the
|
|
254
|
+
* process, so it needs no tracked scope.
|
|
255
|
+
*/
|
|
256
|
+
get launch(): Launch {
|
|
257
|
+
ensureLaunchState()
|
|
258
|
+
return launchValue!
|
|
259
|
+
},
|
|
230
260
|
/** Orientation of the display the window is on. */
|
|
231
261
|
get orientation(): Orientation {
|
|
232
262
|
ensureOrientationState()
|
package/src/gpu.ts
CHANGED
|
@@ -135,7 +135,7 @@ export {
|
|
|
135
135
|
// updated draw range (vertexCount after its buffer gained or lost dynamic
|
|
136
136
|
// geometry, firstVertex for a different window of a shared buffer,
|
|
137
137
|
// instanceCount for an instanced population; absent keys keep their current
|
|
138
|
-
// value, like params) and/or swapped buffers (
|
|
138
|
+
// value, like params) and/or swapped buffers (an instance buffer pointed at a
|
|
139
139
|
// larger buffer once a population outgrows the old one - the growth
|
|
140
140
|
// primitive; replace-only, the range is rechecked); destroyBuffer is the manual
|
|
141
141
|
// cleanup path for buffers created outside a reactive scope. renderTarget is
|
|
@@ -147,7 +147,7 @@ export {
|
|
|
147
147
|
// GPU-side (exact, same size): seed a loadOp "load" accumulator, snapshot a
|
|
148
148
|
// ping-pong buffer, reset state to a known image.
|
|
149
149
|
export { copyTexture, destroyBuffer, renderTarget, setDraw } from "flux:gpu"
|
|
150
|
-
export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange,
|
|
150
|
+
export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange, InstanceOrder, OrderUpdate, ShaderParams, Topology, VertexAttribute, VertexBufferLayout, VertexFormat } from "flux:gpu"
|
|
151
151
|
|
|
152
152
|
// The draw-list verbs, re-exported raw: entries live and die with their draw
|
|
153
153
|
// target (see createDrawTarget below), so there is no per-entry lifetime to
|
|
@@ -366,20 +366,18 @@ export function createShaderTexture(
|
|
|
366
366
|
* texture id is, e.g. `<texture src>`; resize with `setTargetSize`, drive
|
|
367
367
|
* uniforms with `<texture params>` or `setTargetParams`). Many targets may
|
|
368
368
|
* share one pipeline, and creating a target compiles nothing. The target
|
|
369
|
-
* brings the per-target half: size, the concrete
|
|
370
|
-
*
|
|
371
|
-
* `
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
* lives on the pipeline
|
|
382
|
-
* and throws here. Frees the target when the reactive owner is disposed (opt
|
|
369
|
+
* brings the per-target half: size, the concrete `buffers` the pipeline's
|
|
370
|
+
* layouts describe (one id per layout, in declaration order), the draw
|
|
371
|
+
* range (`vertexCount` defaults to the rest of the tightest vertex-step
|
|
372
|
+
* buffer from `firstVertex` on, `instanceCount` repeats it as instances
|
|
373
|
+
* told apart by `gl_InstanceID` and defaults to one per record of the
|
|
374
|
+
* tightest instance-step buffer; a fullscreen pass over an attributeless
|
|
375
|
+
* pipeline is `{ vertexCount: 3 }` with a covering-triangle vertex stage),
|
|
376
|
+
* uniforms, and `clearColor`. An `indexBuffer` + `indexFormat` pair makes
|
|
377
|
+
* the draw indexed (shared vertices stored once), with the range in
|
|
378
|
+
* `firstIndex`/`indexCount` spelling - see IndexBinding/IndexRange. Draw
|
|
379
|
+
* state (`topology`, `blend`, `cull`, `depth`, `depthWrite`) lives on the
|
|
380
|
+
* pipeline and throws here. Frees the target when the reactive owner is disposed (opt
|
|
383
381
|
* out with `autoFree: false`); the pipeline is yours and outlives it.
|
|
384
382
|
*
|
|
385
383
|
* `render: "manual"` makes it a manual target: the runtime never renders it
|
|
@@ -404,11 +402,8 @@ export function createShaderTarget(
|
|
|
404
402
|
params?: gpu.ShaderParams | null,
|
|
405
403
|
opts?: {
|
|
406
404
|
textures?: gpu.TextureBindings
|
|
407
|
-
buffer
|
|
408
|
-
|
|
409
|
-
/** One buffer per instance slot of the pipeline (index = the
|
|
410
|
-
* attributes' `slot`); pass this OR `instanceBuffer`, not both. */
|
|
411
|
-
instanceBuffers?: gpu.BufferId[]
|
|
405
|
+
/** One buffer id per layout of the pipeline, in declaration order. */
|
|
406
|
+
buffers?: gpu.BufferId[]
|
|
412
407
|
/** Draw the instance records in key order (see InstanceOrder). */
|
|
413
408
|
instanceOrder?: gpu.InstanceOrder
|
|
414
409
|
clearColor?: [number, number, number, number]
|
|
@@ -658,9 +653,9 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
|
|
|
658
653
|
* texture, returning the texture id (usable anywhere a normal texture id is,
|
|
659
654
|
* e.g. `<texture src>`) - named, like `createShaderTexture`, for what comes
|
|
660
655
|
* back. Unlike `createShaderTexture` the vertex stage is yours:
|
|
661
|
-
* declare `in` attributes matching `opts.
|
|
662
|
-
*
|
|
663
|
-
*
|
|
656
|
+
* declare `in` attributes matching `opts.buffers` (each layout with the
|
|
657
|
+
* {@link createBuffer} id it reads) and your own varyings toward the
|
|
658
|
+
* fragment stage. Clip space is y-down: `gl_Position` y = -1 is the top
|
|
664
659
|
* row of the target and +1 the bottom, so camera-up geometry must negate y
|
|
665
660
|
* (or fold the flip into its projection) to display up. Both sources may
|
|
666
661
|
* reference `iResolution` and any uniform they declare (`float`/`int`
|
|
@@ -679,10 +674,10 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
|
|
|
679
674
|
* see DrawRange) defaults to the whole buffer drawn once and can be changed
|
|
680
675
|
* later with `setDraw`; `instanceCount` is the standard answer to particles
|
|
681
676
|
* and repeated meshes, N copies of the range told apart by `gl_InstanceID`
|
|
682
|
-
* in the vertex stage. `
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
677
|
+
* in the vertex stage. A layout with `stepMode: "instance"` gives each
|
|
678
|
+
* instance its own interleaved record - real per-instance state instead of
|
|
679
|
+
* `gl_InstanceID` arithmetic - and `instanceCount` then defaults to one
|
|
680
|
+
* instance per record. An
|
|
686
681
|
* `indexBuffer` + `indexFormat` pair makes the draw
|
|
687
682
|
* indexed (shared vertices stored once), with the range in
|
|
688
683
|
* `firstIndex`/`indexCount` spelling; `opts.cull` discards one face set by
|
|
@@ -701,13 +696,8 @@ export function createPipelineTexture(
|
|
|
701
696
|
params?: gpu.ShaderParams | null,
|
|
702
697
|
opts?: {
|
|
703
698
|
textures?: gpu.TextureBindings
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
instanceAttributes?: gpu.InstanceAttribute[]
|
|
707
|
-
instanceBuffer?: gpu.BufferId
|
|
708
|
-
/** One buffer per instance slot (index = the attributes' `slot`);
|
|
709
|
-
* pass this OR `instanceBuffer`, not both. */
|
|
710
|
-
instanceBuffers?: gpu.BufferId[]
|
|
699
|
+
/** The pipeline's layouts, each with the buffer it reads. */
|
|
700
|
+
buffers?: (gpu.VertexBufferLayout & { buffer: gpu.BufferId })[]
|
|
711
701
|
/** Draw the instance records in key order (see InstanceOrder). */
|
|
712
702
|
instanceOrder?: gpu.InstanceOrder
|
|
713
703
|
topology?: gpu.Topology
|
package/src/index.ts
CHANGED
|
@@ -3,11 +3,11 @@ export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables,
|
|
|
3
3
|
export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
|
|
4
4
|
export { parseColor, mixColors, withAlpha, brightness, createLinearGradient, createRadialGradient } from "./color"
|
|
5
5
|
export type { Gradient, GradientStop } from "./color"
|
|
6
|
-
export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, exit } from "./window"
|
|
7
|
-
export type { BackEvent } from "./window"
|
|
6
|
+
export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, onSuspend, onQuit, exit, background } from "./window"
|
|
7
|
+
export type { BackEvent, LifecycleHandler } from "./window"
|
|
8
8
|
export { windowSize, safeArea, displayScale, windowFocused, keyboardHeight, lockPointer, pointerLocked } from "./window"
|
|
9
9
|
export { env } from "./environment"
|
|
10
|
-
export type { InputDevices, SystemTheme, Orientation, Visibility } from "./environment"
|
|
10
|
+
export type { InputDevices, SystemTheme, Orientation, Visibility, Launch } from "./environment"
|
|
11
11
|
export { gamepads } from "./gamepad"
|
|
12
12
|
export type { GamepadState } from "./gamepad"
|
|
13
13
|
export { capabilities } from "./capabilities"
|
package/src/renderer.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createRoot, onCleanup, NotReadyError } from "@solidjs/signals"
|
|
1
|
+
import { createRoot, onCleanup, NotReadyError, untrack } from "@solidjs/signals"
|
|
2
2
|
import { createRenderer } from "@solidjs/universal"
|
|
3
3
|
import { createErrorBoundary } from "solid-js"
|
|
4
4
|
import type { Element } from "solid-js"
|
|
@@ -51,9 +51,13 @@ let destroyScheduled = false
|
|
|
51
51
|
// before dropping handlers so onBlur still fires for a focused descendant.
|
|
52
52
|
function destroyNode(node: ProxyNode): void {
|
|
53
53
|
tree.destroyNode(node.id)
|
|
54
|
+
// Read once, untracked: destroyNode runs inside effect apply phases (the
|
|
55
|
+
// root boundary's swap, the end-of-tick sweep), where a per-node signal
|
|
56
|
+
// read is a STRICT_READ_UNTRACKED warning per node.
|
|
57
|
+
let focused = untrack(focusedNode)
|
|
54
58
|
let cleanup = (n: ProxyNode) => {
|
|
55
59
|
for (let child of n.children) if (child.parent === n) cleanup(child)
|
|
56
|
-
if (n.id ===
|
|
60
|
+
if (n.id === focused) setFocus(null)
|
|
57
61
|
nodes.delete(n.id)
|
|
58
62
|
cleanupNode(n.id)
|
|
59
63
|
}
|
|
@@ -288,6 +292,17 @@ let renderer = createRenderer<ProxyNode>({
|
|
|
288
292
|
if (anchor) tree.insertNode(parent.id, node.id, anchor.id)
|
|
289
293
|
else tree.insertNode(parent.id, node.id)
|
|
290
294
|
|
|
295
|
+
// A bare move - Solid reorders a <For> with insertBefore on a node
|
|
296
|
+
// that is still attached, no removeNode first - unlinks the node from
|
|
297
|
+
// wherever it was on the native side (a same-parent reorder included).
|
|
298
|
+
// The mirror must drop the old entry too, or it keeps both and
|
|
299
|
+
// getNextSibling hands Solid stale anchors from then on, so every
|
|
300
|
+
// later insert lands in the wrong place.
|
|
301
|
+
let previous = node.parent
|
|
302
|
+
if (previous) {
|
|
303
|
+
let at = previous.children.indexOf(node)
|
|
304
|
+
if (at !== -1) previous.children.splice(at, 1)
|
|
305
|
+
}
|
|
291
306
|
node.parent = parent
|
|
292
307
|
|
|
293
308
|
if (!anchor) {
|
package/src/runtime-modules.d.ts
CHANGED
|
@@ -66,12 +66,19 @@ declare module "srt:events" {
|
|
|
66
66
|
// The running application's own surface (lattice), present in every build.
|
|
67
67
|
declare module "srt:app" {
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
70
|
-
* client, quit when standalone or at the player root (on
|
|
71
|
-
*
|
|
72
|
-
*
|
|
69
|
+
* End the current app now, without running the quit hooks: back to the
|
|
70
|
+
* player in a dev client, quit when standalone or at the player root (on
|
|
71
|
+
* Android the activity finishes). Prefer the @solidrt/core `exit`, which
|
|
72
|
+
* runs `onQuit` first.
|
|
73
73
|
*/
|
|
74
74
|
export function exit(): void
|
|
75
|
+
/**
|
|
76
|
+
* Leave the current app without ending it: back to the player in a dev
|
|
77
|
+
* client; otherwise to the OS background (Android moveTaskToBack, a
|
|
78
|
+
* minimized window on desktop). Core's default action of an unprevented
|
|
79
|
+
* `back` on Android; exposed there as `background`.
|
|
80
|
+
*/
|
|
81
|
+
export function background(): void
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
// Dev-server control surface (lattice). Present only in dev/go builds; in other
|
package/src/types.d.ts
CHANGED
|
@@ -461,6 +461,23 @@ export interface LineGeometryProps extends PositionProps {
|
|
|
461
461
|
/** A cubic-bezier timing curve: a CSS name or [x1, y1, x2, y2] control values. */
|
|
462
462
|
export type TransitionCurve = "linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out" | [number, number, number, number]
|
|
463
463
|
|
|
464
|
+
/**
|
|
465
|
+
* The object form of `from`/`exit`: the endpoint value plus that
|
|
466
|
+
* direction's own motion, so an ease-out enter pairs with an ease-in exit
|
|
467
|
+
* instead of playing the entry's curve backwards. Every field but `value`
|
|
468
|
+
* is optional and falls back to the entry's (`delay` included, so a
|
|
469
|
+
* staggered list needs no change), except that naming a `curve` or a
|
|
470
|
+
* `bounce` decides the kind outright: `{ value: 0, curve: "ease-in" }` on a
|
|
471
|
+
* spring entry is a tween of the entry's duration.
|
|
472
|
+
*/
|
|
473
|
+
export interface TransitionEndpoint {
|
|
474
|
+
value: number | string
|
|
475
|
+
duration?: number
|
|
476
|
+
curve?: TransitionCurve
|
|
477
|
+
bounce?: number
|
|
478
|
+
delay?: number
|
|
479
|
+
}
|
|
480
|
+
|
|
464
481
|
/**
|
|
465
482
|
* A perceptual spring - the default kind: a bare `{ duration }` is a
|
|
466
483
|
* critically damped spring. `duration` (ms) is the perceptual settling
|
|
@@ -479,20 +496,26 @@ export interface TransitionSpring {
|
|
|
479
496
|
* Mount-time enter animation: at the element's first attach the property
|
|
480
497
|
* snaps to this value and animates to the value it mounted with. Numbers
|
|
481
498
|
* for the scalar properties; the color property takes a CSS color string
|
|
482
|
-
* or packed number
|
|
483
|
-
*
|
|
499
|
+
* or packed number, or a {@link TransitionEndpoint} giving the enter its
|
|
500
|
+
* own curve, duration, bounce or delay. Per-property entries only (not
|
|
501
|
+
* under `all`); a later move or reorder re-runs nothing.
|
|
484
502
|
*/
|
|
485
|
-
from?: number | string
|
|
503
|
+
from?: number | string | TransitionEndpoint
|
|
486
504
|
/**
|
|
487
|
-
* Removal exit animation: an
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
505
|
+
* Removal exit animation: when an element unmounts, every `exit`
|
|
506
|
+
* declared on it or under it animates its property to this value
|
|
507
|
+
* (honoring `delay`), and the unmounted element stays visible, subtree
|
|
508
|
+
* and all, until the last of them settles, then frees. Same value forms
|
|
509
|
+
* as `from`, per-property only. A move never plays it, the exiting
|
|
510
|
+
* subtree is hit-test invisible, and no onTransitionEnd fires (the
|
|
511
|
+
* components are already disposed). An exiting element leaves the layout
|
|
512
|
+
* flow at once: its siblings reflow as if it were gone, and it is painted
|
|
513
|
+
* at its last box, which follows its parent's moves but not the parent's
|
|
514
|
+
* own reflow. The {@link TransitionEndpoint} form gives the exit its own
|
|
515
|
+
* motion: an ease-out enter reads wrong played backwards, so pair it with
|
|
516
|
+
* `exit: { value, curve: "ease-in" }`.
|
|
494
517
|
*/
|
|
495
|
-
exit?: number | string
|
|
518
|
+
exit?: number | string | TransitionEndpoint
|
|
496
519
|
}
|
|
497
520
|
|
|
498
521
|
/**
|
|
@@ -511,20 +534,26 @@ export interface TransitionTween {
|
|
|
511
534
|
* Mount-time enter animation: at the element's first attach the property
|
|
512
535
|
* snaps to this value and animates to the value it mounted with. Numbers
|
|
513
536
|
* for the scalar properties; the color property takes a CSS color string
|
|
514
|
-
* or packed number
|
|
515
|
-
*
|
|
537
|
+
* or packed number, or a {@link TransitionEndpoint} giving the enter its
|
|
538
|
+
* own curve, duration, bounce or delay. Per-property entries only (not
|
|
539
|
+
* under `all`); a later move or reorder re-runs nothing.
|
|
516
540
|
*/
|
|
517
|
-
from?: number | string
|
|
541
|
+
from?: number | string | TransitionEndpoint
|
|
518
542
|
/**
|
|
519
|
-
* Removal exit animation: an
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
543
|
+
* Removal exit animation: when an element unmounts, every `exit`
|
|
544
|
+
* declared on it or under it animates its property to this value
|
|
545
|
+
* (honoring `delay`), and the unmounted element stays visible, subtree
|
|
546
|
+
* and all, until the last of them settles, then frees. Same value forms
|
|
547
|
+
* as `from`, per-property only. A move never plays it, the exiting
|
|
548
|
+
* subtree is hit-test invisible, and no onTransitionEnd fires (the
|
|
549
|
+
* components are already disposed). An exiting element leaves the layout
|
|
550
|
+
* flow at once: its siblings reflow as if it were gone, and it is painted
|
|
551
|
+
* at its last box, which follows its parent's moves but not the parent's
|
|
552
|
+
* own reflow. The {@link TransitionEndpoint} form gives the exit its own
|
|
553
|
+
* motion: an ease-out enter reads wrong played backwards, so pair it with
|
|
554
|
+
* `exit: { value, curve: "ease-in" }`.
|
|
526
555
|
*/
|
|
527
|
-
exit?: number | string
|
|
556
|
+
exit?: number | string | TransitionEndpoint
|
|
528
557
|
}
|
|
529
558
|
|
|
530
559
|
/**
|
|
@@ -571,9 +600,12 @@ export type TransitionPropName =
|
|
|
571
600
|
| "radius"
|
|
572
601
|
| "color"
|
|
573
602
|
|
|
574
|
-
/**
|
|
603
|
+
/**
|
|
604
|
+
* Payload of onTransitionEnd: which animated property finished. `"layout"`
|
|
605
|
+
* is the layout slide settling on its box.
|
|
606
|
+
*/
|
|
575
607
|
export interface TransitionEndEvent {
|
|
576
|
-
property: TransitionPropName
|
|
608
|
+
property: TransitionPropName | "layout"
|
|
577
609
|
}
|
|
578
610
|
|
|
579
611
|
export interface TransitionProps {
|
|
@@ -610,6 +642,20 @@ export interface TransitionProps {
|
|
|
610
642
|
* lifecycle are unaffected. Adds on top of a per-entry `delay`.
|
|
611
643
|
*/
|
|
612
644
|
stagger?: number
|
|
645
|
+
/**
|
|
646
|
+
* Layout slide: when a layout moves this element (a row removed
|
|
647
|
+
* above it, one inserted, a `<For>` reorder, a resize), it slides
|
|
648
|
+
* from where it was painted to its new box on this motion instead
|
|
649
|
+
* of jumping, and is hit-tested where it is drawn. A reflow
|
|
650
|
+
* mid-slide retargets (a spring keeps its velocity). Its first
|
|
651
|
+
* layout, a reparent and a `display: none` toggle snap. The slide is
|
|
652
|
+
* parent-relative: a parent that moves carries its children at
|
|
653
|
+
* once, so declare it on every level that should glide. `true`
|
|
654
|
+
* borrows the `all` entry's motion (an error without one); `all`
|
|
655
|
+
* alone never slides. Position only today: a size change snaps.
|
|
656
|
+
* Settles with onTransitionEnd property `"layout"`.
|
|
657
|
+
*/
|
|
658
|
+
layout?: Omit<TransitionSpring, "from" | "exit"> | Omit<TransitionTween, "from" | "exit"> | TransitionShorthand | boolean
|
|
613
659
|
} & {
|
|
614
660
|
[P in TransitionPropName]?: Transition
|
|
615
661
|
})
|
package/src/video.ts
CHANGED
|
@@ -5,74 +5,134 @@
|
|
|
5
5
|
// subtree effects - applies to video for free. A richer Video component
|
|
6
6
|
// composes on top of this in a higher layer, not in core.
|
|
7
7
|
//
|
|
8
|
+
// A plane player (`present: "plane"`, Android) has no texture: the platform
|
|
9
|
+
// composites the picture fullscreen beneath the UI on the video's own clock,
|
|
10
|
+
// and the UI simply draws over it. It exists from open until the owner is
|
|
11
|
+
// disposed, and it streams: the source may be a URL, read as it plays. On a
|
|
12
|
+
// platform without a plane the open fails (error() with kind "no-plane"),
|
|
13
|
+
// and an app that runs everywhere falls back to a texture player itself.
|
|
14
|
+
//
|
|
8
15
|
// The imperative primitive lives in the `flux:video` module; import { open }
|
|
9
16
|
// from "flux:video" for non-reactive use.
|
|
10
17
|
|
|
11
18
|
import { createSignal, onCleanup } from "@solidjs/signals"
|
|
12
19
|
import type { TextureId } from "flux:gpu"
|
|
13
|
-
import { open } from "flux:video"
|
|
20
|
+
import { open, type VideoError, type VideoPlane, type VideoPlayer, type VideoSource } from "flux:video"
|
|
21
|
+
|
|
22
|
+
export type { VideoError, VideoErrorKind, VideoSource } from "flux:video"
|
|
14
23
|
|
|
15
24
|
export type VideoOptions = {
|
|
16
|
-
/** Start playback as soon as the
|
|
25
|
+
/** Start playback as soon as the source is open. */
|
|
17
26
|
autoplay?: boolean
|
|
27
|
+
/**
|
|
28
|
+
* Where the picture goes: a texture (default, displayed by the app) or the
|
|
29
|
+
* platform's video plane (fullscreen beneath the UI, Android only).
|
|
30
|
+
*/
|
|
31
|
+
present?: "texture" | "plane"
|
|
32
|
+
/** Plane only: letterboxed (default) or cropped to fill the window. */
|
|
33
|
+
fit?: "contain" | "cover"
|
|
18
34
|
}
|
|
19
35
|
|
|
20
36
|
/** An opened video as reactive accessors plus playback controls. */
|
|
21
37
|
export type VideoStream = {
|
|
22
|
-
/**
|
|
38
|
+
/** True once the source is open (and, for a plane, the plane exists). */
|
|
39
|
+
ready(): boolean
|
|
40
|
+
/** Texture id once open, undefined while opening and always for a plane player; render with <texture src={...}>. */
|
|
23
41
|
texture(): TextureId | undefined
|
|
24
42
|
/** Frame size, undefined while opening. */
|
|
25
43
|
width(): number | undefined
|
|
26
44
|
height(): number | undefined
|
|
27
|
-
/** Duration in seconds, undefined while opening. */
|
|
45
|
+
/** Duration in seconds, undefined while opening or when the source has no known end. */
|
|
28
46
|
duration(): number | undefined
|
|
29
|
-
/**
|
|
30
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The failure: opening failed (unreadable or unreachable source,
|
|
49
|
+
* unsupported codec, no plane on this platform), or playback stopped on
|
|
50
|
+
* one later. Its `kind` says which; an app's plane-to-texture fallback
|
|
51
|
+
* keys on "no-plane" only.
|
|
52
|
+
*/
|
|
53
|
+
error(): VideoError | undefined
|
|
31
54
|
/** Start or resume playback (before open resolves, playback starts on resolve). */
|
|
32
55
|
play(): void
|
|
33
56
|
/** Pause playback; the current frame stays displayed. */
|
|
34
57
|
pause(): void
|
|
58
|
+
/**
|
|
59
|
+
* Seek to a time in seconds. A no-op while opening, on a texture player,
|
|
60
|
+
* and on a source that cannot seek (see `seekable`). While paused the
|
|
61
|
+
* target frame is shown.
|
|
62
|
+
*/
|
|
63
|
+
seek(seconds: number): void
|
|
64
|
+
/** Whether `seek` does anything (plain read, not a signal). */
|
|
65
|
+
seekable(): boolean
|
|
35
66
|
/** Whether playback is running (plain read, not a signal). */
|
|
36
67
|
playing(): boolean
|
|
37
68
|
/** Presentation time of the displayed frame in seconds (plain read, not a signal). */
|
|
38
69
|
currentTime(): number
|
|
39
70
|
/** Whether the last frame has been displayed (plain read, not a signal). */
|
|
40
71
|
finished(): boolean
|
|
72
|
+
/** Whether playback is held for the source to catch up (plain read, not a signal). */
|
|
73
|
+
buffering(): boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type AnyPlayer = VideoPlayer | VideoPlane
|
|
77
|
+
|
|
78
|
+
function toVideoError(e: unknown): VideoError {
|
|
79
|
+
if (e instanceof Error && "kind" in e) return e as VideoError
|
|
80
|
+
let error = new Error(String(e)) as VideoError
|
|
81
|
+
error.kind = "decode"
|
|
82
|
+
return error
|
|
41
83
|
}
|
|
42
84
|
|
|
43
85
|
/**
|
|
44
|
-
* Opens a video
|
|
45
|
-
*
|
|
86
|
+
* Opens a video (WebM, VP9 + Opus) from a path, an http(s) URL (plane
|
|
87
|
+
* players only) or a file(), and exposes it as reactive signals: read
|
|
88
|
+
* texture() in JSX and the frames appear once playback starts. Closes
|
|
46
89
|
* automatically when the reactive owner is disposed (e.g. the component
|
|
47
|
-
* unmounts). For imperative use, call
|
|
90
|
+
* unmounts), abandoning an open still in flight. For imperative use, call
|
|
91
|
+
* open() from "flux:video" directly.
|
|
48
92
|
*/
|
|
49
|
-
export function createVideo(
|
|
93
|
+
export function createVideo(source: VideoSource, options: VideoOptions = {}): VideoStream {
|
|
94
|
+
let [ready, setReady] = createSignal(false)
|
|
50
95
|
let [texture, setTexture] = createSignal<TextureId | undefined>(undefined)
|
|
51
96
|
let [width, setWidth] = createSignal<number | undefined>(undefined)
|
|
52
97
|
let [height, setHeight] = createSignal<number | undefined>(undefined)
|
|
53
98
|
let [duration, setDuration] = createSignal<number | undefined>(undefined)
|
|
54
|
-
let [error, setError] = createSignal<
|
|
55
|
-
let player:
|
|
99
|
+
let [error, setError] = createSignal<VideoError | undefined>(undefined)
|
|
100
|
+
let player: AnyPlayer | undefined
|
|
56
101
|
let disposed = false
|
|
57
102
|
let wantPlay = options.autoplay ?? false
|
|
103
|
+
// Disposal aborts a pending open, so a player never appears after the
|
|
104
|
+
// owner is gone.
|
|
105
|
+
let abort = new AbortController()
|
|
58
106
|
|
|
59
|
-
|
|
107
|
+
let opened: Promise<AnyPlayer> =
|
|
108
|
+
options.present === "plane"
|
|
109
|
+
? open(source, { present: "plane", fit: options.fit, signal: abort.signal })
|
|
110
|
+
: open(source, { signal: abort.signal })
|
|
111
|
+
opened
|
|
60
112
|
.then((video) => {
|
|
61
113
|
if (disposed) {
|
|
62
114
|
video.close()
|
|
63
115
|
return
|
|
64
116
|
}
|
|
65
117
|
player = video
|
|
66
|
-
setTexture(video.texture)
|
|
118
|
+
if ("texture" in video) setTexture(video.texture)
|
|
67
119
|
setWidth(video.width)
|
|
68
120
|
setHeight(video.height)
|
|
69
121
|
setDuration(video.duration)
|
|
122
|
+
setReady(true)
|
|
70
123
|
if (wantPlay) video.play()
|
|
124
|
+
// A failure that stops playback later lands in the same signal.
|
|
125
|
+
video.failed.then((failure) => {
|
|
126
|
+
if (failure && !disposed) setError(failure)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
.catch((e) => {
|
|
130
|
+
if (!disposed) setError(toVideoError(e))
|
|
71
131
|
})
|
|
72
|
-
.catch((e) => setError(e instanceof Error ? e : new Error(String(e))))
|
|
73
132
|
|
|
74
133
|
onCleanup(() => {
|
|
75
134
|
disposed = true
|
|
135
|
+
abort.abort()
|
|
76
136
|
if (player) {
|
|
77
137
|
player.close()
|
|
78
138
|
player = undefined
|
|
@@ -80,6 +140,7 @@ export function createVideo(path: string, options: VideoOptions = {}): VideoStre
|
|
|
80
140
|
})
|
|
81
141
|
|
|
82
142
|
return {
|
|
143
|
+
ready,
|
|
83
144
|
texture,
|
|
84
145
|
width,
|
|
85
146
|
height,
|
|
@@ -93,8 +154,13 @@ export function createVideo(path: string, options: VideoOptions = {}): VideoStre
|
|
|
93
154
|
wantPlay = false
|
|
94
155
|
player?.pause()
|
|
95
156
|
},
|
|
157
|
+
seek(seconds: number) {
|
|
158
|
+
player?.seek(seconds)
|
|
159
|
+
},
|
|
160
|
+
seekable: () => player?.seekable ?? false,
|
|
96
161
|
playing: () => player?.playing() ?? false,
|
|
97
162
|
currentTime: () => player?.currentTime() ?? 0,
|
|
98
163
|
finished: () => player?.finished() ?? false,
|
|
164
|
+
buffering: () => player?.buffering() ?? false,
|
|
99
165
|
}
|
|
100
166
|
}
|
package/src/window.ts
CHANGED
|
@@ -2,18 +2,152 @@ import { createSignal, getOwner, onCleanup, onSettled, runWithOwner, flush } fro
|
|
|
2
2
|
import { requestFrame, setPointerLock } from "flux:rendertree"
|
|
3
3
|
import { renderFrame } from "srt:render"
|
|
4
4
|
import { on, once } from "srt:events"
|
|
5
|
-
import { exit } from "srt:app"
|
|
5
|
+
import { exit as nativeExit, background as nativeBackground } from "srt:app"
|
|
6
|
+
import { platform } from "flux:process"
|
|
6
7
|
import { getEventHandler, focusedNode, setFocus, activateTextInput, setInterestRoot } from "./core"
|
|
7
8
|
import { scanForOrphans, getNodePath } from "./renderer"
|
|
8
9
|
|
|
10
|
+
// ------ Lifecycle hooks ----------------
|
|
11
|
+
|
|
12
|
+
// A suspend or quit handler; the runtime waits for a returned promise.
|
|
13
|
+
export type LifecycleHandler = () => void | Promise<void>
|
|
14
|
+
|
|
15
|
+
// How long exit() waits for the quit handlers before leaving anyway. The
|
|
16
|
+
// platform-initiated ends (window close, Android onDestroy) are bounded by the
|
|
17
|
+
// runtime's own deadline instead; this one only keeps a hung handler from
|
|
18
|
+
// pinning an app that asked to leave.
|
|
19
|
+
const EXIT_HOOK_DEADLINE_MS = 2000
|
|
20
|
+
|
|
21
|
+
let suspendHandlers: LifecycleHandler[] = []
|
|
22
|
+
let quitHandlers: LifecycleHandler[] = []
|
|
23
|
+
// The suspend dispatch still in flight, if any: a quit that follows a
|
|
24
|
+
// suspend (a finishing Android activity runs onPause, then onDestroy) waits
|
|
25
|
+
// for it before its own handlers, so the persist that suspend started lands
|
|
26
|
+
// before the process ends.
|
|
27
|
+
let pendingSuspend: Promise<void> | null = null
|
|
28
|
+
|
|
29
|
+
function registerLifecycle(list: LifecycleHandler[], fn: LifecycleHandler) {
|
|
30
|
+
list.push(fn)
|
|
31
|
+
let cleanup = () => {
|
|
32
|
+
let i = list.lastIndexOf(fn)
|
|
33
|
+
if (i >= 0) list.splice(i, 1)
|
|
34
|
+
}
|
|
35
|
+
if (getOwner()) onCleanup(cleanup)
|
|
36
|
+
return cleanup
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Runs every handler (a throw counts as a rejected promise) and settles once
|
|
40
|
+
// all of them have, logging the failures: one handler's error must not cut
|
|
41
|
+
// another's write short.
|
|
42
|
+
function settleHandlers(name: string, list: LifecycleHandler[]): Promise<void> {
|
|
43
|
+
let results = [...list].map((fn) => {
|
|
44
|
+
try {
|
|
45
|
+
return Promise.resolve(fn())
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return Promise.reject(err)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
return Promise.allSettled(results).then((settled) => {
|
|
51
|
+
for (let r of settled) {
|
|
52
|
+
if (r.status === "rejected") console.error(`Error in ${name} handler:`, r.reason)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function dispatchSuspend(): Promise<void> {
|
|
58
|
+
let p = settleHandlers("onSuspend", suspendHandlers).finally(() => {
|
|
59
|
+
if (pendingSuspend === p) pendingSuspend = null
|
|
60
|
+
})
|
|
61
|
+
pendingSuspend = p
|
|
62
|
+
return p
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function dispatchQuit(): Promise<void> {
|
|
66
|
+
let inflight = pendingSuspend ?? Promise.resolve()
|
|
67
|
+
return inflight.then(() => settleHandlers("onQuit", quitHandlers))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The runtime asks through the event bus and waits for `done()`: the
|
|
71
|
+
// platform is held open (or its close budget spent) until then, so this must
|
|
72
|
+
// answer exactly once, whatever the handlers do. Subscribed at module load:
|
|
73
|
+
// core is always present, and a hook nobody listens to would leave the
|
|
74
|
+
// runtime waiting out its deadline on every close.
|
|
75
|
+
on("suspend", (e: { done: () => void }) => {
|
|
76
|
+
dispatchSuspend().then(e.done, e.done)
|
|
77
|
+
})
|
|
78
|
+
on("quit", (e: { done: () => void }) => {
|
|
79
|
+
dispatchQuit().then(e.done, e.done)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Calls `fn` when the app is being suspended and may be killed without
|
|
84
|
+
* further notice: the user switches away on Android or iOS (the platform's
|
|
85
|
+
* "will enter background" moment). Never fires on desktop, where a minimized
|
|
86
|
+
* app is not killed. This is the moment to persist session state - a file, a
|
|
87
|
+
* database row, an HTTP request - so a later launch can pick the session up;
|
|
88
|
+
* the runtime holds the platform open and waits for a returned promise, up to
|
|
89
|
+
* its deadline (about ten seconds, platform-dependent), then lets the
|
|
90
|
+
* platform proceed. Nothing decides for you whether to restore afterwards;
|
|
91
|
+
* that is the app's call.
|
|
92
|
+
*
|
|
93
|
+
* Returns a cleanup function; also auto-cleans within an owned scope.
|
|
94
|
+
*/
|
|
95
|
+
export function onSuspend(fn: LifecycleHandler) {
|
|
96
|
+
return registerLifecycle(suspendHandlers, fn)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Calls `fn` when this app instance is ending: `exit()` (including the
|
|
101
|
+
* default action of an unprevented `back` on desktop), the desktop window
|
|
102
|
+
* closing, the Android activity finishing. Not when the app goes to the
|
|
103
|
+
* background (`background()`, or back at the root on Android). Not a last chance to save - on mobile the
|
|
104
|
+
* suspend hook has already run, and a user who quits usually does not want
|
|
105
|
+
* a session kept - but the place for work that needs a real close, and the
|
|
106
|
+
* runtime waits for a returned promise up to its deadline (well under a
|
|
107
|
+
* second on Android, where the platform's close budget is fixed; a couple of
|
|
108
|
+
* seconds on desktop). A force quit or a crash gives no signal on any
|
|
109
|
+
* platform.
|
|
110
|
+
*
|
|
111
|
+
* Returns a cleanup function; also auto-cleans within an owned scope.
|
|
112
|
+
*/
|
|
113
|
+
export function onQuit(fn: LifecycleHandler) {
|
|
114
|
+
return registerLifecycle(quitHandlers, fn)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Ends the current app: back to the player in a dev client, quitting when
|
|
119
|
+
* standalone or at the player itself (on Android the activity finishes, so
|
|
120
|
+
* the next launch starts fresh). The default action of an unprevented `back`
|
|
121
|
+
* event on desktop; call it directly to exit programmatically, e.g. after
|
|
122
|
+
* intercepting back for an unsaved-changes dialog. Runs the `onQuit`
|
|
123
|
+
* handlers first and leaves once their promises settle (or the deadline
|
|
124
|
+
* passes); returns immediately.
|
|
125
|
+
*/
|
|
126
|
+
export function exit() {
|
|
127
|
+
let deadline = new Promise<void>((resolve) => setTimeout(resolve, EXIT_HOOK_DEADLINE_MS))
|
|
128
|
+
Promise.race([dispatchQuit(), deadline]).then(nativeExit, nativeExit)
|
|
129
|
+
}
|
|
130
|
+
|
|
9
131
|
/**
|
|
10
|
-
* Leaves the current app
|
|
11
|
-
* client
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
132
|
+
* Leaves the current app without ending it: back to the player in a dev
|
|
133
|
+
* client; otherwise the OS takes the app to the background - on Android the
|
|
134
|
+
* task moves behind the others with the process kept, on desktop the window
|
|
135
|
+
* minimizes. The default action of an unprevented `back` event on Android,
|
|
136
|
+
* where that is what the system itself does at the root of an app. No
|
|
137
|
+
* `onQuit` runs (the app is not ending); `onSuspend` fires on Android on the
|
|
138
|
+
* way, as for any background.
|
|
15
139
|
*/
|
|
16
|
-
export
|
|
140
|
+
export function background() {
|
|
141
|
+
nativeBackground()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The default action of an unprevented back event: what the platform itself
|
|
145
|
+
// does at the root of an app. Android backgrounds (since Android 12);
|
|
146
|
+
// everywhere else back at the root ends the app.
|
|
147
|
+
function backDefault() {
|
|
148
|
+
if (platform === "android") background()
|
|
149
|
+
else exit()
|
|
150
|
+
}
|
|
17
151
|
|
|
18
152
|
// ------ Pointer routing -----------------
|
|
19
153
|
|
|
@@ -301,8 +435,10 @@ let backHandlers: ((e: BackEvent) => void)[] = []
|
|
|
301
435
|
* desktop dev chord). Call `e.preventDefault()` when back means in-app
|
|
302
436
|
* navigation right now (close a modal, previous screen); unprevented, the
|
|
303
437
|
* event passes to the handler registered before this one, and if none of them
|
|
304
|
-
* prevents it either, to the default action:
|
|
305
|
-
*
|
|
438
|
+
* prevents it either, to the default action: what the platform does at the
|
|
439
|
+
* root of an app - `background()` on Android, `exit()` elsewhere. Apps
|
|
440
|
+
* without a handler get the platform's behavior, which is the correct
|
|
441
|
+
* zero-effort default.
|
|
306
442
|
*
|
|
307
443
|
* Handlers form a stack: the most recently registered runs first and the first
|
|
308
444
|
* to prevent ends the dispatch, so each screen or overlay owns one step of the
|
|
@@ -516,7 +652,7 @@ export function attachWindow(nodeId: number) {
|
|
|
516
652
|
// Top of the stack down, stopping as soon as one takes the event.
|
|
517
653
|
let stack = [...backHandlers]
|
|
518
654
|
for (let i = stack.length - 1; i >= 0 && !prevented; i--) stack[i]!(e)
|
|
519
|
-
if (!prevented)
|
|
655
|
+
if (!prevented) backDefault()
|
|
520
656
|
})
|
|
521
657
|
|
|
522
658
|
unsubTextInput = on("textInput", (e: any) => {
|