@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/src/gpu.ts CHANGED
@@ -11,8 +11,9 @@
11
11
  // `flux:gpu` module.
12
12
  //
13
13
  // Sampling is a per-texture property declared at creation: `filter`
14
- // ("linear" default | "nearest"), `wrap` ("clamp" default | "repeat") and
15
- // `mipmap` (default false) on every create* helper. One state for every
14
+ // ("linear" default | "nearest"), `wrap` ("clamp" default | "repeat"),
15
+ // `mipmap` (default false) and `anisotropy` (default 1 = off; pair it with
16
+ // mipmap) on every create* helper. One state for every
16
17
  // consumer - `<texture>` display and shader sampling both follow it - so a
17
18
  // nearest texture upscales with hard pixels everywhere (the retro/pixel-art
18
19
  // path: render small, display big). `mipmap: true` keeps a mip chain the
@@ -50,7 +51,11 @@
50
51
  // and target holds 8-bit RGBA UNORM exactly as written; nothing converts to
51
52
  // or from linear light. `filter: "linear"` averages and the `blend` modes
52
53
  // accumulate non-linear values - the usual approximation, stated so
53
- // shaders written today stay correct if a format vocabulary arrives.
54
+ // shaders written today stay correct. The one exception is opt-in: an
55
+ // "rgba8-srgb" texture decodes to linear light when a shader samples it
56
+ // (and "rgba16f" holds linear HDR values), which is how a linear-space
57
+ // renderer such as `@solidrt/3d` reads its inputs before encoding its
58
+ // output back into this contract.
54
59
 
55
60
  import { createEffect, createSignal, getOwner, onCleanup, untrack } from "@solidjs/signals"
56
61
  import * as gpu from "flux:gpu"
@@ -68,13 +73,20 @@ export type CreateOptions = { autoFree?: boolean; label?: string }
68
73
 
69
74
  // Sampling options every texture-producing create* helper accepts, applied at
70
75
  // creation as a property of the texture id (there is no set-sampler-later).
71
- export type SamplerOptions = { filter?: gpu.FilterMode; wrap?: gpu.WrapMode; mipmap?: boolean }
76
+ export type SamplerOptions = { filter?: gpu.FilterMode; wrap?: gpu.WrapMode; mipmap?: boolean; anisotropy?: number }
72
77
  export type { FilterMode, WrapMode, TextureBinding, TextureBindings } from "flux:gpu"
73
78
 
74
79
  // Pixel format option for the pixel-upload creates (createTexture,
75
- // createMutableTexture), fixed for the id's lifetime like the sampler state.
76
- // "rgba8" (default) or "r8" - see TextureFormat in flux:gpu for the r8
77
- // contract (1 byte/pixel, sampled as `(v, 0, 0, 1)`, any width).
80
+ // createCubeTexture, createMutableTexture), fixed for the id's lifetime like
81
+ // the sampler state. "rgba8" (default), "rgba8-srgb" (decodes to linear
82
+ // light on sample), "r8", the float data-texture formats "r32f"/"rgba32f"
83
+ // (Float32Array payload, nearest/texelFetch sampling only, no readback) and
84
+ // the HDR image format "rgba16f" (Float32Array payload packed to half float,
85
+ // filterable) - see TextureFormat in flux:gpu for each format's contract.
86
+ // Draw targets (createDrawTarget, createCubeDrawTarget) take the renderable
87
+ // subset: "rgba8", "rgba8-srgb", "rgba16f".
88
+ // "etc2-rgba8" (compressed) is a reserved future value of the same
89
+ // vocabulary.
78
90
  export type TextureFormatOptions = { format?: gpu.TextureFormat }
79
91
  export type { TextureFormat } from "flux:gpu"
80
92
 
@@ -135,7 +147,7 @@ export {
135
147
  // GPU-side (exact, same size): seed a loadOp "load" accumulator, snapshot a
136
148
  // ping-pong buffer, reset state to a known image.
137
149
  export { copyTexture, destroyBuffer, renderTarget, setDraw } from "flux:gpu"
138
- export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange, InstanceAttribute, ShaderParams, Topology, VertexAttribute } from "flux:gpu"
150
+ export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange, InstanceAttribute, InstanceOrder, OrderUpdate, ShaderParams, Topology, VertexAttribute } from "flux:gpu"
139
151
 
