@solidrt/core 0.0.49 → 0.0.51
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 +23 -0
- package/README.md +1 -1
- package/agents/painting.md +61 -0
- package/agents/performance.md +216 -0
- package/examples/README.md +2 -2
- package/examples/{sound.tsx → audio.tsx} +23 -3
- package/examples/gpu-sprites.tsx +102 -0
- package/jsx-runtime.d.ts +19 -14
- package/package.json +7 -6
- package/src/{sound.ts → audio.ts} +99 -18
- package/src/color.ts +17 -18
- package/src/core.ts +88 -0
- package/src/data.ts +99 -0
- package/src/gpu.ts +32 -10
- package/src/index.ts +4 -4
- package/src/renderer.ts +43 -27
- package/src/runtime-modules.d.ts +3 -2
- package/src/scroll.ts +1 -1
- package/src/text-input.ts +297 -60
- package/src/types.d.ts +233 -5
- package/src/window.ts +58 -7
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// Sound playback, reactive (SolidJS) layer. `createSound` decodes an encoded
|
|
2
2
|
// clip (Ogg/Vorbis or WAV) once and owns its lifecycle: the decoded clip is
|
|
3
3
|
// released, and any playing voices stopped, when the reactive owner is disposed.
|
|
4
|
-
// Each play() is cheap (no re-decode). `
|
|
5
|
-
//
|
|
4
|
+
// Each play() is cheap (no re-decode). `createPcmSound` is the same over raw
|
|
5
|
+
// samples the app generated itself; `createSoundStream` reads a large track
|
|
6
|
+
// from a file path on demand instead of decoding it into memory.
|
|
6
7
|
//
|
|
7
8
|
// The imperative primitive lives in the `flux:audio` module; import
|
|
8
9
|
// { play, load, loadPcm, stream } from "flux:audio" for non-reactive use.
|
|
9
10
|
|
|
10
11
|
import { createSignal, onCleanup } from "@solidjs/signals"
|
|
11
|
-
import { load, stream } from "flux:audio"
|
|
12
|
+
import { load, loadPcm, stream } from "flux:audio"
|
|
12
13
|
import { file } from "flux:fs"
|
|
13
14
|
|
|
14
15
|
type FluxFile = ReturnType<typeof file>
|
|
@@ -26,6 +27,15 @@ export type SoundOptions = {
|
|
|
26
27
|
* Omitted means unspatialized.
|
|
27
28
|
*/
|
|
28
29
|
pan?: number
|
|
30
|
+
/**
|
|
31
|
+
* Playback rate: 1.0 plays as loaded, higher is faster and higher-pitched
|
|
32
|
+
* (clamped to [0.01, 100]). Defaults to 1.0.
|
|
33
|
+
*/
|
|
34
|
+
rate?: number
|
|
35
|
+
/** Fade each play() in from silence over this many milliseconds. */
|
|
36
|
+
fadeInMs?: number
|
|
37
|
+
/** Bus name for every voice of this sound (see flux:audio `stop({ bus })`). */
|
|
38
|
+
bus?: string
|
|
29
39
|
/**
|
|
30
40
|
* Let play() stack overlapping voices instead of restarting. Defaults to
|
|
31
41
|
* true: rapid triggers overlap. Set false for a single-voice sound where each
|
|
@@ -34,6 +44,27 @@ export type SoundOptions = {
|
|
|
34
44
|
overlap?: boolean
|
|
35
45
|
}
|
|
36
46
|
|
|
47
|
+
/** Options for the live setters: ramp to the value instead of jumping. */
|
|
48
|
+
export type SoundRampOptions = {
|
|
49
|
+
/**
|
|
50
|
+
* Reach the new value over this many milliseconds, engine-smoothed (immune
|
|
51
|
+
* to frame hitches). Omitted (or 0) sets immediately.
|
|
52
|
+
*/
|
|
53
|
+
rampMs?: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Options for {@link Sound.stop}. */
|
|
57
|
+
export type SoundStopOptions = {
|
|
58
|
+
/** Fade to silence over this many milliseconds before stopping. */
|
|
59
|
+
fadeOutMs?: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Options for a PCM sound: `SoundOptions` plus the channel count. */
|
|
63
|
+
export type PcmSoundOptions = SoundOptions & {
|
|
64
|
+
/** Channel count, interleaved samples when 2. Defaults to 1 (mono). */
|
|
65
|
+
channels?: 1 | 2
|
|
66
|
+
}
|
|
67
|
+
|
|
37
68
|
/** Options for a streamed sound. Streams are always single-voice. */
|
|
38
69
|
export type SoundStreamOptions = {
|
|
39
70
|
/** Repeat the track until stopped. Defaults to false. */
|
|
@@ -42,18 +73,26 @@ export type SoundStreamOptions = {
|
|
|
42
73
|
gain?: number
|
|
43
74
|
/** Stereo position in [-1, 1] (see {@link SoundOptions.pan}). */
|
|
44
75
|
pan?: number
|
|
76
|
+
/** Playback rate (see {@link SoundOptions.rate}). Defaults to 1.0. */
|
|
77
|
+
rate?: number
|
|
78
|
+
/** Fade each play() in from silence over this many milliseconds. */
|
|
79
|
+
fadeInMs?: number
|
|
80
|
+
/** Bus name for the stream's voice (see flux:audio `stop({ bus })`). */
|
|
81
|
+
bus?: string
|
|
45
82
|
}
|
|
46
83
|
|
|
47
84
|
/** A decoded sound with reactive lifecycle. */
|
|
48
85
|
export type Sound = {
|
|
49
86
|
/** Start the clip. Overlaps or restarts per the `overlap` option. */
|
|
50
87
|
play(): void
|
|
51
|
-
/** Stop every voice started from this sound. */
|
|
52
|
-
stop(): void
|
|
88
|
+
/** Stop every voice started from this sound, fading first if asked. */
|
|
89
|
+
stop(options?: SoundStopOptions): void
|
|
53
90
|
/** Set the volume of every live voice, and of voices started later. */
|
|
54
|
-
setGain(gain: number): void
|
|
91
|
+
setGain(gain: number, options?: SoundRampOptions): void
|
|
55
92
|
/** Set the stereo position of every live voice, and of voices started later. */
|
|
56
|
-
setPan(pan: number): void
|
|
93
|
+
setPan(pan: number, options?: SoundRampOptions): void
|
|
94
|
+
/** Set the playback rate of every live voice, and of voices started later. */
|
|
95
|
+
setRate(rate: number, options?: SoundRampOptions): void
|
|
57
96
|
/** True after play() until stop() (does not track natural completion). */
|
|
58
97
|
playing(): boolean
|
|
59
98
|
/** Set if loading failed. */
|
|
@@ -62,12 +101,12 @@ export type Sound = {
|
|
|
62
101
|
|
|
63
102
|
// Shared reactive wrapper: owns the loaded clip, tracks live voices, and
|
|
64
103
|
// disposes both on cleanup. `loader` runs once (may throw -> error signal).
|
|
65
|
-
// Gain and
|
|
66
|
-
// the sound, not back at the initial options.
|
|
104
|
+
// Gain, pan and rate are remembered so later voices start where the setters
|
|
105
|
+
// left the sound, not back at the initial options.
|
|
67
106
|
function reactiveSound(
|
|
68
107
|
loader: () => Clip,
|
|
69
108
|
overlap: boolean,
|
|
70
|
-
initial: { loop?: boolean; gain?: number; pan?: number },
|
|
109
|
+
initial: { loop?: boolean; gain?: number; pan?: number; rate?: number; fadeInMs?: number; bus?: string },
|
|
71
110
|
): Sound {
|
|
72
111
|
let [error, setError] = createSignal<Error | undefined>(undefined, { ownedWrite: true })
|
|
73
112
|
let [playing, setPlaying] = createSignal(false, { ownedWrite: true })
|
|
@@ -77,6 +116,9 @@ function reactiveSound(
|
|
|
77
116
|
let loop = initial.loop
|
|
78
117
|
let gain = initial.gain
|
|
79
118
|
let pan = initial.pan
|
|
119
|
+
let rate = initial.rate
|
|
120
|
+
let fadeInMs = initial.fadeInMs
|
|
121
|
+
let bus = initial.bus
|
|
80
122
|
try {
|
|
81
123
|
clip = loader()
|
|
82
124
|
} catch (e) {
|
|
@@ -89,8 +131,8 @@ function reactiveSound(
|
|
|
89
131
|
voices = voices.filter((v) => !v.ended())
|
|
90
132
|
}
|
|
91
133
|
|
|
92
|
-
let stopAll = () => {
|
|
93
|
-
for (let v of voices) v.stop()
|
|
134
|
+
let stopAll = (options?: SoundStopOptions) => {
|
|
135
|
+
for (let v of voices) v.stop(options)
|
|
94
136
|
voices = []
|
|
95
137
|
setPlaying(false)
|
|
96
138
|
}
|
|
@@ -108,19 +150,24 @@ function reactiveSound(
|
|
|
108
150
|
if (!clip) return
|
|
109
151
|
if (overlap) prune()
|
|
110
152
|
else stopAll()
|
|
111
|
-
voices.push(clip.play({ loop, gain, pan }))
|
|
153
|
+
voices.push(clip.play({ loop, gain, pan, rate, fadeInMs, bus }))
|
|
112
154
|
setPlaying(true)
|
|
113
155
|
},
|
|
114
156
|
stop: stopAll,
|
|
115
|
-
setGain(value) {
|
|
157
|
+
setGain(value, options) {
|
|
116
158
|
gain = value
|
|
117
159
|
prune()
|
|
118
|
-
for (let v of voices) v.setGain(value)
|
|
160
|
+
for (let v of voices) v.setGain(value, options)
|
|
119
161
|
},
|
|
120
|
-
setPan(value) {
|
|
162
|
+
setPan(value, options) {
|
|
121
163
|
pan = value
|
|
122
164
|
prune()
|
|
123
|
-
for (let v of voices) v.setPan(value)
|
|
165
|
+
for (let v of voices) v.setPan(value, options)
|
|
166
|
+
},
|
|
167
|
+
setRate(value, options) {
|
|
168
|
+
rate = value
|
|
169
|
+
prune()
|
|
170
|
+
for (let v of voices) v.setRate(value, options)
|
|
124
171
|
},
|
|
125
172
|
playing,
|
|
126
173
|
error,
|
|
@@ -137,6 +184,33 @@ export function createSound(source: Uint8Array, options: SoundOptions = {}): Sou
|
|
|
137
184
|
loop: options.loop,
|
|
138
185
|
gain: options.gain,
|
|
139
186
|
pan: options.pan,
|
|
187
|
+
rate: options.rate,
|
|
188
|
+
fadeInMs: options.fadeInMs,
|
|
189
|
+
bus: options.bus,
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A sound over raw PCM samples the app generated itself - no decoding, no
|
|
195
|
+
* container. The typed array is the sample format (Uint8Array = unsigned
|
|
196
|
+
* 8-bit, Int16Array = signed 16-bit, Float32Array = 32-bit float), interleaved
|
|
197
|
+
* when `channels` is 2. Same handle and lifecycle as createSound; on a box with
|
|
198
|
+
* no audio device the clip fails to load, so `error()` is set and play() is a
|
|
199
|
+
* no-op, exactly like createSound there. For imperative use, call loadPcm()
|
|
200
|
+
* from "flux:audio".
|
|
201
|
+
*/
|
|
202
|
+
export function createPcmSound(
|
|
203
|
+
samples: Uint8Array | Int16Array | Float32Array,
|
|
204
|
+
sampleRate: number,
|
|
205
|
+
options: PcmSoundOptions = {},
|
|
206
|
+
): Sound {
|
|
207
|
+
return reactiveSound(() => loadPcm(samples, sampleRate, { channels: options.channels }), options.overlap ?? true, {
|
|
208
|
+
loop: options.loop,
|
|
209
|
+
gain: options.gain,
|
|
210
|
+
pan: options.pan,
|
|
211
|
+
rate: options.rate,
|
|
212
|
+
fadeInMs: options.fadeInMs,
|
|
213
|
+
bus: options.bus,
|
|
140
214
|
})
|
|
141
215
|
}
|
|
142
216
|
|
|
@@ -150,5 +224,12 @@ export function createSound(source: Uint8Array, options: SoundOptions = {}): Sou
|
|
|
150
224
|
*/
|
|
151
225
|
export function createSoundStream(source: string | FluxFile, options: SoundStreamOptions = {}): Sound {
|
|
152
226
|
let src = typeof source === "string" ? file(source) : source
|
|
153
|
-
return reactiveSound(() => stream(src), false, {
|
|
227
|
+
return reactiveSound(() => stream(src), false, {
|
|
228
|
+
loop: options.loop,
|
|
229
|
+
gain: options.gain,
|
|
230
|
+
pan: options.pan,
|
|
231
|
+
rate: options.rate,
|
|
232
|
+
fadeInMs: options.fadeInMs,
|
|
233
|
+
bus: options.bus,
|
|
234
|
+
})
|
|
154
235
|
}
|
package/src/color.ts
CHANGED
|
@@ -1,30 +1,29 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import * as tree from "flux:rendertree"
|
|
2
|
+
|
|
3
|
+
// Color parsing and mixing live in the runtime (alloy's color module), one
|
|
4
|
+
// owner for the CSS grammar and the perceptual math; these re-exports keep
|
|
5
|
+
// the core API surface. The renderer no longer parses at all: color strings
|
|
6
|
+
// cross the FFI raw and the runtime decodes them.
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Parses a CSS color string (named, hex, `rgb()`, `hsl()`, ...) into a packed
|
|
8
|
-
* `0xRRGGBBAA` u32: red in the high byte, alpha in the low byte.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* `0xRRGGBBAA` u32: red in the high byte, alpha in the low byte. This is the
|
|
11
|
+
* wire format the runtime expects for the `color` property. Throws on a
|
|
12
|
+
* string that is not a valid CSS color, so a typo fails on the line that
|
|
13
|
+
* wrote it instead of silently painting black.
|
|
12
14
|
*/
|
|
13
15
|
export function parseColor(color: string): number {
|
|
14
|
-
|
|
15
|
-
if (!c.isValid()) throw new Error(`Invalid color "${color}"`)
|
|
16
|
-
let { r, g, b, a } = c.toRgb()
|
|
17
|
-
return (((r & 0xFF) << 24) | ((g & 0xFF) << 16) | ((b & 0xFF) << 8) | ((a * 255) & 0xFF)) >>> 0
|
|
16
|
+
return tree.parseColor(color)
|
|
18
17
|
}
|
|
19
18
|
|
|
20
19
|
/**
|
|
21
|
-
* Mixes two CSS colors in
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
20
|
+
* Mixes two CSS colors in oklab; `t` is the fraction of `b` (0 = pure `a`,
|
|
21
|
+
* 1 = pure `b`). Returns a hex string. Use it to derive semantic tones
|
|
22
|
+
* (muted text, subtle borders) instead of alpha overlays, so the resulting
|
|
23
|
+
* color does not depend on what is drawn beneath it.
|
|
25
24
|
*/
|
|
26
25
|
export function mixColors(a: string, b: string, t: number): string {
|
|
27
|
-
return
|
|
26
|
+
return tree.mixColors(a, b, t)
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
/**
|
|
@@ -33,7 +32,7 @@ export function mixColors(a: string, b: string, t: number): string {
|
|
|
33
32
|
* e.g. whether a label sits light-on-dark (see typeWeight in components).
|
|
34
33
|
*/
|
|
35
34
|
export function brightness(color: string): number {
|
|
36
|
-
return
|
|
35
|
+
return tree.brightness(color)
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
// A color stop: `offset` is 0..1 along the gradient, `color` any CSS color string.
|
package/src/core.ts
CHANGED
|
@@ -331,4 +331,92 @@ export function getBoundingBoxViewport(node: { id: number }): BoundingBox | null
|
|
|
331
331
|
*/
|
|
332
332
|
export function measureText(text: string, options?: tree.MeasureTextOptions): { width: number, height: number } {
|
|
333
333
|
return tree.measureText(text, options)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Segments `text` into wrap units (words with their trailing whitespace) and
|
|
338
|
+
* shapes each in the given font, once, for laying lines out in app code with
|
|
339
|
+
* layoutNextLine or arithmetic of your own over `units`. For the non-standard
|
|
340
|
+
* case (text into a shape, around a moving obstacle, handed between columns,
|
|
341
|
+
* fitted by size); regular text of any length is a <text>.
|
|
342
|
+
*/
|
|
343
|
+
export function prepareText(text: string, options?: tree.MeasureTextOptions): tree.PreparedText {
|
|
344
|
+
return tree.prepareText(text, options)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** One laid-out line from layoutNextLine. */
|
|
348
|
+
export type TextLine = {
|
|
349
|
+
/** Unit range [from, to) into prepared.units. */
|
|
350
|
+
from: number
|
|
351
|
+
to: number
|
|
352
|
+
/** Character range into prepared.text: `text.slice(start, end)` is the line's text (break characters included). */
|
|
353
|
+
start: number
|
|
354
|
+
end: number
|
|
355
|
+
/** Ink width: the units' advances plus the last unit's width, without its trailing whitespace. */
|
|
356
|
+
width: number
|
|
357
|
+
/** Tallest ascent plus tallest descent on the line. */
|
|
358
|
+
height: number
|
|
359
|
+
ascent: number
|
|
360
|
+
/** The line ended at a hard break rather than by running out of width. */
|
|
361
|
+
hardBreak: boolean
|
|
362
|
+
/** Where the next line starts; equal to `to`. */
|
|
363
|
+
cursor: number
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Ink width of the wrap unit starting at unit `index`: its advance through
|
|
368
|
+
* every glued piece that follows, plus the last piece's ink. What must fit on
|
|
369
|
+
* the line for the unit to go on it.
|
|
370
|
+
*/
|
|
371
|
+
export function unitInk(units: tree.TextUnit[], index: number): number {
|
|
372
|
+
let ink = units[index]!.width
|
|
373
|
+
let advance = 0
|
|
374
|
+
for (let j = index + 1; j < units.length && units[j]!.glue; j++) {
|
|
375
|
+
advance += units[j - 1]!.advance
|
|
376
|
+
ink = advance + units[j]!.width
|
|
377
|
+
}
|
|
378
|
+
return ink
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The next line of `prepared` from unit `cursor` that fits `width`, or null
|
|
383
|
+
* when the text is used up. Greedy: units go on the line while the pen plus
|
|
384
|
+
* the unit's ink stays within `width`; a hard break ends the line; a unit
|
|
385
|
+
* wider than `width` on its own goes on the line whole and overflows. Draw a
|
|
386
|
+
* line as `<d-text x y w={line.width + 1}>{prepared.text.slice(line.start, line.end)}</d-text>`
|
|
387
|
+
* with the same font options; its words are already shaped, so that is
|
|
388
|
+
* cheap. With `runs`, a unit crossing a run boundary is several glued units
|
|
389
|
+
* that always land on one line; draw such a line with a <span> per run.
|
|
390
|
+
* Floats, balancing and ellipsis are <text> features, not this.
|
|
391
|
+
*/
|
|
392
|
+
export function layoutNextLine(prepared: tree.PreparedText, cursor: number, width: number): TextLine | null {
|
|
393
|
+
let units = prepared.units
|
|
394
|
+
if (cursor >= units.length) return null
|
|
395
|
+
let pen = 0
|
|
396
|
+
let ascent = 0
|
|
397
|
+
let descent = 0
|
|
398
|
+
let i = cursor
|
|
399
|
+
while (i < units.length) {
|
|
400
|
+
let unit = units[i]!
|
|
401
|
+
// Glued pieces stay with the unit they continue: the whole wrap unit
|
|
402
|
+
// (this piece through the last glued one) must fit for any of it to go on.
|
|
403
|
+
if (i > cursor && !unit.glue && pen + unitInk(units, i) > width) break
|
|
404
|
+
pen += unit.advance
|
|
405
|
+
if (unit.ascent > ascent) ascent = unit.ascent
|
|
406
|
+
if (unit.descent > descent) descent = unit.descent
|
|
407
|
+
i++
|
|
408
|
+
if (unit.hardBreak) break
|
|
409
|
+
}
|
|
410
|
+
let last = units[i - 1]!
|
|
411
|
+
return {
|
|
412
|
+
from: cursor,
|
|
413
|
+
to: i,
|
|
414
|
+
start: units[cursor]!.start,
|
|
415
|
+
end: last.end,
|
|
416
|
+
width: pen - last.advance + last.width,
|
|
417
|
+
height: ascent + descent,
|
|
418
|
+
ascent,
|
|
419
|
+
hardBreak: last.hardBreak,
|
|
420
|
+
cursor: i,
|
|
421
|
+
}
|
|
334
422
|
}
|
package/src/data.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Reactive SQLite. A query is a reactive value: it re-runs when any table it
|
|
2
|
+
// reads is written on this connection, and every consumer updates through
|
|
3
|
+
// normal Solid reactivity. SQLite itself reports both sides of the dependency
|
|
4
|
+
// graph - the statement's read-set comes from `stmt.tables()` (SQLite's
|
|
5
|
+
// authorizer) and writes come from `db.onWrite` (SQLite's update hook, so
|
|
6
|
+
// trigger and cascade writes are included). No SQL parsing, no manual
|
|
7
|
+
// declarations, no wrapper: queries take the plain `flux:sqlite` Database.
|
|
8
|
+
//
|
|
9
|
+
// The contract, in one sentence: a query re-runs when any table it reads is
|
|
10
|
+
// written on this connection. Granularity is per table; writes from another
|
|
11
|
+
// connection or process are not seen; WITHOUT ROWID tables do not report.
|
|
12
|
+
//
|
|
13
|
+
// The imperative primitives live in the `flux:sqlite` module and are
|
|
14
|
+
// framework-neutral. This module is only the thin Solid binding on top (a
|
|
15
|
+
// version signal per table, an async memo per query).
|
|
16
|
+
|
|
17
|
+
import { createMemo, createSignal } from "@solidjs/signals"
|
|
18
|
+
import type { Signal, SourceAccessor } from "@solidjs/signals"
|
|
19
|
+
import type { Database, Row, SqlParam, Statement } from "flux:sqlite"
|
|
20
|
+
|
|
21
|
+
export { Database } from "flux:sqlite"
|
|
22
|
+
export type { OpenMode, Row, RunResult, SqlParam, SqlValue, Statement } from "flux:sqlite"
|
|
23
|
+
|
|
24
|
+
/** Bind parameters: a plain array, or an accessor for reactive params. */
|
|
25
|
+
export type Params = SqlParam[] | (() => SqlParam[])
|
|
26
|
+
|
|
27
|
+
// Per-database dependency tracking, created lazily on the first createQuery:
|
|
28
|
+
// one version signal per table, bumped from the connection's write events.
|
|
29
|
+
// The function reads (and lazily creates) the version signal of one table.
|
|
30
|
+
// Keyed by the plain Database so there is no wrapper type to hand around.
|
|
31
|
+
let trackers = new WeakMap<Database, (table: string) => void>()
|
|
32
|
+
|
|
33
|
+
function trackerFor(db: Database): (table: string) => void {
|
|
34
|
+
let track = trackers.get(db)
|
|
35
|
+
if (track) return track
|
|
36
|
+
|
|
37
|
+
let versions = new Map<string, Signal<number>>()
|
|
38
|
+
// A write bumps only existing signals: a signal that was never read has no
|
|
39
|
+
// subscribers, so there is nothing to invalidate. The subscription lives
|
|
40
|
+
// for the connection's life; close() ends it.
|
|
41
|
+
db.onWrite((tables) => {
|
|
42
|
+
for (let table of tables) {
|
|
43
|
+
let version = versions.get(table)
|
|
44
|
+
if (version) version[1]((v) => v + 1)
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
track = (table) => {
|
|
48
|
+
let version = versions.get(table)
|
|
49
|
+
if (!version) {
|
|
50
|
+
version = createSignal(0)
|
|
51
|
+
versions.set(table, version)
|
|
52
|
+
}
|
|
53
|
+
version[0]()
|
|
54
|
+
}
|
|
55
|
+
trackers.set(db, track)
|
|
56
|
+
return track
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A reactive query: an accessor over the matching rows that re-runs when any
|
|
61
|
+
* table the statement reads is written on this connection. Reads surface as
|
|
62
|
+
* pending until the first result lands - wrap in `<Loading>` or default with
|
|
63
|
+
* `?? []`. Pass params as an accessor to also re-run when they change.
|
|
64
|
+
*/
|
|
65
|
+
export function createQuery(db: Database, sql: string, params?: Params): SourceAccessor<Row[]> {
|
|
66
|
+
return statementQuery(db, sql, params, (stmt, p) => stmt.all(p))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Like {@link createQuery}, but for single-row reads: resolves to the first
|
|
71
|
+
* matching row, or `undefined` when there is none.
|
|
72
|
+
*/
|
|
73
|
+
export function createQueryRow(
|
|
74
|
+
db: Database,
|
|
75
|
+
sql: string,
|
|
76
|
+
params?: Params,
|
|
77
|
+
): SourceAccessor<Row | undefined> {
|
|
78
|
+
return statementQuery(db, sql, params, (stmt, p) => stmt.get(p))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function statementQuery<T>(
|
|
82
|
+
db: Database,
|
|
83
|
+
sql: string,
|
|
84
|
+
params: Params | undefined,
|
|
85
|
+
run: (stmt: Statement, params?: SqlParam[]) => Promise<T>,
|
|
86
|
+
): SourceAccessor<T> {
|
|
87
|
+
let track = trackerFor(db)
|
|
88
|
+
let stmt = db.query(sql)
|
|
89
|
+
// The read-set arrives async (one authorizer round-trip on the connection
|
|
90
|
+
// thread); computed once, it never changes for a given statement. Reads of
|
|
91
|
+
// the query below surface as pending until it lands, then the query runs -
|
|
92
|
+
// so the first execution already subscribes to its tables and no write can
|
|
93
|
+
// slip between subscription and execution.
|
|
94
|
+
let tables = createMemo(() => stmt.tables())
|
|
95
|
+
return createMemo(() => {
|
|
96
|
+
for (let table of tables()) track(table)
|
|
97
|
+
return run(stmt, typeof params === "function" ? params() : params)
|
|
98
|
+
})
|
|
99
|
+
}
|
package/src/gpu.ts
CHANGED
|
@@ -21,8 +21,10 @@
|
|
|
21
21
|
// `<texture>` elements and set their `blendMode` (e.g. `blendMode="plus"` for
|
|
22
22
|
// an additive pass over a base pass) instead of writing a pass that samples
|
|
23
23
|
// both. WITHIN one pipeline draw, `blend: "add"` accumulates overlapping
|
|
24
|
-
// geometry additively
|
|
25
|
-
//
|
|
24
|
+
// geometry additively and `blend: "multiply"` scales it (both
|
|
25
|
+
// order-independent, no sorting); `blend: "alpha"` composites over in
|
|
26
|
+
// draw-list order (order-dependent: the app or a scene layer sorts); anything
|
|
27
|
+
// else draws with GL blending disabled and overwrites.
|
|
26
28
|
//
|
|
27
29
|
// The pixel contract. Three facts hold for every texture and target:
|
|
28
30
|
//
|
|
@@ -40,8 +42,8 @@
|
|
|
40
42
|
// default transparent black needs no thought.
|
|
41
43
|
// - Values are non-linear RGBA8, with no color-space concept. Every texture
|
|
42
44
|
// and target holds 8-bit RGBA UNORM exactly as written; nothing converts to
|
|
43
|
-
// or from linear light. `filter: "linear"` averages and `blend
|
|
44
|
-
//
|
|
45
|
+
// or from linear light. `filter: "linear"` averages and the `blend` modes
|
|
46
|
+
// accumulate non-linear values - the usual approximation, stated so
|
|
45
47
|
// shaders written today stay correct if a format vocabulary arrives.
|
|
46
48
|
|
|
47
49
|
import { createEffect, createSignal, getOwner, onCleanup, untrack } from "@solidjs/signals"
|
|
@@ -98,6 +100,7 @@ export type { BufferId, DrawId, ProgramId, RenderPipelineId, ShaderStageId, Text
|
|
|
98
100
|
// registered at creation keeps working and no re-registration is needed.
|
|
99
101
|
export {
|
|
100
102
|
destroyTexture,
|
|
103
|
+
endBufferWrite,
|
|
101
104
|
resizeTexture,
|
|
102
105
|
setTargetParams,
|
|
103
106
|
setTargetSize,
|
|
@@ -512,10 +515,12 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
|
|
|
512
515
|
* `opts.depth` attaches a private depth buffer (cleared + tested per render);
|
|
513
516
|
* `opts.depthWrite: false` (requires depth) keeps the test but stops the
|
|
514
517
|
* draw from writing depth. `opts.blend: "add"` makes the draw accumulate
|
|
515
|
-
* overlapping geometry additively
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
* the
|
|
518
|
+
* overlapping geometry additively and `"multiply"` makes it scale (darken)
|
|
519
|
+
* what is already there, both order-independent (no sorting) instead of
|
|
520
|
+
* overwriting; `"alpha"` composites over in draw-list order (premultiplied
|
|
521
|
+
* output, back-to-front is the caller's job). A depth-tested blended pass is
|
|
522
|
+
* `{ depth: true, blend: "add", depthWrite: false }` - each option only does
|
|
523
|
+
* what it says, neither implies the other. The draw range (`firstVertex`, `vertexCount`, `instanceCount` -
|
|
519
524
|
* see DrawRange) defaults to the whole buffer drawn once and can be changed
|
|
520
525
|
* later with `setDraw`; `instanceCount` is the standard answer to particles
|
|
521
526
|
* and repeated meshes, N copies of the range told apart by `gl_InstanceID`
|
|
@@ -571,12 +576,29 @@ export function createPipelineTexture(
|
|
|
571
576
|
* created outside a reactive scope you must call `destroyBuffer` yourself.
|
|
572
577
|
* (Destruction order relative to pipelines does not matter.)
|
|
573
578
|
*/
|
|
574
|
-
export function createBuffer(data: ArrayBuffer | ArrayBufferView, opts?: CreateOptions): gpu.BufferId {
|
|
575
|
-
let id = gpu.createBuffer(toUint8(data), opts)
|
|
579
|
+
export function createBuffer(data: ArrayBuffer | ArrayBufferView | number, opts?: CreateOptions): gpu.BufferId {
|
|
580
|
+
let id = gpu.createBuffer(typeof data === "number" ? data : toUint8(data), opts)
|
|
576
581
|
if (opts?.autoFree !== false && getOwner()) onCleanup(() => gpu.destroyBuffer(id))
|
|
577
582
|
return id
|
|
578
583
|
}
|
|
579
584
|
|
|
585
|
+
/**
|
|
586
|
+
* Opens a zero-copy write into a vertex buffer: returns a Float32Array over
|
|
587
|
+
* runtime-owned memory spanning the whole buffer. Write records in place,
|
|
588
|
+
* then publish with {@link endBufferWrite} - the bytes move to the GPU with
|
|
589
|
+
* no copy on the CPU path, which is the per-frame streaming path (instanced
|
|
590
|
+
* sprites, dynamic geometry). Reach other element types through `.buffer`.
|
|
591
|
+
*
|
|
592
|
+
* Contents are UNSPECIFIED at begin (a recycled block holds what was
|
|
593
|
+
* published the time before last): fill everything you publish. One open
|
|
594
|
+
* write per buffer at a time. The view is detached at end/destroy - retained
|
|
595
|
+
* references become zero-length, so a stale write is inert, never a race.
|
|
596
|
+
*/
|
|
597
|
+
export function beginBufferWrite(id: gpu.BufferId): Float32Array {
|
|
598
|
+
let ab = gpu.beginBufferWrite(id)
|
|
599
|
+
return new Float32Array(ab, 0, (ab.byteLength / 4) | 0)
|
|
600
|
+
}
|
|
601
|
+
|
|
580
602
|
/**
|
|
581
603
|
* Overwrites part of a vertex buffer at `byteOffset` (default 0). Every
|
|
582
604
|
* pipeline drawing from the buffer re-renders with its last-applied params,
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
export * from "./renderer"
|
|
2
|
-
export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, getBoundingBox, getBoundingBoxViewport, onPointerMove } from "./core"
|
|
3
|
-
export type { BoundingBox, GlobalPointerEvent } from "./core"
|
|
2
|
+
export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, prepareText, layoutNextLine, unitInk, getBoundingBox, getBoundingBoxViewport, onPointerMove } from "./core"
|
|
3
|
+
export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
|
|
4
4
|
export { parseColor, mixColors, brightness, createLinearGradient, createRadialGradient } from "./color"
|
|
5
5
|
export type { Gradient, GradientStop } from "./color"
|
|
6
6
|
export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, exit } from "./window"
|
|
7
7
|
export type { BackEvent } from "./window"
|
|
8
|
-
export { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } from "./window"
|
|
8
|
+
export { windowSize, safeArea, displayScale, windowFocused, keyboardHeight, lockPointer, pointerLocked } from "./window"
|
|
9
9
|
export { env } from "./environment"
|
|
10
10
|
export type { InputDevices, SystemTheme, Orientation, Visibility } from "./environment"
|
|
11
11
|
export { gamepads } from "./gamepad"
|
|
@@ -49,7 +49,7 @@ export type {
|
|
|
49
49
|
Color,
|
|
50
50
|
Pct,
|
|
51
51
|
} from "./types"
|
|
52
|
-
export type { MeasureTextOptions } from "flux:rendertree"
|
|
52
|
+
export type { MeasureTextOptions, PreparedText, TextUnit } from "flux:rendertree"
|
|
53
53
|
|
|
54
54
|
// A percentage value for dimensional props (e.g. transformOrigin): `pct(50)` is
|
|
55
55
|
// half the element box. Keeps percentages a first-class branded value rather
|
package/src/renderer.ts
CHANGED
|
@@ -4,7 +4,6 @@ import type { Element } from "solid-js"
|
|
|
4
4
|
import * as tree from "flux:rendertree"
|
|
5
5
|
import { attachWindow } from "./window"
|
|
6
6
|
import { setEventHandler, setFocusable, setTextInputHints, cleanupNode, focusedNode, setFocus } from "./core"
|
|
7
|
-
import { parseColor, isGradient } from "./color"
|
|
8
7
|
|
|
9
8
|
export { getEventHandler } from "./core"
|
|
10
9
|
|
|
@@ -166,34 +165,49 @@ function setTreeProperty(node: ProxyNode, name: string, value: unknown): void {
|
|
|
166
165
|
// Shared by the renderer's setProperty hook and by createElement, which since
|
|
167
166
|
// the dom-expressions "universal" template passes static props inline as a
|
|
168
167
|
// second argument rather than as separate setProp calls.
|
|
168
|
+
// A property's route is a function of its name alone, so the classifying
|
|
169
|
+
// regex and compares run once per unique name; per write it is one Map get.
|
|
170
|
+
const ROUTE_TREE = 0
|
|
171
|
+
const ROUTE_EVENT = 1
|
|
172
|
+
const ROUTE_FOCUSABLE = 2
|
|
173
|
+
const ROUTE_HINTS = 3
|
|
174
|
+
let propRoutes = new Map<string, number>()
|
|
175
|
+
|
|
176
|
+
function routeFor(name: string): number {
|
|
177
|
+
let route = propRoutes.get(name)
|
|
178
|
+
if (route === undefined) {
|
|
179
|
+
route = /^on[A-Z]/.test(name)
|
|
180
|
+
? ROUTE_EVENT
|
|
181
|
+
: name === "focusable"
|
|
182
|
+
? ROUTE_FOCUSABLE
|
|
183
|
+
: name === "textInputHints"
|
|
184
|
+
? ROUTE_HINTS
|
|
185
|
+
: ROUTE_TREE
|
|
186
|
+
propRoutes.set(name, route)
|
|
187
|
+
}
|
|
188
|
+
return route
|
|
189
|
+
}
|
|
190
|
+
|
|
169
191
|
function applyProp<T>(node: ProxyNode, name: string, value: T): void {
|
|
170
192
|
if (!node) return
|
|
171
193
|
|
|
172
194
|
// console.debug("[srt] applyProp", node.id, name, value)
|
|
173
195
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (name === "color" && isGradient(value)) {
|
|
190
|
-
setTreeProperty(node, name, value)
|
|
191
|
-
return
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (name === "color" && typeof value === "string") {
|
|
195
|
-
setTreeProperty(node, name, parseColor(value))
|
|
196
|
-
return
|
|
196
|
+
switch (routeFor(name)) {
|
|
197
|
+
case ROUTE_EVENT:
|
|
198
|
+
// A non-function, non-null value on an on* name is not a handler;
|
|
199
|
+
// fall through to the tree so the native side rejects it.
|
|
200
|
+
if (value == null || typeof value === "function") {
|
|
201
|
+
setEventHandler(node.id, name, value as Function | null | undefined)
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
break
|
|
205
|
+
case ROUTE_FOCUSABLE:
|
|
206
|
+
setFocusable(node.id, value === true)
|
|
207
|
+
return
|
|
208
|
+
case ROUTE_HINTS:
|
|
209
|
+
setTextInputHints(node.id, value as any)
|
|
210
|
+
return
|
|
197
211
|
}
|
|
198
212
|
|
|
199
213
|
setTreeProperty(node, name, value)
|
|
@@ -233,10 +247,12 @@ export let {
|
|
|
233
247
|
return proxy
|
|
234
248
|
},
|
|
235
249
|
|
|
250
|
+
// A string child of <text> or <span>: a run of its parent's content, with
|
|
251
|
+
// no element form of its own (the DOM's "#text" node name).
|
|
236
252
|
createTextNode: (value: string): ProxyNode => {
|
|
237
|
-
let proxy = createProxyNode("
|
|
253
|
+
let proxy = createProxyNode("#text")
|
|
238
254
|
// console.debug("[srt] createTextNode", proxy.id, value)
|
|
239
|
-
tree.createNode(proxy.id, "
|
|
255
|
+
tree.createNode(proxy.id, "#text")
|
|
240
256
|
tree.setProperty(proxy.id, "text", "" + value)
|
|
241
257
|
return proxy
|
|
242
258
|
},
|
|
@@ -246,7 +262,7 @@ export let {
|
|
|
246
262
|
tree.setProperty(node.id, "text", "" + value)
|
|
247
263
|
},
|
|
248
264
|
|
|
249
|
-
isTextNode: (node: ProxyNode): boolean => node?.elementType === "
|
|
265
|
+
isTextNode: (node: ProxyNode): boolean => node?.elementType === "#text",
|
|
250
266
|
setProperty: <T>(node: ProxyNode, name: string, value: T): void => {
|
|
251
267
|
// console.debug("[srt] setProperty", node.id, name, value)
|
|
252
268
|
applyProp(node, name, value)
|