@solidrt/core 0.0.54 → 0.0.56
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 +100 -22
- package/agents/painting.md +36 -12
- package/agents/performance.md +36 -6
- package/docs/reference/detached.md +20 -10
- package/docs/reference/drawing.md +10 -3
- package/docs/reference/elements.md +3 -1
- package/docs/reference/gpu.md +12 -1
- package/docs/reference/layout.md +28 -0
- package/docs/reference/text.md +34 -0
- package/examples/README.md +5 -0
- package/examples/detached-positioning.tsx +3 -1
- package/examples/line-points.tsx +21 -0
- package/examples/stagger.tsx +45 -0
- package/examples/text-flow.tsx +90 -0
- package/examples/text-glyphs.tsx +73 -0
- package/examples/view-design-size.tsx +5 -0
- package/package.json +6 -5
- package/src/color.ts +17 -0
- package/src/core.ts +28 -7
- package/src/environment.ts +87 -69
- package/src/gamepad.ts +10 -8
- package/src/gpu.ts +130 -21
- package/src/index.ts +14 -4
- package/src/pan.ts +20 -5
- package/src/renderer.ts +16 -2
- package/src/runtime-modules.d.ts +7 -7
- package/src/svg.ts +3 -2
- package/src/text-input.ts +43 -8
- package/src/transform.ts +52 -23
- package/src/types.d.ts +158 -44
- package/src/window.ts +54 -38
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Enter, exit and stagger are declared on elements; the runtime plays them
|
|
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 - the node stays
|
|
4
|
+
// painted until that settles, then frees. Both are per-property entries on
|
|
5
|
+
// the element's own transition spec. `stagger` (ms) goes on an ANCESTOR,
|
|
6
|
+
// never on the animating elements: every descendant enter or exit that
|
|
7
|
+
// begins in the same frame gets index * stagger of extra delay, in
|
|
8
|
+
// occurrence order. It cascades nothing on its own - the descendants must
|
|
9
|
+
// declare from/exit.
|
|
10
|
+
//
|
|
11
|
+
// An enter plays once per mount. To replay it, remount the subtree: a keyed
|
|
12
|
+
// <Show> re-creates its child whenever `when` changes value, so bumping an
|
|
13
|
+
// epoch replays the whole cascade. Tap anywhere: odd taps unmount the list
|
|
14
|
+
// (staggered exits), even taps mount a fresh one (staggered enters).
|
|
15
|
+
import { render, createSignal, Show } from "@solidrt/core"
|
|
16
|
+
import type { Transition } from "@solidrt/core"
|
|
17
|
+
|
|
18
|
+
const ROWS = ["Signals", "Effects", "Memos", "Stores", "Boundaries"]
|
|
19
|
+
// Extra delay per row before its enter or exit starts.
|
|
20
|
+
const STAGGER_MS = 70
|
|
21
|
+
// Slide in from the left, out to the right; fade both ways.
|
|
22
|
+
const SLIDE = { duration: 500, bounce: 0.2, from: -80, exit: 80 } satisfies Transition
|
|
23
|
+
const FADE = { duration: 350, curve: "ease-out", from: 0, exit: 0 } satisfies Transition
|
|
24
|
+
|
|
25
|
+
function App() {
|
|
26
|
+
let [epoch, setEpoch] = createSignal(1)
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<window padding={40} gap={12} onPointerDown={() => setEpoch((e) => e + 1)} transition={{ stagger: STAGGER_MS }}>
|
|
30
|
+
<text color="#8899aa" fontSize={14}>Tap to replay</text>
|
|
31
|
+
<Show when={epoch() % 2 === 1 ? epoch() : 0} keyed>
|
|
32
|
+
{(_epoch) =>
|
|
33
|
+
ROWS.map((label) => (
|
|
34
|
+
<view x={0} opacity={1} transition={{ x: SLIDE, opacity: FADE }} height={48} justifyContent="center" paddingLeft={16}>
|
|
35
|
+
<d-rect color="#2a3a55" radius={10} />
|
|
36
|
+
<text color="#e8eef6" fontSize={18}>{label}</text>
|
|
37
|
+
</view>
|
|
38
|
+
))
|
|
39
|
+
}
|
|
40
|
+
</Show>
|
|
41
|
+
</window>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
render(() => <App />)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// App-side line breaking. prepareText shapes a paragraph's words once (through
|
|
2
|
+
// the shared word cache) into units carrying advance, ink width, ascent and
|
|
3
|
+
// descent; layoutNextLine then breaks one line out of them per call, at
|
|
4
|
+
// whatever width it is handed, and returns the cursor the next call continues
|
|
5
|
+
// from. The engine never sees a "shape" or a "column": here the paragraph is
|
|
6
|
+
// poured band by band into a circle (each band's width is the chord at that
|
|
7
|
+
// height, its line centered on it) and, when the circle is full, the same
|
|
8
|
+
// cursor continues in a column beside it. Every line is a d-text of exactly its
|
|
9
|
+
// own text, so the whole break redoes on every resize for arithmetic plus
|
|
10
|
+
// word-cache hits; nothing is shaped twice.
|
|
11
|
+
import { For, createMemo, layoutNextLine, prepareText, render, windowSize } from "@solidrt/core"
|
|
12
|
+
|
|
13
|
+
const FONT = { fontSize: 16, lineHeight: 1.4 }
|
|
14
|
+
const PAD = 32
|
|
15
|
+
// Gap between the circle and the column that continues its text.
|
|
16
|
+
const GAP = 32
|
|
17
|
+
// A chord shorter than this holds no word: skip the band at the circle's poles.
|
|
18
|
+
const MIN_BAND = 90
|
|
19
|
+
// Breathing room between the ring and the line ends.
|
|
20
|
+
const INSET = 10
|
|
21
|
+
|
|
22
|
+
const TEXT =
|
|
23
|
+
"A word is shaped once and keeps its width. From there a line is a loop: add words while the pen stays " +
|
|
24
|
+
"inside the width you were given, stop, and hand the cursor to whoever wants the next line. Give each " +
|
|
25
|
+
"call the chord of a circle and the paragraph fills the circle; give the next call a column and it " +
|
|
26
|
+
"continues there, because a column is only the same loop in a new box. The engine shaped the words " +
|
|
27
|
+
"before the first frame and never hears about circles or columns at all. Resize the window: the circle " +
|
|
28
|
+
"grows, every band changes width, and the text re-breaks into it while the words stay as they were. " +
|
|
29
|
+
"That is the whole trick, and it is the oldest one in typesetting: a compositor with a case of type " +
|
|
30
|
+
"never reshaped a letter when the column narrowed. The letters kept their width and only the breaks " +
|
|
31
|
+
"moved. Text engines lost that when a paragraph became one opaque object laid out at one width, and " +
|
|
32
|
+
"every layout question turned into a re-shape. Prepared text gives the idea back, and this page is that " +
|
|
33
|
+
"loop and nothing else."
|
|
34
|
+
|
|
35
|
+
type Placed = { x: number, y: number, w: number, text: string }
|
|
36
|
+
|
|
37
|
+
function App() {
|
|
38
|
+
let prepared = prepareText(TEXT, FONT)
|
|
39
|
+
let lines = createMemo<Placed[]>(() => {
|
|
40
|
+
let { width, height } = windowSize()
|
|
41
|
+
let r = Math.min(width * 0.24, (height - 2 * PAD) / 2)
|
|
42
|
+
let cx = PAD + r
|
|
43
|
+
let cy = height / 2
|
|
44
|
+
let units = prepared.units
|
|
45
|
+
// Single style: every line is as tall as its first unit.
|
|
46
|
+
let lineH = units[0]!.ascent + units[0]!.descent
|
|
47
|
+
let out: Placed[] = []
|
|
48
|
+
let cursor = 0
|
|
49
|
+
// The circle, top to bottom: each band's width is the chord at its middle.
|
|
50
|
+
for (let y = cy - r; y + lineH <= cy + r && cursor < units.length; y += lineH) {
|
|
51
|
+
let dy = y + lineH / 2 - cy
|
|
52
|
+
let half = Math.sqrt(Math.max(0, (r - INSET) * (r - INSET) - dy * dy))
|
|
53
|
+
if (2 * half < MIN_BAND) continue
|
|
54
|
+
let line = layoutNextLine(prepared, cursor, 2 * half)
|
|
55
|
+
if (!line) break
|
|
56
|
+
out.push({ x: cx - line.width / 2, y, w: line.width, text: prepared.text.slice(line.start, line.end) })
|
|
57
|
+
cursor = line.cursor
|
|
58
|
+
}
|
|
59
|
+
// The column beside it continues from the same cursor.
|
|
60
|
+
let colX = cx + r + GAP
|
|
61
|
+
let colW = width - colX - PAD
|
|
62
|
+
for (let y = PAD; y + lineH <= height - PAD && cursor < units.length; y += lineH) {
|
|
63
|
+
let line = layoutNextLine(prepared, cursor, colW)
|
|
64
|
+
if (!line) break
|
|
65
|
+
out.push({ x: colX, y, w: line.width, text: prepared.text.slice(line.start, line.end) })
|
|
66
|
+
cursor = line.cursor
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
})
|
|
70
|
+
let circle = createMemo(() => {
|
|
71
|
+
let { width, height } = windowSize()
|
|
72
|
+
let r = Math.min(width * 0.24, (height - 2 * PAD) / 2)
|
|
73
|
+
return { x: PAD, y: height / 2 - r, d: 2 * r }
|
|
74
|
+
})
|
|
75
|
+
return (
|
|
76
|
+
<window>
|
|
77
|
+
<d-rect color="#f4efe6" />
|
|
78
|
+
<d-oval x={circle().x} y={circle().y} w={circle().d} h={circle().d} drawStyle="stroke" strokeWidth={1} color="#d8b26a" />
|
|
79
|
+
<For each={lines()} keyed={false}>
|
|
80
|
+
{line => (
|
|
81
|
+
<d-text x={line().x} y={line().y} w={line().w + 1} {...FONT} color="#2b2620">
|
|
82
|
+
{line().text}
|
|
83
|
+
</d-text>
|
|
84
|
+
)}
|
|
85
|
+
</For>
|
|
86
|
+
</window>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
render(() => <App />)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Per-glyph positions. prepareText with `carets: true` reports, per wrap unit,
|
|
2
|
+
// the x of every grapheme boundary from the shaping the engine draws - kerning
|
|
3
|
+
// included - so one d-text per glyph placed from them sits exactly on the whole
|
|
4
|
+
// headline. Measuring characters one at a time with measureText cannot know a
|
|
5
|
+
// glyph's neighbors, so pairs like AV and TA come out loose and the row drifts
|
|
6
|
+
// right. Three rows of the same headline: drawn whole, per glyph from carets,
|
|
7
|
+
// per glyph from measureText. Per-glyph d-texts are what a per-character effect
|
|
8
|
+
// (a wave, a color cycle, a stagger) animates.
|
|
9
|
+
import { measureText, prepareText, render } from "@solidrt/core"
|
|
10
|
+
|
|
11
|
+
const FONT = { fontSize: 48, fontWeight: 800 } as const
|
|
12
|
+
const HEADLINE = "AVATAR Two Ya"
|
|
13
|
+
const LEFT = 32
|
|
14
|
+
const TOP = 40
|
|
15
|
+
const ROW = 96
|
|
16
|
+
// A single glyph never wraps at this width; it only bounds the d-text.
|
|
17
|
+
const GLYPH_W = FONT.fontSize * 2
|
|
18
|
+
const LABEL = { fontSize: 13, color: "#8a8378" }
|
|
19
|
+
|
|
20
|
+
type Glyph = { x: number, text: string }
|
|
21
|
+
|
|
22
|
+
// Kerned: each unit's pen position plus its caret offsets.
|
|
23
|
+
function fromCarets(): Glyph[] {
|
|
24
|
+
let prepared = prepareText(HEADLINE, { ...FONT, carets: true })
|
|
25
|
+
let out: Glyph[] = []
|
|
26
|
+
let pen = 0
|
|
27
|
+
for (let unit of prepared.units) {
|
|
28
|
+
let carets = unit.carets!
|
|
29
|
+
for (let i = 0; i + 1 < carets.length; i++) {
|
|
30
|
+
out.push({ x: pen + carets[i]!.x, text: prepared.text.slice(carets[i]!.offset, carets[i + 1]!.offset) })
|
|
31
|
+
}
|
|
32
|
+
pen += unit.advance
|
|
33
|
+
}
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Unkerned: each character measured alone, advanced by its own width.
|
|
38
|
+
function fromMeasure(): Glyph[] {
|
|
39
|
+
let out: Glyph[] = []
|
|
40
|
+
let x = 0
|
|
41
|
+
for (let ch of HEADLINE) {
|
|
42
|
+
out.push({ x, text: ch })
|
|
43
|
+
x += measureText(ch, FONT).width
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function Row(props: { y: number, label: string, glyphs: Glyph[], color: string }) {
|
|
49
|
+
return (
|
|
50
|
+
<d-view x={LEFT} y={props.y}>
|
|
51
|
+
<d-text y={-20} {...LABEL}>{props.label}</d-text>
|
|
52
|
+
{props.glyphs.map(g => (
|
|
53
|
+
<d-text x={g.x} w={GLYPH_W} {...FONT} color={props.color}>{g.text}</d-text>
|
|
54
|
+
))}
|
|
55
|
+
</d-view>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function App() {
|
|
60
|
+
return (
|
|
61
|
+
<window>
|
|
62
|
+
<d-rect color="#f4efe6" />
|
|
63
|
+
<d-view x={LEFT} y={TOP}>
|
|
64
|
+
<d-text y={-20} {...LABEL}>whole headline</d-text>
|
|
65
|
+
<d-text {...FONT} color="#2b2620">{HEADLINE}</d-text>
|
|
66
|
+
</d-view>
|
|
67
|
+
<Row y={TOP + ROW} label="per glyph from carets: true (kerned)" glyphs={fromCarets()} color="#2b2620" />
|
|
68
|
+
<Row y={TOP + 2 * ROW} label="per glyph from measureText per character (drifts)" glyphs={fromMeasure()} color="#b3452b" />
|
|
69
|
+
</window>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
render(() => <App />)
|
|
@@ -30,6 +30,11 @@
|
|
|
30
30
|
// geometry - diagrams, slides, dashboards, game boards, emulator screens - fit
|
|
31
31
|
// one design space instead.
|
|
32
32
|
//
|
|
33
|
+
// Design space changes the units, not the element choice: laid-out views and
|
|
34
|
+
// text stay the default under a designSize (fact 3). The d-* scene below is
|
|
35
|
+
// free-form drawn geometry; panels, cards and labels would still be built
|
|
36
|
+
// from laid-out elements, as the flex row at the bottom shows.
|
|
37
|
+
//
|
|
33
38
|
// Resize the window and watch: the scene keeps its aspect ratio, everything
|
|
34
39
|
// including text scales together, and the readout reports the same design
|
|
35
40
|
// coordinates for the same spot on the scene at any window size.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.56",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"funding": "https://github.com/sponsors/wellawaretech",
|
|
6
6
|
"author": "Antoine van Wel",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"./microphone": "./src/microphone.ts",
|
|
18
18
|
"./speech-recognition": "./src/speech-recognition.ts",
|
|
19
19
|
"./text-input": "./src/text-input.ts",
|
|
20
|
+
"./video": "./src/video.ts",
|
|
20
21
|
"./jsx-runtime": "./jsx-runtime.d.ts",
|
|
21
22
|
"./jsx-runtime-dev": "./jsx-runtime.d.ts"
|
|
22
23
|
},
|
|
@@ -29,11 +30,11 @@
|
|
|
29
30
|
"AGENTS.md"
|
|
30
31
|
],
|
|
31
32
|
"devDependencies": {
|
|
32
|
-
"@solidrt/flux-types": "0.0.
|
|
33
|
+
"@solidrt/flux-types": "0.0.56"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
|
35
|
-
"@solidjs/signals": "2.0.0-rc.
|
|
36
|
-
"@solidjs/universal": "2.0.0-rc.
|
|
37
|
-
"solid-js": "2.0.0-rc.
|
|
36
|
+
"@solidjs/signals": "2.0.0-rc.6",
|
|
37
|
+
"@solidjs/universal": "2.0.0-rc.6",
|
|
38
|
+
"solid-js": "2.0.0-rc.6"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/src/color.ts
CHANGED
|
@@ -26,6 +26,23 @@ export function mixColors(a: string, b: string, t: number): string {
|
|
|
26
26
|
return tree.mixColors(a, b, t)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Alpha byte range of the packed 0xRRGGBBAA color.
|
|
30
|
+
const ALPHA_MAX = 255
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The same CSS color at opacity `alpha` (0 = transparent, 1 = opaque),
|
|
34
|
+
* replacing whatever alpha the input carried. Returns `#rrggbbaa`. Accepts
|
|
35
|
+
* any color `parseColor` does, so a named color or an `rgb()` is fine. For a
|
|
36
|
+
* transparent variant of a theme color prefer this over an `rgba()` literal
|
|
37
|
+
* rebuilt by hand; for a tone that must not depend on what is drawn beneath,
|
|
38
|
+
* `mixColors` against the backdrop.
|
|
39
|
+
*/
|
|
40
|
+
export function withAlpha(color: string, alpha: number): string {
|
|
41
|
+
let rgb = parseColor(color) >>> 8
|
|
42
|
+
let a = Math.round(Math.min(1, Math.max(0, alpha)) * ALPHA_MAX)
|
|
43
|
+
return "#" + rgb.toString(16).padStart(6, "0") + a.toString(16).padStart(2, "0")
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
/**
|
|
30
47
|
* Perceived brightness of a CSS color, 0 (black) to 1 (white), YIQ-weighted.
|
|
31
48
|
* Compare a text color against its backdrop to decide rendering polarity,
|
package/src/core.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as tree from "flux:rendertree"
|
|
2
2
|
import type { TextureId } from "flux:gpu"
|
|
3
|
-
import { createSignal, onCleanup } from "@solidjs/signals"
|
|
3
|
+
import { createSignal, getOwner, onCleanup } from "@solidjs/signals"
|
|
4
4
|
import { on } from "srt:events"
|
|
5
5
|
|
|
6
6
|
let handlers = new Map<number, Map<string, Function>>()
|
|
@@ -93,7 +93,7 @@ export function onPointerMove(fn: (e: GlobalPointerEvent) => void): () => void {
|
|
|
93
93
|
if (interestRoot != null) syncInterest(interestRoot)
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
|
-
onCleanup(cleanup)
|
|
96
|
+
if (getOwner()) onCleanup(cleanup)
|
|
97
97
|
return cleanup
|
|
98
98
|
}
|
|
99
99
|
|
|
@@ -326,6 +326,19 @@ export function getBoundingBoxViewport(node: { id: number }): BoundingBox | null
|
|
|
326
326
|
return tree.getBoundingBoxViewport(node.id)
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
/**
|
|
330
|
+
* The node's laid-out box in its parent's frame: layout output before any
|
|
331
|
+
* transform composes (DOM offsetLeft/offsetTop/offsetWidth/offsetHeight
|
|
332
|
+
* semantics). The untransformed companion to getBoundingBox: pointer events
|
|
333
|
+
* report localX/localY in this box's units, whatever designSize fits or
|
|
334
|
+
* transforms sit on the ancestor chain. A snapshot read like getBoundingBox
|
|
335
|
+
* (call it inside `onLayout` or an event handler); `null` for detached nodes
|
|
336
|
+
* and before the first layout.
|
|
337
|
+
*/
|
|
338
|
+
export function getLayoutBox(node: { id: number }): BoundingBox | null {
|
|
339
|
+
return tree.getLayoutBox(node.id)
|
|
340
|
+
}
|
|
341
|
+
|
|
329
342
|
/**
|
|
330
343
|
* The texture id of a snapshot boundary's retained rasterization
|
|
331
344
|
* (`repaintBoundary="snapshot"`): the subtree's pixels as a live texture any
|
|
@@ -352,11 +365,19 @@ export function measureText(text: string, options?: tree.MeasureTextOptions): {
|
|
|
352
365
|
}
|
|
353
366
|
|
|
354
367
|
/**
|
|
355
|
-
* Segments `text` into wrap units (words with their
|
|
356
|
-
* shapes each in the given font, once,
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
368
|
+
* App-side line breaking. Segments `text` into wrap units (words with their
|
|
369
|
+
* trailing whitespace) and shapes each in the given font, once, through the
|
|
370
|
+
* shared word cache; laying lines out is then arithmetic over `units`, with
|
|
371
|
+
* layoutNextLine or a loop of your own. This is how text goes into a shape,
|
|
372
|
+
* parts around an obstacle that is not in the flow, pours across columns
|
|
373
|
+
* (layoutNextLine's `cursor` continues a paragraph in the next box), fits a
|
|
374
|
+
* box by trying sizes, and gets per-glyph positions (`carets: true`; the
|
|
375
|
+
* only kerned source, see TextUnit.carets). Draw each line as a d-text of
|
|
376
|
+
* exactly its own text. Re-breaking every frame is cheap - the words are
|
|
377
|
+
* shaped already and the lines' d-texts hit the same cache - so a shape
|
|
378
|
+
* that moves or breathes can re-flow at frame rate. A paragraph that just
|
|
379
|
+
* wraps in a box is a <text>, which also does floats, balancing and
|
|
380
|
+
* ellipsis.
|
|
360
381
|
*/
|
|
361
382
|
export function prepareText(text: string, options?: tree.MeasureTextOptions): tree.PreparedText {
|
|
362
383
|
return tree.prepareText(text, options)
|
package/src/environment.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createSignal, runWithOwner } from "@solidjs/signals"
|
|
2
2
|
import { on } from "srt:events"
|
|
3
3
|
import { onPointerMove } from "./core"
|
|
4
4
|
import { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } from "./window"
|
|
@@ -12,13 +12,17 @@ import { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } fro
|
|
|
12
12
|
// capability derived from them can appear mid-session (e.g. the first mouse
|
|
13
13
|
// move) but never flickers away.
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
15
|
+
// Every fact below is lazily created the first time its ensure* function
|
|
16
|
+
// runs, which is usually inside a component or a computation (a memo's first
|
|
17
|
+
// read of env.inputDevices, a HUD effect's first read of pointerLocked()).
|
|
18
|
+
// Created there, the fact would belong to that reader: sticky events replay
|
|
19
|
+
// their cached value synchronously on subscribe (srt:events' on()), so the
|
|
20
|
+
// on() call writes the signal it just created inside the reader's owned
|
|
21
|
+
// scope - a render-time write, which the guard rejects - and a root or a
|
|
22
|
+
// subscription created there is disposed with the reader. The facts are
|
|
23
|
+
// app-lifetime, so each ensure* creates under no owner (runWithOwner(null)):
|
|
24
|
+
// the replay lands unowned, nothing is filed under the reader, and no
|
|
25
|
+
// `ownedWrite` opt-out is needed.
|
|
22
26
|
|
|
23
27
|
/** Connected input device classes, as reported by the runtime. */
|
|
24
28
|
export interface InputDevices {
|
|
@@ -39,58 +43,68 @@ let devicesAccessor: (() => InputDevices | undefined) | undefined
|
|
|
39
43
|
|
|
40
44
|
function ensureDevicesState() {
|
|
41
45
|
if (devicesAccessor) return
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
runWithOwner(null, () => {
|
|
47
|
+
let [devices, setDevices] = createSignal<InputDevices | undefined>(undefined)
|
|
48
|
+
// Sticky: the current state replays on subscribe, so the first read already
|
|
49
|
+
// sees it on runtimes that report devices.
|
|
50
|
+
on("inputDevices", (d: InputDevices) => {
|
|
51
|
+
setDevices({ keyboard: !!d.keyboard, mouse: !!d.mouse, touch: !!d.touch, screenKeyboard: !!d.screenKeyboard })
|
|
52
|
+
})
|
|
53
|
+
devicesAccessor = devices
|
|
47
54
|
})
|
|
48
|
-
devicesAccessor = devices
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
let systemThemeAccessor: (() => SystemTheme) | undefined
|
|
52
58
|
|
|
53
59
|
function ensureSystemThemeState() {
|
|
54
60
|
if (systemThemeAccessor) return
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
runWithOwner(null, () => {
|
|
62
|
+
let [theme, setTheme] = createSignal<SystemTheme>("unknown")
|
|
63
|
+
on("systemTheme", (e: { theme?: SystemTheme }) => setTheme(e.theme ?? "unknown"))
|
|
64
|
+
systemThemeAccessor = theme
|
|
65
|
+
})
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
let visibilityAccessor: (() => Visibility) | undefined
|
|
61
69
|
|
|
62
70
|
function ensureVisibilityState() {
|
|
63
71
|
if (visibilityAccessor) return
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
runWithOwner(null, () => {
|
|
73
|
+
let [visibility, setVisibility] = createSignal<Visibility>("visible")
|
|
74
|
+
// Sticky. The runtime may report the same state through several platform
|
|
75
|
+
// paths; the signal's equality check turns repeats into no-ops for
|
|
76
|
+
// reactive consumers.
|
|
77
|
+
on("visibility", (e: { state?: Visibility }) => setVisibility(e.state === "hidden" ? "hidden" : "visible"))
|
|
78
|
+
visibilityAccessor = visibility
|
|
79
|
+
})
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
let orientationAccessor: (() => Orientation) | undefined
|
|
73
83
|
|
|
74
84
|
function ensureOrientationState() {
|
|
75
85
|
if (orientationAccessor) return
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
runWithOwner(null, () => {
|
|
87
|
+
let [orientation, setOrientation] = createSignal<Orientation>("unknown")
|
|
88
|
+
on("displayOrientation", (e: { orientation?: Orientation }) => {
|
|
89
|
+
setOrientation(e.orientation ?? "unknown")
|
|
90
|
+
})
|
|
91
|
+
orientationAccessor = orientation
|
|
79
92
|
})
|
|
80
|
-
orientationAccessor = orientation
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
let textScaleAccessor: (() => number) | undefined
|
|
84
96
|
|
|
85
97
|
function ensureTextScaleState() {
|
|
86
98
|
if (textScaleAccessor) return
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
99
|
+
runWithOwner(null, () => {
|
|
100
|
+
let [scale, setScale] = createSignal(1)
|
|
101
|
+
// Sticky, like systemTheme. Guard nonsense values: a runtime bug reporting
|
|
102
|
+
// 0 or a negative would otherwise collapse all text.
|
|
103
|
+
on("textScale", (e: { scale?: number }) => {
|
|
104
|
+
setScale(typeof e.scale === "number" && e.scale > 0 ? e.scale : 1)
|
|
105
|
+
})
|
|
106
|
+
textScaleAccessor = scale
|
|
92
107
|
})
|
|
93
|
-
textScaleAccessor = scale
|
|
94
108
|
}
|
|
95
109
|
|
|
96
110
|
let mouseSeenAccessor: (() => boolean) | undefined
|
|
@@ -98,50 +112,54 @@ let touchSeenAccessor: (() => boolean) | undefined
|
|
|
98
112
|
|
|
99
113
|
function ensurePointerState() {
|
|
100
114
|
if (mouseSeenAccessor) return
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
sawMouse
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
sawTouch
|
|
117
|
-
|
|
115
|
+
runWithOwner(null, () => {
|
|
116
|
+
let [mouse, setMouse] = createSignal(false)
|
|
117
|
+
let [touch, setTouch] = createSignal(false)
|
|
118
|
+
let sawMouse = false
|
|
119
|
+
let sawTouch = false
|
|
120
|
+
let unsubs: (() => void)[] = []
|
|
121
|
+
let unsubMove: () => void = null!
|
|
122
|
+
let note = (e: { pointerType?: string }) => {
|
|
123
|
+
if (e.pointerType === "mouse" && !sawMouse) {
|
|
124
|
+
sawMouse = true
|
|
125
|
+
setMouse(true)
|
|
126
|
+
// Moves have nothing left to teach: touch is learned from downs (a
|
|
127
|
+
// touch never moves without one), so drop the move subscription and
|
|
128
|
+
// with it the ambient interest bit that forces move deliveries.
|
|
129
|
+
unsubMove()
|
|
130
|
+
} else if (e.pointerType === "touch" && !sawTouch) {
|
|
131
|
+
sawTouch = true
|
|
132
|
+
setTouch(true)
|
|
133
|
+
}
|
|
134
|
+
// Both types observed: nothing left to learn, stop listening.
|
|
135
|
+
if (sawMouse && sawTouch) for (let u of unsubs) u()
|
|
118
136
|
}
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
mouseSeenAccessor = mouse
|
|
129
|
-
touchSeenAccessor = touch
|
|
137
|
+
// Downs always deliver, so the raw bus tap suffices; moves are gated when
|
|
138
|
+
// nobody listens, so they go through onPointerMove, whose subscription
|
|
139
|
+
// keeps them flowing. Unowned here, so it registers no cleanup: the
|
|
140
|
+
// returned unsubscribe is the probe's only handle, as intended.
|
|
141
|
+
unsubMove = onPointerMove(note)
|
|
142
|
+
unsubs.push(unsubMove, on("pointerDown", note))
|
|
143
|
+
mouseSeenAccessor = mouse
|
|
144
|
+
touchSeenAccessor = touch
|
|
145
|
+
})
|
|
130
146
|
}
|
|
131
147
|
|
|
132
148
|
let keyboardSeenAccessor: (() => boolean) | undefined
|
|
133
149
|
|
|
134
150
|
function ensureKeyboardState() {
|
|
135
151
|
if (keyboardSeenAccessor) return
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
152
|
+
runWithOwner(null, () => {
|
|
153
|
+
let [keyboard, setKeyboard] = createSignal(false)
|
|
154
|
+
// Soft keyboards also deliver some keydowns (Backspace, Return), so this can
|
|
155
|
+
// read true on a touch-only device once the user types in a field. Only a
|
|
156
|
+
// fallback: capabilities prefer runtime-reported device presence.
|
|
157
|
+
let unsub = on("keydown", () => {
|
|
158
|
+
setKeyboard(true)
|
|
159
|
+
unsub()
|
|
160
|
+
})
|
|
161
|
+
keyboardSeenAccessor = keyboard
|
|
143
162
|
})
|
|
144
|
-
keyboardSeenAccessor = keyboard
|
|
145
163
|
}
|
|
146
164
|
|
|
147
165
|
/**
|
package/src/gamepad.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createSignal } from "@solidjs/signals"
|
|
1
|
+
import { createSignal, runWithOwner } from "@solidjs/signals"
|
|
2
2
|
import { on } from "srt:events"
|
|
3
3
|
|
|
4
4
|
// Gamepad State: a reactive mirror of the runtime's sticky "gamepads" event.
|
|
@@ -18,7 +18,7 @@ import { on } from "srt:events"
|
|
|
18
18
|
*
|
|
19
19
|
* The snapshot is a faithful report. Note that pressing "back" (select) on a
|
|
20
20
|
* mapped pad ALSO emits the `back` event (see onBack) - it is the pad-side
|
|
21
|
-
* sibling of Android's system back, the runtime's exit-to-
|
|
21
|
+
* sibling of Android's system back, the runtime's exit-to-player gesture.
|
|
22
22
|
* Apps that bind "back" for their own controls should preventDefault that
|
|
23
23
|
* event.
|
|
24
24
|
*/
|
|
@@ -52,11 +52,13 @@ let gamepadsAccessor: (() => (GamepadState | null)[]) | undefined
|
|
|
52
52
|
*/
|
|
53
53
|
export function gamepads(): (GamepadState | null)[] {
|
|
54
54
|
if (!gamepadsAccessor) {
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
// Under no owner: the sticky event replays synchronously inside on(),
|
|
56
|
+
// and the first read is usually a tracked scope (see environment.ts).
|
|
57
|
+
runWithOwner(null, () => {
|
|
58
|
+
let [pads, setPads] = createSignal<(GamepadState | null)[]>([])
|
|
59
|
+
on("gamepads", (e: { pads?: (GamepadState | null)[] }) => setPads(e.pads ?? []))
|
|
60
|
+
gamepadsAccessor = pads
|
|
61
|
+
})
|
|
60
62
|
}
|
|
61
|
-
return gamepadsAccessor()
|
|
63
|
+
return gamepadsAccessor!()
|
|
62
64
|
}
|