@solidrt/core 0.0.54 → 0.0.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +100 -22
- package/agents/painting.md +36 -12
- package/agents/performance.md +36 -6
- package/docs/reference/detached.md +20 -10
- package/docs/reference/drawing.md +10 -3
- package/docs/reference/elements.md +3 -1
- package/docs/reference/gpu.md +12 -1
- package/docs/reference/layout.md +28 -0
- package/docs/reference/text.md +34 -0
- package/examples/README.md +5 -0
- package/examples/detached-positioning.tsx +3 -1
- package/examples/line-points.tsx +21 -0
- package/examples/stagger.tsx +45 -0
- package/examples/text-flow.tsx +90 -0
- package/examples/text-glyphs.tsx +73 -0
- package/examples/view-design-size.tsx +5 -0
- package/package.json +6 -5
- package/src/color.ts +17 -0
- package/src/core.ts +28 -7
- package/src/environment.ts +87 -69
- package/src/gamepad.ts +10 -8
- package/src/gpu.ts +130 -21
- package/src/index.ts +14 -4
- package/src/pan.ts +20 -5
- package/src/renderer.ts +16 -2
- package/src/runtime-modules.d.ts +7 -7
- package/src/svg.ts +3 -2
- package/src/text-input.ts +43 -8
- package/src/transform.ts +52 -23
- package/src/types.d.ts +158 -44
- package/src/window.ts +54 -38
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")
|
|
15
|
-
// `mipmap` (default false)
|
|
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
|
|
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
|
|
76
|
-
// "rgba8" (default)
|
|
77
|
-
//
|
|
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
|
|
220
|
-
* for "r8"
|
|
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
|
|
247
|
-
*
|
|
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
|
|
448
|
-
// (typed uniforms
|
|
449
|
-
// an `{ id, filter?, wrap? }`
|
|
450
|
-
|
|
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 (
|
|
454
|
-
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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.
|
|
91
|
-
origin = { x: e.
|
|
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 =
|
|
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.
|
|
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)
|
package/src/runtime-modules.d.ts
CHANGED
|
@@ -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
|
|
70
|
-
* client, quit when standalone or at 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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
186
|
-
* app-specific, but surfaced here since the
|
|
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?:
|
|
31
|
-
strokeJoin?:
|
|
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
|
|
326
|
-
// a grapheme, e.g. from a controlled value, snaps back).
|
|
327
|
-
let
|
|
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
|
|
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
|