140
152
  // The draw-list verbs, re-exported raw: entries live and die with their draw
141
153
  // target (see createDrawTarget below), so there is no per-entry lifetime to
@@ -149,7 +161,10 @@ export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexF
149
161
  // params every entry reads. setDrawOrder replaces the whole
150
162
  // list order with a full permutation of the live ids - the sorting verb
151
163
  // (opaque front-to-back, transparent back-to-front, re-issued when the
152
- // camera moves).
164
+ // camera moves). That orders ENTRIES; ordering the instance RECORDS within
165
+ // one entry is the instanceOrder creation option (see InstanceOrder), whose
166
+ // projected-key direction updates ride setDraw/setDrawRange as
167
+ // orderDirection.
153
168
  export { addDraw, removeDraw, setDrawBuffers, setDrawOrder, setDrawParams, setDrawRange, setDrawTextures } from "flux:gpu"
154
169
 
155
170
  // The device ceilings (max texture/target size, sampler inputs per pass,
@@ -216,8 +231,9 @@ export { captureSnapshot, readTexture } from "flux:gpu"
216
231
  /**
217
232
  * Uploads raw pixels to an immutable GPU texture and returns its id (use it
218
233
  * as `<texture src={id} />`). `data` must be exactly `width * height` pixels
219
- * at the declared format's size (`* 4` bytes for the default "rgba8", `* 1`
220
- * for "r8"); a mismatch throws. RGBA data is uploaded verbatim and composited
234
+ * at the declared format's size, in the view type matching the format
235
+ * (Uint8Array for "rgba8"/"rgba8-srgb"/"r8", Float32Array for "r32f"/
236
+ * "rgba32f"/"rgba16f"); a mismatch throws. RGBA data is uploaded verbatim and composited
221
237
  * as premultiplied alpha, like every texture: `decodeImage` and the readback
222
238
  * calls already deliver that, and hand-built pixels must too (`rgb * a`). For pixels you intend to mutate and
223
239
  * re-upload, use `createMutableTexture` instead. When called inside a
@@ -229,7 +245,7 @@ export { captureSnapshot, readTexture } from "flux:gpu"
229
245
  * scope.
230
246
  */
231
247
  export function createTexture(
232
- data: Uint8Array,
248
+ data: Uint8Array | Float32Array,
233
249
  width: number,
234
250
  height: number,
235
251
  opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
@@ -239,18 +255,52 @@ export function createTexture(
239
255
  return id
240
256
  }
241
257
 
258
+ /**
259
+ * Uploads a cube map: six square faces of `size * size` pixels in GL order
260
+ * (+X, -X, +Y, -Y, +Z, -Z), each in the view type matching the declared
261
+ * format like `createTexture`, and returns its id. A cube map is sampled by
262
+ * DIRECTION from a `uniform samplerCube` in a shader (`texture(uEnv, dir)`;
263
+ * `textureLod` for an explicit mip level, `mipmap: true` builds the chain
264
+ * from the faces) - the skybox, reflection and environment-lighting
265
+ * primitive. `faces` may instead be an explicit mip chain - an array of
266
+ * six-face arrays, level 0 first, halving down to the 1x1 level (the full
267
+ * chain) - uploaded as given: prefiltered environment levels, with no
268
+ * generated chain and so no renderability requirement on the format (an
269
+ * "rgba16f" chain works on every device). Explicit levels imply `mipmap:
270
+ * true`. Sampler-only: the id binds through `textures` like any other,
271
+ * but `<texture src>` cannot display it and `readTexture`, `copyTexture`,
272
+ * `uploadTexture` and `resizeTexture` throw (it is create-once). `wrap` has
273
+ * no effect - cube filtering is seamless across faces. Binding a cube map
274
+ * to a `sampler2D`, or a 2D texture to a `samplerCube`, throws at the bind.
275
+ * Note GL's cube convention: each face is what a lookup in that direction
276
+ * returns, the cube as seen from OUTSIDE, so a face set authored the Three
277
+ * way (each face as seen from inside) reads mirrored on x unless each
278
+ * image is mirrored at load. Freed like `createTexture` (auto-free under a
279
+ * reactive owner, `{ autoFree: false }` or `destroyTexture` otherwise).
280
+ */
281
+ export function createCubeTexture(
282
+ faces: (Uint8Array | Float32Array)[] | (Uint8Array | Float32Array)[][],
283
+ size: number,
284
+ opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
285
+ ): gpu.TextureId {
286
+ let id = gpu.createCubeTexture(faces, size, opts)
287
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => gpu.destroyTexture(id))
288
+ return id
289
+ }
290
+
242
291
  /**
243
292
  * Creates a GPU texture you intend to update over time: seed it with `data`,
244
293
  * then call `uploadTexture(id, data)` (from flux:gpu) to push new pixels.
245
294
  * `data` must hold at least `width * height` pixels at the declared format's
246
- * size (`* 4` bytes for the default "rgba8", `* 1` for "r8"; it may hold
247
- * several frames). Like `createTexture`, the texture is freed automatically
295
+ * size, in the view type matching the format (Uint8Array for "rgba8"/
296
+ * "rgba8-srgb"/"r8", Float32Array for "r32f"/"rgba32f"/"rgba16f"); it may
297
+ * hold several frames. Like `createTexture`, the texture is freed automatically
248
298
  * when the reactive owner is disposed (opt out with `{ autoFree: false }`);
249
299
  * created outside a reactive scope you must call `destroyTexture` (from
250
300
  * flux:gpu) yourself.
251
301
  */
