@solidrt/core 0.0.48 → 0.0.50
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 +14 -3
- package/examples/README.md +1 -1
- package/examples/sound.tsx +23 -3
- package/jsx-runtime.d.ts +4 -0
- package/package.json +5 -5
- package/src/core.ts +69 -0
- package/src/gpu.ts +12 -8
- package/src/index.ts +3 -3
- package/src/renderer.ts +5 -3
- package/src/sound.ts +31 -3
- package/src/text-input.ts +18 -4
- package/src/types.d.ts +62 -2
- package/src/video.ts +100 -0
package/AGENTS.md
CHANGED
|
@@ -94,7 +94,7 @@ tsconfig.json - the two load-bearing lines are jsx + jsxImportSource:
|
|
|
94
94
|
```
|
|
95
95
|
|
|
96
96
|
Peer deps @solidjs/signals and @solidjs/universal must match (currently
|
|
97
|
-
2.0.0-
|
|
97
|
+
2.0.0-rc.0); bun resolves them from peerDependencies.
|
|
98
98
|
|
|
99
99
|
## Element model (the parts that are easy to get wrong)
|
|
100
100
|
|
|
@@ -159,6 +159,17 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
|
|
|
159
159
|
expression container preserves it - `<d-text>{"one two"}</d-text>` - and
|
|
160
160
|
`\n` inside one produces a hard line break.
|
|
161
161
|
|
|
162
|
+
- Rich text: `<span>` inside `<text>` restyles a run (`color`, `fontFamily`,
|
|
163
|
+
`fontSize`, `fontWeight`, `fontStyle`, `lineHeight`); spans nest and
|
|
164
|
+
inherit inward from the `<text>`. Never lay a paragraph out word by word in
|
|
165
|
+
a wrapping row to mix styles - one `<text>` with spans wraps as a whole.
|
|
166
|
+
A span is content, not a box (no layout or `d-` form, no size, no
|
|
167
|
+
bounding box; it takes its parent's form). A `<span>` takes
|
|
168
|
+
pointer handlers (a link is a span, hit per line it spans), and any other
|
|
169
|
+
element child of `<text>` (`<view>`, `<texture>`, `<path>`, ...) is an
|
|
170
|
+
inline atom flowing with the words as one unbreakable box on the baseline;
|
|
171
|
+
give it margins for spacing, since JSX trims the whitespace around it.
|
|
172
|
+
|
|
162
173
|
- Events: there is NO `onClick`/`onPress`. A "button" is a `<view>`/`<rect>`
|
|
163
174
|
with `onPointerDown`. Handlers: onPointerDown/Up/Move/Enter/Leave, onWheel,
|
|
164
175
|
onKeyDown/Up, onTextInput, onFocus/onBlur. Text entry: focus a node with an
|
|
@@ -200,8 +211,8 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
|
|
|
200
211
|
Also onResize, onLayout, onWindowFocus, onWindowBlur.
|
|
201
212
|
|
|
202
213
|
- Device/GPU access via subpath imports: @solidrt/core/camera, /microphone,
|
|
203
|
-
/speech, /gpu. Image flow: `decodeImage(bytes)` ->
|
|
204
|
-
-> `<texture src={id} />`.
|
|
214
|
+
/speech, /gpu. Image flow: `decodeImage(bytes)` ->
|
|
215
|
+
`createTexture(data,w,h)` -> `<texture src={id} />`.
|
|
205
216
|
|
|
206
217
|
## Minimal app, core primitives only (verified to render)
|
|
207
218
|
|
package/examples/README.md
CHANGED
|
@@ -48,7 +48,7 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
|
|
|
48
48
|
- `window-shader-history.tsx` - the window shader's frame history: `previous` binds last frame as uPrevious, drawn as a one-frame motion echo behind an orbiting square; click toggles the echo term.
|
|
49
49
|
|
|
50
50
|
## Sound
|
|
51
|
-
- `sound.tsx` - `createSound`: decode a clip once from bytes (here a binary import), replay cheaply; `overlap` stacking vs single-voice, `playing()` signal, release on unmount. Points to `createSoundStream` for long tracks streamed from a path.
|
|
51
|
+
- `sound.tsx` - `createSound`: decode a clip once from bytes (here a binary import), replay cheaply; `overlap` stacking vs single-voice, `playing()` signal, release on unmount. `createPcmSound` for a synthesised clip from raw samples (a generated sine sweep). Points to `createSoundStream` for long tracks streamed from a path.
|
|
52
52
|
|
|
53
53
|
## Vector graphics
|
|
54
54
|
- `parse-svg.tsx` - `parseSvg` turns a whole SVG *document string* (not HTML/JSX children) into plain draw data mapped to `<d-path>` inside a `viewBox`-fitted view; per-shape hover highlighting shows the payoff (exact-outline hit testing, recolor without re-parse), plus a `currentColor` icon recolored via the `color` option. This is how to use existing icon libraries (Lucide, Heroicons, etc.) - hand their SVG source to `parseSvg`.
|
package/examples/sound.tsx
CHANGED
|
@@ -14,14 +14,32 @@
|
|
|
14
14
|
// selection tick. playing() is a signal: true from play() until stop() (it
|
|
15
15
|
// does not flip back when a clip ends naturally).
|
|
16
16
|
//
|
|
17
|
+
// Sounds you can compute need no bytes at all: createPcmSound takes raw
|
|
18
|
+
// samples (here a 60 ms sine sweep in an Int16Array) and gives the same handle
|
|
19
|
+
// - generate it, do not ship it. Same reactive ownership as createSound.
|
|
20
|
+
//
|
|
17
21
|
// For a long track (music, ambience) do not load bytes at all: pass a file
|
|
18
22
|
// path to createSoundStream from the same module, which decodes from disk on
|
|
19
23
|
// demand and stays off the heap. Same play()/stop()/playing() surface,
|
|
20
24
|
// always single-voice.
|
|
21
25
|
import { render } from "@solidrt/core"
|
|
22
|
-
import { createSound } from "@solidrt/core/sound"
|
|
26
|
+
import { createPcmSound, createSound } from "@solidrt/core/sound"
|
|
23
27
|
import blipBytes from "./blip.wav" with { type: "binary" }
|
|
24
28
|
|
|
29
|
+
const RATE = 44100
|
|
30
|
+
// A short sweep from 880 Hz down to 440 Hz with a linear fade-out.
|
|
31
|
+
function sweep(): Int16Array {
|
|
32
|
+
let n = Math.round(RATE * 0.06)
|
|
33
|
+
let out = new Int16Array(n)
|
|
34
|
+
let phase = 0
|
|
35
|
+
for (let i = 0; i < n; i++) {
|
|
36
|
+
let t = i / n
|
|
37
|
+
phase += (2 * Math.PI * (880 - 440 * t)) / RATE
|
|
38
|
+
out[i] = Math.round(Math.sin(phase) * (1 - t) * 0.6 * 32767)
|
|
39
|
+
}
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
25
43
|
function Button(props: { label: string; onTap: () => void }) {
|
|
26
44
|
return (
|
|
27
45
|
<view onPointerDown={props.onTap} padding={12} clipRadius={8}>
|
|
@@ -34,13 +52,15 @@ function Button(props: { label: string; onTap: () => void }) {
|
|
|
34
52
|
function App() {
|
|
35
53
|
let blip = createSound(blipBytes, { gain: 0.8 })
|
|
36
54
|
let tick = createSound(blipBytes, { overlap: false })
|
|
55
|
+
let synth = createPcmSound(sweep(), RATE)
|
|
37
56
|
|
|
38
57
|
return (
|
|
39
58
|
<window padding={20} gap={8} alignItems="flex-start">
|
|
40
59
|
<Button label="Blip (tap fast to stack voices)" onTap={() => blip.play()} />
|
|
41
60
|
<Button label="Tick (overlap: false, restarts)" onTap={() => tick.play()} />
|
|
42
|
-
<Button label="
|
|
43
|
-
<
|
|
61
|
+
<Button label="Sweep (createPcmSound, generated)" onTap={() => synth.play()} />
|
|
62
|
+
<Button label="Stop" onTap={() => { blip.stop(); tick.stop(); synth.stop() }} />
|
|
63
|
+
<text color="#888">{blip.playing() || tick.playing() || synth.playing() ? "playing" : "silent"}</text>
|
|
44
64
|
</window>
|
|
45
65
|
)
|
|
46
66
|
}
|
package/jsx-runtime.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
ViewProps,
|
|
8
8
|
ViewOwnProps,
|
|
9
9
|
TextProps,
|
|
10
|
+
SpanProps,
|
|
10
11
|
TextureProps,
|
|
11
12
|
LayoutProps,
|
|
12
13
|
PositionProps,
|
|
@@ -54,5 +55,8 @@ export namespace JSX {
|
|
|
54
55
|
"d-path": PathProps & PositionProps & ElementRef
|
|
55
56
|
"d-texture": TextureProps & GeometryProps & ElementRef
|
|
56
57
|
"d-text": TextProps & TextGeometryProps & ElementRef
|
|
58
|
+
// A styled run inside <text>/<d-text>; never has a layout box, so there is
|
|
59
|
+
// no d- form.
|
|
60
|
+
span: SpanProps & ElementRef
|
|
57
61
|
}
|
|
58
62
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.50",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Antoine van Wel",
|
|
6
6
|
"type": "module",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"colord": "^2.9.3"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@solidrt/flux-types": "0.0.
|
|
30
|
+
"@solidrt/flux-types": "0.0.50"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
|
-
"@solidjs/signals": "2.0.0-
|
|
34
|
-
"@solidjs/universal": "2.0.0-
|
|
35
|
-
"solid-js": "2.0.0-
|
|
33
|
+
"@solidjs/signals": "2.0.0-rc.0",
|
|
34
|
+
"@solidjs/universal": "2.0.0-rc.0",
|
|
35
|
+
"solid-js": "2.0.0-rc.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/core.ts
CHANGED
|
@@ -331,4 +331,73 @@ 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
|
+
* The next line of `prepared` from unit `cursor` that fits `width`, or null
|
|
368
|
+
* when the text is used up. Greedy: units go on the line while the pen plus
|
|
369
|
+
* the unit's ink stays within `width`; a hard break ends the line; a unit
|
|
370
|
+
* wider than `width` on its own goes on the line whole and overflows. Draw a
|
|
371
|
+
* line as `<d-text x y w={line.width + 1}>{prepared.text.slice(line.start, line.end)}</d-text>`
|
|
372
|
+
* with the same font options; its words are already shaped, so that is
|
|
373
|
+
* cheap. Floats, balancing and ellipsis are <text> features, not this.
|
|
374
|
+
*/
|
|
375
|
+
export function layoutNextLine(prepared: tree.PreparedText, cursor: number, width: number): TextLine | null {
|
|
376
|
+
let units = prepared.units
|
|
377
|
+
if (cursor >= units.length) return null
|
|
378
|
+
let pen = 0
|
|
379
|
+
let ascent = 0
|
|
380
|
+
let descent = 0
|
|
381
|
+
let i = cursor
|
|
382
|
+
while (i < units.length) {
|
|
383
|
+
let unit = units[i]!
|
|
384
|
+
if (i > cursor && pen + unit.width > width) break
|
|
385
|
+
pen += unit.advance
|
|
386
|
+
if (unit.ascent > ascent) ascent = unit.ascent
|
|
387
|
+
if (unit.descent > descent) descent = unit.descent
|
|
388
|
+
i++
|
|
389
|
+
if (unit.hardBreak) break
|
|
390
|
+
}
|
|
391
|
+
let last = units[i - 1]!
|
|
392
|
+
return {
|
|
393
|
+
from: cursor,
|
|
394
|
+
to: i,
|
|
395
|
+
start: units[cursor]!.start,
|
|
396
|
+
end: last.end,
|
|
397
|
+
width: pen - last.advance + last.width,
|
|
398
|
+
height: ascent + descent,
|
|
399
|
+
ascent,
|
|
400
|
+
hardBreak: last.hardBreak,
|
|
401
|
+
cursor: i,
|
|
402
|
+
}
|
|
334
403
|
}
|
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"
|
|
@@ -512,10 +514,12 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
|
|
|
512
514
|
* `opts.depth` attaches a private depth buffer (cleared + tested per render);
|
|
513
515
|
* `opts.depthWrite: false` (requires depth) keeps the test but stops the
|
|
514
516
|
* draw from writing depth. `opts.blend: "add"` makes the draw accumulate
|
|
515
|
-
* overlapping geometry additively
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
* the
|
|
517
|
+
* overlapping geometry additively and `"multiply"` makes it scale (darken)
|
|
518
|
+
* what is already there, both order-independent (no sorting) instead of
|
|
519
|
+
* overwriting; `"alpha"` composites over in draw-list order (premultiplied
|
|
520
|
+
* output, back-to-front is the caller's job). A depth-tested blended pass is
|
|
521
|
+
* `{ depth: true, blend: "add", depthWrite: false }` - each option only does
|
|
522
|
+
* what it says, neither implies the other. The draw range (`firstVertex`, `vertexCount`, `instanceCount` -
|
|
519
523
|
* see DrawRange) defaults to the whole buffer drawn once and can be changed
|
|
520
524
|
* later with `setDraw`; `instanceCount` is the standard answer to particles
|
|
521
525
|
* and repeated meshes, N copies of the range told apart by `gl_InstanceID`
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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, 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"
|
|
@@ -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
|
@@ -233,10 +233,12 @@ export let {
|
|
|
233
233
|
return proxy
|
|
234
234
|
},
|
|
235
235
|
|
|
236
|
+
// A string child of <text> or <span>: a run of its parent's content, with
|
|
237
|
+
// no element form of its own (the DOM's "#text" node name).
|
|
236
238
|
createTextNode: (value: string): ProxyNode => {
|
|
237
|
-
let proxy = createProxyNode("
|
|
239
|
+
let proxy = createProxyNode("#text")
|
|
238
240
|
// console.debug("[srt] createTextNode", proxy.id, value)
|
|
239
|
-
tree.createNode(proxy.id, "
|
|
241
|
+
tree.createNode(proxy.id, "#text")
|
|
240
242
|
tree.setProperty(proxy.id, "text", "" + value)
|
|
241
243
|
return proxy
|
|
242
244
|
},
|
|
@@ -246,7 +248,7 @@ export let {
|
|
|
246
248
|
tree.setProperty(node.id, "text", "" + value)
|
|
247
249
|
},
|
|
248
250
|
|
|
249
|
-
isTextNode: (node: ProxyNode): boolean => node?.elementType === "
|
|
251
|
+
isTextNode: (node: ProxyNode): boolean => node?.elementType === "#text",
|
|
250
252
|
setProperty: <T>(node: ProxyNode, name: string, value: T): void => {
|
|
251
253
|
// console.debug("[srt] setProperty", node.id, name, value)
|
|
252
254
|
applyProp(node, name, value)
|
package/src/sound.ts
CHANGED
|
@@ -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>
|
|
@@ -34,6 +35,12 @@ export type SoundOptions = {
|
|
|
34
35
|
overlap?: boolean
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** Options for a PCM sound: `SoundOptions` plus the channel count. */
|
|
39
|
+
export type PcmSoundOptions = SoundOptions & {
|
|
40
|
+
/** Channel count, interleaved samples when 2. Defaults to 1 (mono). */
|
|
41
|
+
channels?: 1 | 2
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
/** Options for a streamed sound. Streams are always single-voice. */
|
|
38
45
|
export type SoundStreamOptions = {
|
|
39
46
|
/** Repeat the track until stopped. Defaults to false. */
|
|
@@ -140,6 +147,27 @@ export function createSound(source: Uint8Array, options: SoundOptions = {}): Sou
|
|
|
140
147
|
})
|
|
141
148
|
}
|
|
142
149
|
|
|
150
|
+
/**
|
|
151
|
+
* A sound over raw PCM samples the app generated itself - no decoding, no
|
|
152
|
+
* container. The typed array is the sample format (Uint8Array = unsigned
|
|
153
|
+
* 8-bit, Int16Array = signed 16-bit, Float32Array = 32-bit float), interleaved
|
|
154
|
+
* when `channels` is 2. Same handle and lifecycle as createSound; on a box with
|
|
155
|
+
* no audio device the clip fails to load, so `error()` is set and play() is a
|
|
156
|
+
* no-op, exactly like createSound there. For imperative use, call loadPcm()
|
|
157
|
+
* from "flux:audio".
|
|
158
|
+
*/
|
|
159
|
+
export function createPcmSound(
|
|
160
|
+
samples: Uint8Array | Int16Array | Float32Array,
|
|
161
|
+
sampleRate: number,
|
|
162
|
+
options: PcmSoundOptions = {},
|
|
163
|
+
): Sound {
|
|
164
|
+
return reactiveSound(() => loadPcm(samples, sampleRate, { channels: options.channels }), options.overlap ?? true, {
|
|
165
|
+
loop: options.loop,
|
|
166
|
+
gain: options.gain,
|
|
167
|
+
pan: options.pan,
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
143
171
|
/**
|
|
144
172
|
* Streams a large track, decoding on demand instead of loading it into memory.
|
|
145
173
|
* Single-voice: each play() restarts it. Pass a path (resolved like flux:fs,
|
package/src/text-input.ts
CHANGED
|
@@ -63,14 +63,19 @@ export type TextBuffer = {
|
|
|
63
63
|
* source. Without one it holds the text itself. The selection is always
|
|
64
64
|
* buffer-owned state and is clamped to the current text length on read, so an
|
|
65
65
|
* external truncation of a controlled value cannot leave the caret dangling.
|
|
66
|
-
* Every edit is clamped to `maxLength`.
|
|
66
|
+
* Every edit is clamped to `maxLength`. Mutations commit synchronously
|
|
67
|
+
* (flush), so consecutive edits in one task observe each other - required for
|
|
68
|
+
* burst input, where several handlers run with no microtask between them.
|
|
67
69
|
*/
|
|
68
70
|
export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
|
|
69
71
|
let initial = options.defaultValue ?? ""
|
|
70
72
|
let [internalValue, setInternalValue] = createSignal(initial)
|
|
73
|
+
// The caret starts at the end of the current text: for a controlled buffer
|
|
74
|
+
// that is the owner's value, which defaultValue does not reflect.
|
|
75
|
+
let initialCaret = (options.value?.() ?? initial).length
|
|
71
76
|
let [selectionState, setSelectionState] = createSignal<Selection>({
|
|
72
|
-
anchor:
|
|
73
|
-
focus:
|
|
77
|
+
anchor: initialCaret,
|
|
78
|
+
focus: initialCaret,
|
|
74
79
|
})
|
|
75
80
|
|
|
76
81
|
let value = () => options.value?.() ?? internalValue()
|
|
@@ -89,7 +94,12 @@ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
|
|
|
89
94
|
|
|
90
95
|
let setCaret = (offset: number) => setSelectionState({ anchor: offset, focus: offset })
|
|
91
96
|
|
|
92
|
-
// Apply a text edit and place the caret, clamping to maxLength.
|
|
97
|
+
// Apply a text edit and place the caret, clamping to maxLength. The flush
|
|
98
|
+
// commits the writes (including a controlled owner's from onInput) before
|
|
99
|
+
// returning: edits must observe each other within one task, because event
|
|
100
|
+
// bursts can dispatch several handlers with no microtask between them
|
|
101
|
+
// (Android IME input arrives as backspace+commit bursts; see
|
|
102
|
+
// okf/backlog/event-burst-stale-signal-reads.md).
|
|
93
103
|
let apply = (next: string, caret: number) => {
|
|
94
104
|
let max = options.maxLength?.()
|
|
95
105
|
if (max != null && next.length > max) next = next.slice(0, max)
|
|
@@ -97,6 +107,7 @@ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
|
|
|
97
107
|
if (options.value?.() == null) setInternalValue(next)
|
|
98
108
|
setCaret(caret)
|
|
99
109
|
options.onInput?.(next)
|
|
110
|
+
flush()
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
return {
|
|
@@ -131,6 +142,7 @@ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
|
|
|
131
142
|
// A non-extending left/right on a range collapses to the near edge.
|
|
132
143
|
if (!extend && anchor !== focus && (direction === "left" || direction === "right")) {
|
|
133
144
|
setCaret(direction === "left" ? Math.min(anchor, focus) : Math.max(anchor, focus))
|
|
145
|
+
flush()
|
|
134
146
|
return
|
|
135
147
|
}
|
|
136
148
|
let next = focus
|
|
@@ -139,11 +151,13 @@ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
|
|
|
139
151
|
else if (direction === "start") next = 0
|
|
140
152
|
else if (direction === "end") next = len
|
|
141
153
|
setSelectionState({ anchor: extend ? anchor : next, focus: next })
|
|
154
|
+
flush()
|
|
142
155
|
},
|
|
143
156
|
|
|
144
157
|
setSelection: (anchor, focus) => {
|
|
145
158
|
let len = value().length
|
|
146
159
|
setSelectionState({ anchor: Math.min(anchor, len), focus: Math.min(focus, len) })
|
|
160
|
+
flush()
|
|
147
161
|
},
|
|
148
162
|
|
|
149
163
|
setValue: (next) => apply(next, next.length),
|
package/src/types.d.ts
CHANGED
|
@@ -131,6 +131,21 @@ export interface LayoutProps extends FlexboxProps, GridProps {
|
|
|
131
131
|
overflow?: "visible" | "clip" | "hidden" | "scroll"
|
|
132
132
|
overflowX?: "visible" | "clip" | "hidden" | "scroll"
|
|
133
133
|
overflowY?: "visible" | "clip" | "hidden" | "scroll"
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* As an inline atom (an element child of <text>): leave the flow and sit
|
|
137
|
+
* against that side of the text, at the top of the line where the atom
|
|
138
|
+
* occurs; the lines it overlaps wrap around its margin box. Same-side
|
|
139
|
+
* floats overlapping vertically sit beside each other. The text's height
|
|
140
|
+
* includes the float. Meaningless outside a <text>.
|
|
141
|
+
*/
|
|
142
|
+
float?: "left" | "right"
|
|
143
|
+
/**
|
|
144
|
+
* As an inline atom: start a new line below the text's earlier floats on
|
|
145
|
+
* that side (a floated atom goes below them instead of beside). An empty
|
|
146
|
+
* `<view clear="both" />` is the section break after an image.
|
|
147
|
+
*/
|
|
148
|
+
clear?: "left" | "right" | "both"
|
|
134
149
|
}
|
|
135
150
|
|
|
136
151
|
/** Colors are CSS color strings, parsed to a packed u32 by `parseColor`. */
|
|
@@ -545,8 +560,12 @@ export interface PathProps extends PaintProps, PointerProps {
|
|
|
545
560
|
fillRule?: "nonzero" | "evenodd"
|
|
546
561
|
}
|
|
547
562
|
|
|
548
|
-
|
|
549
|
-
|
|
563
|
+
/**
|
|
564
|
+
* Per-run text style: the paragraph default on <text>, an override on <span>.
|
|
565
|
+
* The cascade is intra-paragraph only: a span inherits from its enclosing
|
|
566
|
+
* span, then from the <text>; nothing inherits across the tree.
|
|
567
|
+
*/
|
|
568
|
+
export interface TextRunProps {
|
|
550
569
|
fontFamily?: "sans" | "serif" | "mono" | (string & {})
|
|
551
570
|
fontSize?: number
|
|
552
571
|
/**
|
|
@@ -557,8 +576,49 @@ export interface TextProps extends PaintProps, PointerProps {
|
|
|
557
576
|
lineHeight?: number
|
|
558
577
|
fontStyle?: "normal" | "italic"
|
|
559
578
|
fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* A styled run inside <text>. Inline text only: children are text and other
|
|
583
|
+
* spans. `color` takes what a text's color takes (solid or gradient).
|
|
584
|
+
* Pointer handlers fire for the boxes of the run's own text on each line it
|
|
585
|
+
* spans and bubble to the enclosing spans and text.
|
|
586
|
+
*/
|
|
587
|
+
export interface SpanProps extends TextRunProps, PointerProps {
|
|
588
|
+
children?: Children
|
|
589
|
+
color?: Color | Gradient
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export interface TextProps extends PaintProps, PointerProps, TextRunProps {
|
|
593
|
+
children?: Children
|
|
560
594
|
textAlign?: "left" | "right" | "center" | "justify"
|
|
561
595
|
maxLines?: number
|
|
596
|
+
/**
|
|
597
|
+
* What happens to text cut off by maxLines: "clip" (default), "ellipsis"
|
|
598
|
+
* (a U+2026 at the end of the last line), or any other string to use as
|
|
599
|
+
* the ellipsis. Drawn in the paragraph's default style.
|
|
600
|
+
*/
|
|
601
|
+
textOverflow?: "clip" | "ellipsis" | (string & {})
|
|
602
|
+
/**
|
|
603
|
+
* A word (wrap unit) wider than the line: "anywhere" (default) splits it
|
|
604
|
+
* at grapheme boundaries so it stays inside the box, "normal" keeps it
|
|
605
|
+
* whole and lets it overflow (CSS's default).
|
|
606
|
+
*/
|
|
607
|
+
overflowWrap?: "normal" | "anywhere"
|
|
608
|
+
/**
|
|
609
|
+
* First-line indent in pixels. Negative hangs: the first line starts at 0
|
|
610
|
+
* and every following line is indented by the magnitude. A hard break does
|
|
611
|
+
* not start a new first line.
|
|
612
|
+
*/
|
|
613
|
+
textIndent?: number
|
|
614
|
+
/**
|
|
615
|
+
* How lines are chosen beyond greedy fitting (CSS text-wrap): "wrap"
|
|
616
|
+
* (default) is greedy; "balance" evens the line lengths while keeping the
|
|
617
|
+
* line count (headings, captions); "pretty" is greedy except that a lone
|
|
618
|
+
* word on the last line pulls one down from the line above. Neither
|
|
619
|
+
* applies once maxLines truncates.
|
|
620
|
+
*/
|
|
621
|
+
textWrap?: "wrap" | "balance" | "pretty"
|
|
562
622
|
}
|
|
563
623
|
|
|
564
624
|
/**
|
package/src/video.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Video playback, reactive (SolidJS) layer. There is no <video> element by
|
|
2
|
+
// design: a player streams into a texture id, so displaying it is just
|
|
3
|
+
// <texture src={video.texture()} /> (or <d-texture> for detached-layout
|
|
4
|
+
// placement), and every texture capability - shaders, offscreen targets,
|
|
5
|
+
// subtree effects - applies to video for free. A richer Video component
|
|
6
|
+
// composes on top of this in a higher layer, not in core.
|
|
7
|
+
//
|
|
8
|
+
// The imperative primitive lives in the `flux:video` module; import { open }
|
|
9
|
+
// from "flux:video" for non-reactive use.
|
|
10
|
+
|
|
11
|
+
import { createSignal, onCleanup } from "@solidjs/signals"
|
|
12
|
+
import type { TextureId } from "flux:gpu"
|
|
13
|
+
import { open } from "flux:video"
|
|
14
|
+
|
|
15
|
+
export type VideoOptions = {
|
|
16
|
+
/** Start playback as soon as the file is open. */
|
|
17
|
+
autoplay?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** An opened video as reactive accessors plus playback controls. */
|
|
21
|
+
export type VideoStream = {
|
|
22
|
+
/** Texture id once the file is open, undefined while opening; render with <texture src={...}>. */
|
|
23
|
+
texture(): TextureId | undefined
|
|
24
|
+
/** Frame size, undefined while opening. */
|
|
25
|
+
width(): number | undefined
|
|
26
|
+
height(): number | undefined
|
|
27
|
+
/** Duration in seconds, undefined while opening. */
|
|
28
|
+
duration(): number | undefined
|
|
29
|
+
/** Set if opening failed (unreadable file, unsupported codec). */
|
|
30
|
+
error(): Error | undefined
|
|
31
|
+
/** Start or resume playback (before open resolves, playback starts on resolve). */
|
|
32
|
+
play(): void
|
|
33
|
+
/** Pause playback; the current frame stays displayed. */
|
|
34
|
+
pause(): void
|
|
35
|
+
/** Whether playback is running (plain read, not a signal). */
|
|
36
|
+
playing(): boolean
|
|
37
|
+
/** Presentation time of the displayed frame in seconds (plain read, not a signal). */
|
|
38
|
+
currentTime(): number
|
|
39
|
+
/** Whether the last frame has been displayed (plain read, not a signal). */
|
|
40
|
+
finished(): boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Opens a video file (MP4, H.264 + AAC) and exposes it as reactive signals:
|
|
45
|
+
* read texture() in JSX and the frames appear once playback starts. Closes
|
|
46
|
+
* automatically when the reactive owner is disposed (e.g. the component
|
|
47
|
+
* unmounts). For imperative use, call open() from "flux:video" directly.
|
|
48
|
+
*/
|
|
49
|
+
export function createVideo(path: string, options: VideoOptions = {}): VideoStream {
|
|
50
|
+
let [texture, setTexture] = createSignal<TextureId | undefined>(undefined)
|
|
51
|
+
let [width, setWidth] = createSignal<number | undefined>(undefined)
|
|
52
|
+
let [height, setHeight] = createSignal<number | undefined>(undefined)
|
|
53
|
+
let [duration, setDuration] = createSignal<number | undefined>(undefined)
|
|
54
|
+
let [error, setError] = createSignal<Error | undefined>(undefined)
|
|
55
|
+
let player: Awaited<ReturnType<typeof open>> | undefined
|
|
56
|
+
let disposed = false
|
|
57
|
+
let wantPlay = options.autoplay ?? false
|
|
58
|
+
|
|
59
|
+
open(path)
|
|
60
|
+
.then((video) => {
|
|
61
|
+
if (disposed) {
|
|
62
|
+
video.close()
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
player = video
|
|
66
|
+
setTexture(video.texture)
|
|
67
|
+
setWidth(video.width)
|
|
68
|
+
setHeight(video.height)
|
|
69
|
+
setDuration(video.duration)
|
|
70
|
+
if (wantPlay) video.play()
|
|
71
|
+
})
|
|
72
|
+
.catch((e) => setError(e instanceof Error ? e : new Error(String(e))))
|
|
73
|
+
|
|
74
|
+
onCleanup(() => {
|
|
75
|
+
disposed = true
|
|
76
|
+
if (player) {
|
|
77
|
+
player.close()
|
|
78
|
+
player = undefined
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
texture,
|
|
84
|
+
width,
|
|
85
|
+
height,
|
|
86
|
+
duration,
|
|
87
|
+
error,
|
|
88
|
+
play() {
|
|
89
|
+
wantPlay = true
|
|
90
|
+
player?.play()
|
|
91
|
+
},
|
|
92
|
+
pause() {
|
|
93
|
+
wantPlay = false
|
|
94
|
+
player?.pause()
|
|
95
|
+
},
|
|
96
|
+
playing: () => player?.playing() ?? false,
|
|
97
|
+
currentTime: () => player?.currentTime() ?? 0,
|
|
98
|
+
finished: () => player?.finished() ?? false,
|
|
99
|
+
}
|
|
100
|
+
}
|