@solidrt/core 0.0.49 → 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 CHANGED
@@ -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
@@ -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`.
@@ -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="Stop" onTap={() => { blip.stop(); tick.stop() }} />
43
- <text color="#888">{blip.playing() || tick.playing() ? "playing" : "silent"}</text>
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.49",
3
+ "version": "0.0.50",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "colord": "^2.9.3"
28
28
  },
29
29
  "devDependencies": {
30
- "@solidrt/flux-types": "0.0.49"
30
+ "@solidrt/flux-types": "0.0.50"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@solidjs/signals": "2.0.0-rc.0",
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 (order-independent, no sorting); anything else draws
25
- // with GL blending disabled and overwrites.
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: "add"`
44
- // accumulates non-linear values - the usual approximation, stated so
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 (order-independent, no sorting) instead of
516
- * overwriting; a depth-tested additive pass is `{ depth: true, blend: "add",
517
- * depthWrite: false }` - each option only does what it says, neither implies
518
- * the other. The draw range (`firstVertex`, `vertexCount`, `instanceCount` -
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("d-span")
239
+ let proxy = createProxyNode("#text")
238
240
  // console.debug("[srt] createTextNode", proxy.id, value)
239
- tree.createNode(proxy.id, "d-span")
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 === "d-span",
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). `createSoundStream` is the same but reads
5
- // a large track from a file path on demand instead of decoding it into memory.
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/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
- export interface TextProps extends PaintProps, PointerProps {
549
- children?: Children
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
  /**