252
302
  export function createMutableTexture(
253
- data: Uint8Array,
303
+ data: Uint8Array | Float32Array,
254
304
  width: number,
255
305
  height: number,
256
306
  opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
@@ -293,6 +343,10 @@ export function createMutableTexture(
293
343
  * `in vec2 vUV;` yourself to read it. One naming trap: GLSL ES reserves
294
344
  * `packed` as a keyword, so `vec4 packed = texture(...)` fails with a syntax
295
345
  * error that does not name the identifier - pick another name.
346
+ *
347
+ * A bad source is a runtime failure, not one `srt check` catches: the compile
348
+ * throws at this call, so a shader created in a component body takes the app
349
+ * to the error window unless an `<Errored>` closer in the tree claims it.
296
350
  */
297
351
  export function createShaderTexture(
298
352
  fragmentSrc: string,
@@ -355,6 +409,8 @@ export function createShaderTarget(
355
409
  /** One buffer per instance slot of the pipeline (index = the
356
410
  * attributes' `slot`); pass this OR `instanceBuffer`, not both. */
357
411
  instanceBuffers?: gpu.BufferId[]
412
+ /** Draw the instance records in key order (see InstanceOrder). */
413
+ instanceOrder?: gpu.InstanceOrder
358
414
  clearColor?: [number, number, number, number]
359
415
  render?: "auto" | "manual"
360
416
  loadOp?: "clear" | "load"
@@ -395,6 +451,13 @@ export function createShaderTarget(
395
451
  * driven later with `setTargetTextures`, same precedence and coverage
396
452
  * rules.
397
453
  *
454
+ * `format` is the color storage: "rgba8" (default), "rgba8-srgb" (encodes
455
+ * what a pass writes, decodes on sample) or "rgba16f" (half float, keeps
456
+ * the range - an HDR view or bake; where `limits.halfFloatRenderable`,
457
+ * else it throws). A target of either non-default format is SAMPLER-ONLY:
458
+ * a pass source, never displayed by a `<texture>` leaf, read back or
459
+ * copied (render it through a pass into an rgba8 target for those).
460
+ *
398
461
  * `into` makes a sub-target: a draw target rendering into the rectangle at
399
462
  * `x`/`y` (top-left origin) of draw target `into`'s storage, so N views or
400
463
  * N shadow maps share one texture and ONE pass; the id is a draw target to
@@ -416,6 +479,7 @@ export function createDrawTarget(
416
479
  params?: gpu.ShaderParams | null,
417
480
  opts?: {
418
481
  depth?: boolean | "texture"
482
+ format?: "rgba8" | "rgba8-srgb" | "rgba16f"
419
483
  textures?: gpu.TextureBindings
420
484
  clearColor?: [number, number, number, number]
421
485
  render?: "auto" | "manual"
@@ -432,6 +496,41 @@ export function createDrawTarget(
432
496
  return id
433
497
  }
434
498
 
499
+ /**
500
+ * Creates a cube draw target: a draw target whose output is a `size` x
501
+ * `size` cube map, rendered one face at a time with `renderTarget(id,
502
+ * face)` (0 +X, 1 -X, 2 +Y, 3 -Y, 4 +Z, 5 -Z) - the reflection-probe
503
+ * primitive (`@solidrt/3d`'s `scene.createReflectionProbe` drives it).
504
+ * Manual by contract, one depth renderbuffer for the six faces (`depth:
505
+ * true`), no samples or tiles; `format` rgba8, rgba8-srgb (encodes on
506
+ * write, decodes on sample) or rgba16f (half float, the HDR probe format,
507
+ * where `limits.halfFloatRenderable`); `mipmap: true` allocates the chain and
508
+ * `renderTarget(id, face, level)` renders one level of it (a face render
509
+ * without a level regenerates the chain instead) - the prefiltered
510
+ * environment path. The id binds to a `samplerCube`
511
+ * and cannot be displayed, read back, copied or resized. Each face pass
512
+ * inverts the front-face rule (a GL cube face is the x mirror of a 2D
513
+ * target's image), so render the faces through an x-mirrored projection;
514
+ * see flux:gpu's createCubeDrawTarget. Freed like every target.
515
+ */
516
+ export function createCubeDrawTarget(
517
+ size: number,
518
+ params?: gpu.ShaderParams | null,
519
+ opts?: {
520
+ depth?: boolean
521
+ format?: "rgba8" | "rgba8-srgb" | "rgba16f"
522
+ textures?: gpu.TextureBindings
523
+ clearColor?: [number, number, number, number]
524
+ render?: "manual"
525
+ loadOp?: "clear" | "load"
526
+ } & CreateOptions &
527
+ SamplerOptions,
528
+ ): gpu.TextureId {
529
+ let id = gpu.createCubeDrawTarget(size, params, opts)
530
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => gpu.destroyTexture(id))
531
+ return id
532
+ }
533
+
435
534
  /** The reactive shader description `createShaderTextureMemo` builds from.
436
535
  * Sampling (`filter`/`wrap`/`mipmap`) is creation-time state, so changing it rebuilds
437
536
  * at a fresh id, like a fragment-source or sampler-binding change. */
@@ -444,14 +543,22 @@ export type ShaderSpec = {
444
543
  } & SamplerOptions
445
544
 
446
545
  // Shallow name->value equality for params/textures records; treats undefined
447
- // as the empty record. A param value may be a number or a flat number array
448
- // (typed uniforms), so arrays compare elementwise; a texture binding may be
449
- // an `{ id, filter?, wrap? }` override, compared field by field.
450
- type RecordValue = number | number[] | gpu.TextureBinding
546
+ // as the empty record. A param value may be a number or a flat number list
547
+ // (typed uniforms: a number[] or a Float32Array/Float64Array), so lists
548
+ // compare elementwise; a texture binding may be an `{ id, filter?, wrap? }`
549
+ // override, compared field by field.
550
+ type RecordValue = number | number[] | Float32Array | Float64Array | gpu.TextureBinding
551
+ function isNumberList(v: RecordValue | undefined): v is number[] | Float32Array | Float64Array {
552
+ return Array.isArray(v) || v instanceof Float32Array || v instanceof Float64Array
553
+ }
451
554
  function sameValue(a: RecordValue | undefined, b: RecordValue | undefined): boolean {
452
555
  if (a === b) return true
453
- if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => v === b[i])
454
- if (typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
556
+ if (isNumberList(a) && isNumberList(b)) {
557
+ if (a.length !== b.length) return false
558
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
559
+ return true
560
+ }
561
+ if (typeof a === "object" && typeof b === "object" && !isNumberList(a) && !isNumberList(b)) {
455
562
  return a.id === b.id && a.filter === b.filter && a.wrap === b.wrap
456
563
  }
457
564
  return false
@@ -601,6 +708,8 @@ export function createPipelineTexture(
601
708
  /** One buffer per instance slot (index = the attributes' `slot`);
602
709
  * pass this OR `instanceBuffer`, not both. */
603
710
  instanceBuffers?: gpu.BufferId[]
711
+ /** Draw the instance records in key order (see InstanceOrder). */
712
+ instanceOrder?: gpu.InstanceOrder
604
713
  topology?: gpu.Topology
605
714
  depth?: boolean
606
715
  depthWrite?: boolean
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export * from "./renderer"
2
- export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, prepareText, layoutNextLine, unitInk, getBoundingBox, getBoundingBoxViewport, snapshotTexture, onPointerMove } from "./core"
2
+ export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, prepareText, layoutNextLine, unitInk, getBoundingBox, getBoundingBoxViewport, getLayoutBox, snapshotTexture, onPointerMove } from "./core"
3
3
  export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
4
- export { parseColor, mixColors, brightness, createLinearGradient, createRadialGradient } from "./color"
4
+ export { parseColor, mixColors, withAlpha, 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"
@@ -12,7 +12,7 @@ export { gamepads } from "./gamepad"
12
12
  export type { GamepadState } from "./gamepad"
13
13
  export { capabilities } from "./capabilities"
14
14
  export type { Capabilities, WindowSizeClass } from "./capabilities"
15
- export { createTexture } from "./gpu"
15
+ export { createCubeTexture, createTexture } from "./gpu"
16
16
  export type { TextureId } from "./gpu"
17
17
  export { createImage, decodeImage, encodeImage } from "./image"
18
18
  export type { DecodedImage, ImageSource } from "./image"
@@ -43,6 +43,10 @@ export type {
43
43
  TextEvent,
44
44
  TextInputHints,
45
45
  PaintProps,
46
+ BlendMode,
47
+ DrawStyle,
48
+ StrokeCap,
49
+ StrokeJoin,
46
50
  WindowProps,
47
51
  WindowShaderProps,
48
52
  ViewProps,
@@ -51,11 +55,17 @@ export type {
51
55
  LineProps,
52
56
  PathProps,
53
57
  TextProps,
58
+ FontFamily,
59
+ FontStyle,
60
+ FontWeight,
61
+ TextDecoration,
62
+ TextAlign,
63
+ TextOverflow,
54
64
  TextureProps,
55
65
  Color,
56
66
  Pct,
57
67
  } from "./types"
58
- export type { MeasureTextOptions, PreparedText, TextUnit } from "flux:rendertree"
68
+ export type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit } from "flux:rendertree"
59
69
 
60
70
  // A percentage value for dimensional props (e.g. transformOrigin): `pct(50)` is
61
71
  // half the element box. Keeps percentages a first-class branded value rather
package/src/pan.ts CHANGED
@@ -18,7 +18,13 @@ export interface PanOptions {
18
18
  */
19
19
  axis?: PanAxis
20
20
  onPanStart?: () => void
21
- /** Pointer movement since the previous event; positive dx is rightward, positive dy downward. */
21
+ /**
22
+ * Pointer movement since the previous event, in the handler node's PARENT
23
+ * frame (the frame its own x/y live in: window pixels under no scaling
24
+ * ancestor, design pixels under a designSize fit), so it applies 1:1
25
+ * whether the consumer moves the node itself or content inside it.
26
+ * Positive dx is rightward, positive dy downward.
27
+ */
22
28
  onPanMove?: (dx: number, dy: number) => void
23
29
  onPanEnd?: () => void
24
30
  }
@@ -35,8 +41,17 @@ export interface PanOptions {
35
41
  // cancel() is the external-cancel hook; it ends an active pan without
36
42
  // onPanEnd. Options are read at event time. Single-pointer by design; for
37
43
  // multi-pointer pinch/rotate compose createTransform instead.
44
+ //
45
+ // Frames: the slop is finger travel, so it is measured in window pixels
46
+ // (clientX/clientY); the deltas are measured in the node's parent frame
47
+ // (parentX/parentY), the frame the node's own coordinates live in, so a
48
+ // scaled ancestor (a designSize fit) is accounted for and both the drag
49
+ // idiom (move the node) and the content idiom (scroll what is inside it)
50
+ // apply the delta unchanged. The event carries both frames exactly, on
51
+ // and off the node.
38
52
  export function createPan(options: PanOptions) {
39
- // Down position while armed; last delivered position while active.
53
+ // Down position (window pixels) while armed; last delivered position
54
+ // (parent frame) while active.
40
55
  let origin: { x: number; y: number } | null = null
41
56
  let active: number | null = null
42
57
  let armed: number | null = null
@@ -78,7 +93,7 @@ export function createPan(options: PanOptions) {
78
93
  if (arena.steal(e.pointerId, owner)) {
79
94
  active = e.pointerId
80
95
  armed = null
81
- origin = { x: e.clientX, y: e.clientY }
96
+ origin = { x: e.parentX, y: e.parentY }
82
97
  options.onPanStart?.()
83
98
  } else {
84
99
  // The arena is resolved against us; the drag belongs elsewhere.
@@ -87,8 +102,8 @@ export function createPan(options: PanOptions) {
87
102
  return
88
103
  }
89
104
  if (active === e.pointerId && origin) {
90
- options.onPanMove?.(e.clientX - origin.x, e.clientY - origin.y)
91
- origin = { x: e.clientX, y: e.clientY }
105
+ options.onPanMove?.(e.parentX - origin.x, e.parentY - origin.y)
106
+ origin = { x: e.parentX, y: e.parentY }
92
107
  }
93
108
  },
94
109
  onPointerUp: (e: PointerEvent) => {
package/src/renderer.ts CHANGED
@@ -125,16 +125,18 @@ export function scanForOrphans(now: number): void {
125
125
  counts.set(node.elementType, (counts.get(node.elementType) ?? 0) + 1)
126
126
  }
127
127
  if (total === 0) return
128
+ // Warn only when a new element type joins the orphans, but list every type
129
+ // with its count so the breakdown always adds up to the total.
128
130
  let fresh = [...counts].filter(([type]) => !warnedLeakTypes.has(type))
129
131
  if (fresh.length === 0) return
130
132
  for (let [type] of fresh) warnedLeakTypes.add(type)
131
- let list = fresh.map(([type, n]) => `<${type}> x${n}`).join(", ")
133
+ let list = [...counts].map(([type, n]) => `<${type}> x${n}`).join(", ")
132
134
  console.warn(
133
135
  `Leak sentinel: ${total} nodes are unreachable and will never be freed: ${list}. ` +
134
136
  `The usual cause is reading an element-valued prop more than once (every read ` +
135
137
  `builds a new subtree); read it once where it mounts, or resolve it with ` +
136
138
  `children(). If these nodes are intentionally kept for later mounting, ignore ` +
137
- `this. Element types already reported are not reported again.`,
139
+ `this. The next warning comes when a new element type joins the list.`,
138
140
  )
139
141
  }
140
142
 
@@ -261,6 +263,18 @@ let renderer = createRenderer<ProxyNode>({
261
263
  insertNode: (parent: ProxyNode, node: ProxyNode, anchor?: ProxyNode): void => {
262
264
  if (!node) return
263
265
 
266
+ // A value without an id is not a node: a signal accessor (<For>, <Repeat>,
267
+ // a memo) that reached the renderer unresolved. Without this check it
268
+ // surfaces as an FFI type error on node.id, which names nothing. Known
269
+ // cause: okf/upstream/signals-flatten-array-clobbers-needs-unwrap.md.
270
+ if (typeof node !== "object" || node.id === undefined) {
271
+ let what = typeof node === "function" ? "a signal accessor" : `a ${typeof node}`
272
+ throw new Error(
273
+ `insertNode received ${what} instead of an element under <${parent?.elementType ?? "?"}>; ` +
274
+ `resolve the children with children() or return one root element from the component.`,
275
+ )
276
+ }
277
+
264
278
  // A re-inserted node is being moved, not destroyed: cancel its pending
265
279
  // destroy so the end-of-tick sweep leaves it (and its subtree) alone.
266
280
  pendingDestroy.delete(node.id)
@@ -66,8 +66,8 @@ declare module "srt:events" {
66
66
  // The running application's own surface (lattice), present in every build.
67
67
  declare module "srt:app" {
68
68
  /**
69
- * Leave the current app, unconditionally: back to the launcher in a dev
70
- * client, quit when standalone or at the launcher root (on Android the
69
+ * Leave the current app, unconditionally: back to the player in a dev
70
+ * client, quit when standalone or at the player root (on Android the
71
71
  * client backgrounds instead of dying). The default action of an
72
72
  * unprevented `back` event; prefer the @solidrt/core re-export.
73
73
  */
@@ -81,7 +81,7 @@ declare module "srt:dev" {
81
81
  export const canDiscover: boolean
82
82
  export const recents: string[]
83
83
  /**
84
- * The dev-server address the client was launched with (so the launcher can
84
+ * The dev-server address the client was launched with (so the player can
85
85
  * auto-connect without on-device interaction), or null when launched without
86
86
  * one.
87
87
  */
@@ -101,7 +101,7 @@ declare module "srt:dev" {
101
101
  export function registerDebug(name: string, fn: (args?: any) => unknown): void
102
102
  }
103
103
 
104
- // Installed-app management (lattice), the launcher's surface over the client's
104
+ // Installed-app management (lattice), the player's surface over the client's
105
105
  // version store. Present only in go/dev client builds; elsewhere `available`
106
106
  // is false, `list` returns [], and launch/remove are no-ops.
107
107
  declare module "srt:apps" {
@@ -166,7 +166,7 @@ declare module "srt:apps" {
166
166
  export function info(id: string): AppInfo
167
167
  /**
168
168
  * Boot the app's installed current version, replacing the running app (the
169
- * launcher). Throws when the app is not installed. Custom fonts of the
169
+ * player). Throws when the app is not installed. Custom fonts of the
170
170
  * launched app register at client startup only, not mid-session.
171
171
  */
172
172
  export function launch(id: string): void
@@ -182,8 +182,8 @@ declare module "srt:apps" {
182
182
  */
183
183
  export function clearCache(id: string): void
184
184
  /**
185
- * Build identity of this runtime, for the launcher's settings screen. Not
186
- * app-specific, but surfaced here since the launcher already imports this
185
+ * Build identity of this runtime, for the player's settings screen. Not
186
+ * app-specific, but surfaced here since the player already imports this
187
187
  * module. `version` is the release version (git describe; "0.0.0-dev" in a
188
188
  * plain build), `profile` is "debug" or "release", `platform` is the OS
189
189
  * (std::env::consts::OS, e.g. "linux", "android", "windows", "macos").
package/src/svg.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { parseSvg as fluxParseSvg } from "flux:svg"
8
8
  import { parseColor, type Gradient } from "./color"
9
+ import type { StrokeCap, StrokeJoin } from "./types"
9
10
 
10
11
  /**
11
12
  * Tags an inline SVG source, returning it unchanged. Documents small enough to
@@ -27,8 +28,8 @@ export type SvgDraw = {
27
28
  drawStyle: "fill" | "stroke"
28
29
  fillRule?: "nonzero" | "evenodd"
29
30
  strokeWidth?: number
30
- strokeCap?: "butt" | "round" | "square"
31
- strokeJoin?: "miter" | "round" | "bevel"
31
+ strokeCap?: StrokeCap
32
+ strokeJoin?: StrokeJoin
32
33
  }
33
34
 
34
35
  /** A parsed document: intrinsic size (viewBox/width-height) plus the flat draw list. */
package/src/text-input.ts CHANGED
@@ -214,6 +214,9 @@ export type EditorLine = {
214
214
  /** The caret's box in content coordinates (before scroll). */
215
215
  export type CaretRect = { x: number; y: number; height: number }
216
216
 
217
+ /** One line's slice of a selection highlight, in content coordinates (before scroll). */
218
+ export type SelectionRect = { x: number; y: number; width: number; height: number }
219
+
217
220
  export type TextEditorLayout = {
218
221
  lines(): EditorLine[]
219
222
  caret(): CaretRect
@@ -221,6 +224,8 @@ export type TextEditorLayout = {
221
224
  caretLine(): number
222
225
  /** The caret position (grapheme boundary) on line `line` nearest to content x. */
223
226
  offsetAtX(line: number, x: number): number
227
+ /** Highlight boxes for the anchor..focus range, one per touched line; empty when collapsed. */
228
+ selectionRects(anchor: number, focus: number): SelectionRect[]
224
229
  /** Index of the line at content y (clamped to the first/last line). */
225
230
  lineAtY(y: number): number
226
231
  /** The caret position one grapheme left/right of `offset`; a break sequence is one step. For createTextBuffer's `step`. */
@@ -322,18 +327,22 @@ export function createTextEditorLayout(
322
327
  }
323
328
  let caretLine = createMemo(() => lineOf(input().caret))
324
329
 
325
- // The caret sits at the last stop at or before its offset (an offset inside
326
- // a grapheme, e.g. from a controlled value, snaps back).
327
- let caret = createMemo((): CaretRect => {
328
- let offset = input().caret
329
- let index = caretLine()
330
- let line = lines()[index]!
330
+ // The x of the last stop at or before `offset` on line `index` (an offset
331
+ // inside a grapheme, e.g. from a controlled value, snaps back).
332
+ let xAt = (index: number, offset: number): number => {
331
333
  let x = 0
332
334
  for (let stop of lineStops(index)) {
333
335
  if (stop.offset > offset) break
334
336
  x = stop.x
335
337
  }
336
- return { x, y: line.y, height: line.height }
338
+ return x
339
+ }
340
+
341
+ let caret = createMemo((): CaretRect => {
342
+ let offset = input().caret
343
+ let index = caretLine()
344
+ let line = lines()[index]!
345
+ return { x: xAt(index, offset), y: line.y, height: line.height }
337
346
  })
338
347
 
339
348
  // Only positions that show on this line are candidates: a boundary offset
@@ -352,6 +361,32 @@ export function createTextEditorLayout(
352
361
  return best
353
362
  }
354
363
 
364
+ // One box per line the range touches, offsets snapped to stops like the
365
+ // caret. A line the selection continues past (every one but the last)
366
+ // highlights to its ink end plus a space width, so the selected break - a
367
+ // newline, or the wrap the range crosses - is visible, and a selected empty
368
+ // line shows as that sliver alone. The last line ends at the focus stop; a
369
+ // range ending on a wrap boundary owns a zero-width box there (the boundary
370
+ // offset displays on the next line) and drops it.
371
+ let selectionRects = (anchor: number, focus: number): SelectionRect[] => {
372
+ let start = Math.min(anchor, focus)
373
+ let end = Math.max(anchor, focus)
374
+ if (start >= end) return []
375
+ let ls = lines()
376
+ let first = lineOf(start)
377
+ let last = lineOf(end)
378
+ let breakWidth = measureText(" ", input().font).width
379
+ let out: SelectionRect[] = []
380
+ for (let i = first; i <= last; i++) {
381
+ let line = ls[i]!
382
+ let x = i === first ? xAt(i, start) : 0
383
+ let width = i === last ? xAt(i, end) - x : Math.max(line.width - x, 0) + breakWidth
384
+ if (width <= 0) continue
385
+ out.push({ x, y: line.y, width, height: line.height })
386
+ }
387
+ return out
388
+ }
389
+
355
390
  let lineAtY = (y: number): number => {
356
391
  let ls = lines()
357
392
  let index = 0
@@ -399,7 +434,7 @@ export function createTextEditorLayout(
399
434
  setScrollY(follow(scrollY(), c.y, c.height, vh, contentHeight))
400
435
  })
401
436
 
402
- return { lines, caret, caretLine, offsetAtX, lineAtY, step, scrollX, scrollY }
437
+ return { lines, caret, caretLine, offsetAtX, selectionRects, lineAtY, step, scrollX, scrollY }
403
438
  }
404
439
 
405
440
  // Wrap units wider than `width` (through their glued pieces) split into one