@solidrt/core 0.0.63 → 0.0.65

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
@@ -111,7 +111,7 @@ tsconfig.json - the two load-bearing lines are jsx + jsxImportSource:
111
111
  ```
112
112
 
113
113
  Peer deps @solidjs/signals and @solidjs/universal must match (currently
114
- 2.0.0-rc.9); bun resolves them from peerDependencies. On a version bump,
114
+ 2.0.0-rc.13); bun resolves them from peerDependencies. On a version bump,
115
115
  `bun install` only warns ("incorrect peer dependency") and keeps the old
116
116
  ones, and `bun update solid-js @solidjs/signals @solidjs/universal` does
117
117
  not read the pin either: it adds all three to package.json at registry
@@ -316,6 +316,24 @@ that reads exactly like Solid fallout.
316
316
  nothing focused, the window alone), so `<window onKeyDown>` is the
317
317
  app-global shortcut point; `stopPropagation()` ends the walk. `focusable`
318
318
  declares focus-navigation candidacy (enumerate via getFocusables()).
319
+ Every pointer, wheel and key event carries `timeStamp` (ms): when the
320
+ input happened. Time anything about input with it (a press length, the
321
+ gap between two taps, a speed from two positions), never with
322
+ `performance.now()` in the handler: that is when the handler ran, and
323
+ it does not follow the dev clock, `srt render` or a test. The stamp is
324
+ the input's own time, not its delivery's: a move carries the time the
325
+ pointer was at the position it reports, however late the frame that
326
+ delivers it; a down, up, wheel or key the time the platform recorded.
327
+ Compare stamps only with each other.
328
+ A pointer move also carries `predicted` (false on every other event).
329
+ Touch arrives in batches, and a batch can miss the frame it belongs to;
330
+ the runtime then bridges the gap with one predicted step so a drag does
331
+ not stall and jump, and that move has `predicted: true`. DRAW with it,
332
+ like any move; do NOT MEASURE with it: leave it out of a velocity, a
333
+ path or a stroke you keep. If the finger had in fact stopped, the next
334
+ move reports where it really is with the time it got there, which is
335
+ EARLIER than the predicted move's `timeStamp` - the one case where
336
+ stamps go back. The recognizers below already do this.
319
337
 
320
338
  - Gesture recognizers, shared by every package: `createPan` (single-pointer
321
339
  drag, axis-aware slop, per-event dx/dy) and `createTransform` (merged
@@ -332,11 +350,16 @@ that reads exactly like Solid fallout.
332
350
  and two-finger translation different meanings - dx/dy alone cannot tell
333
351
  them apart).
334
352
  Both ends carry the lift velocity (`onPanEnd(velocity)`,
335
- `onTransformEnd(velocity)`: parent-frame px/s from `createVelocityTracker`,
353
+ `onTransformEnd(velocity)`: parent-frame px/s from `createVelocityTracker`
354
+ (`push(x, y, e.timeStamp)`, `velocity(up.timeStamp)`: it reads no clock;
355
+ push real positions only, `if (!e.predicted)`),
336
356
  a least-squares fit over the last 100 ms of positions, never the last
337
357
  two samples - the final sample before a lift is often stationary and
338
- frame batching makes the last delta a frame old; zero after a 50 ms
339
- rest or under `FLING_MIN_VELOCITY`), the fling fact for whoever animates
358
+ frame batching makes the last delta a frame old; a pause over 40 ms
359
+ cuts the history, so only the motion after it counts; zero after a
360
+ 50 ms rest, when that motion covers under 8 px (a finger leaving the
361
+ panel shifts a pixel or two, which is not a fling), or under
362
+ `FLING_MIN_VELOCITY`), the fling fact for whoever animates
340
363
  on. The discrete recognizers: `createSwipe({ directions?, onSwipe(direction,
341
364
  velocity), onSwipeMove?, onSwipeEnd? })` (a pan classified at the lift:
342
365
  24 px of travel at 300 px/s within 30 degrees of an axis, direction by
@@ -432,7 +455,12 @@ that reads exactly like Solid fallout.
432
455
  `chord(...sources)` are the interactions (Unity's Hold/Tap/MultiTap):
433
456
  button sources made from button sources, so a charged attack is
434
457
  `hold(pad.button("west"))` on one action next to the plain press on
435
- another; a tap reads pressed for one task, enough for onPress.
458
+ another; a tap reads pressed for one task, enough for onPress. `tap`
459
+ and `doubleTap` time a press with the source's own `changedAt()` (the
460
+ `timeStamp` of the event behind each edge; keyboard keys and pad
461
+ buttons carry it), so a custom button source has to provide it to sit
462
+ under one, and they throw on one that does not (a pointer pulse, a
463
+ `hold`). A key lost to a blur is no tap.
436
464
  Consumers with the axes contract (`createAxes({ rotate: "vec2", zoom:
437
465
  "axis" }, { onNudge, onBegin, onEnd })`, what every camera control
438
466
  exposes as `.axes`) are connected by name with `input.drive(control.axes,
@@ -479,8 +507,8 @@ that reads exactly like Solid fallout.
479
507
  forward], forward = -y), `rise` axis (up). The runtime-free
480
508
  half (`createInputMap`, `createAxes`, `keyboard`, the processors) is
481
509
  importable as `@solidrt/core/input` for headless checks;
482
- checks/input-map-check.ts runs it on the bare flux binary, and
483
- checks/input-gamepad-check.ts drives the gamepad device and the join
510
+ tests/input-map.test.ts runs it on the bare flux binary, and
511
+ tests/input-gamepad.test.ts drives the gamepad device and the join
484
512
  order over a signal of pad snapshots (input-gamepad-device.ts is
485
513
  runtime-free; input-gamepad.ts hands it gamepads()). The pointer
486
514
  feed's discrete pulses (`doubleTap`, `longPress`) bound to an AXIS
@@ -494,7 +522,7 @@ that reads exactly like Solid fallout.
494
522
  on, and the shot blender (`createShotBlend`: several cameras, a
495
523
  priority, a timed blend, over the packages' `createShots`) - is
496
524
  `@solidrt/core/camera-control`, pure, pinned by
497
- checks/camera-control-check.ts; okf/design/camera-controls.md is the
525
+ tests/camera-control.test.ts; okf/design/camera-controls.md is the
498
526
  pipeline the 2d and 3d controls run over it.
499
527
 
500
528
  - Reactivity is SolidJS 2.0 (`@solidjs/signals`), NOT Solid 1.x. `createSignal`
@@ -581,7 +609,14 @@ that reads exactly like Solid fallout.
581
609
  apply, return it so the loop runs only while the condition holds); `rate` is the
582
610
  display's nominal refresh rate in Hz, which a fixed-timestep loop needs
583
611
  (see @solidrt/cli agents/debugging.md on stepping); `requestAnimationFrame`
584
- exists as a web-standard one-shot but is not the preferred driver. A JS
612
+ exists as a web-standard one-shot but is not the preferred driver.
613
+ `onFrame(fn, { demand: false })` is the same hook without the standing
614
+ frame request: it runs on every frame the runtime delivers and the app
615
+ presents only when the body writes, for a loop that follows time but
616
+ seldom changes the picture. `frameTime()` is the current frame's `tick`
617
+ read from anywhere (not reactive, requests nothing): the app's own
618
+ clock for code with no tick in hand, where `performance.now()` would
619
+ ignore the dev clock, `srt render` and tests. A JS
585
620
  tween loop or an animation library pushing interpolated values through
586
621
  signals is the single most expensive mistake available here - read
587
622
  agents/performance.md before writing either.
@@ -612,6 +647,54 @@ that reads exactly like Solid fallout.
612
647
  transparent pixels for CPU work, and `encodeImage` converts back to
613
648
  straight for the file.
614
649
 
650
+ - Compressed textures, for anything that holds many or large textures (a
651
+ 3D model, a tile set): ship ONE KTX2 file per image and let each device
652
+ turn it into the block format its GPU samples, a quarter of the memory
653
+ of the decoded image. `encodeTexture(decodeImage(bytes), { codec, srgb,
654
+ mipmap })` makes the file at bake time (slow, never at load);
655
+ `transcodeTexture(ktx2)` -> `createTexture(t.data, t.width, t.height,
656
+ { format: t.format, mipmap: t.mipmap })` loads it, picking the
657
+ device's format by itself. Start a transcode per texture and await
658
+ them together: the runtime runs four at a time. Such a texture is
659
+ sample-only (no upload into it, no readback, no generated mips: the
660
+ chain comes from the file). Block compression is lossy and damages
661
+ sharp art and text, so it is not for UI images. The runtime-free half
662
+ is `@solidrt/core/textures`, importable under bun by a bake script or
663
+ an extension's pure modules: `isKtx2(bytes)` tells such a file from a
664
+ PNG or JPEG, and `textureSettings(packageJson)` reads how an app
665
+ wants its textures compressed, declared under `solidrt.textures` in
666
+ its package.json, so every bake, whoever wrote it, honors the same
667
+ settings. Three levels, the narrower one winning: `compress` for the
668
+ app (on unless it says otherwise), `compress` / `codec` / `quality`
669
+ per kind of texture, and `files`, a list of entries that each name
670
+ files by glob pattern (`match`, a path from the project root) and set
671
+ the same three for them, a later entry winning over an earlier one.
672
+ The kinds say what the texels ARE, not what draws them:
673
+ `color` (sRGB color the eye sees), `normal` (directions), `data`
674
+ (other linear values that tolerate a small error: a mask, a
675
+ roughness). An extension maps its own uses onto them. Values that
676
+ must come back exact (a lookup table, a distance field) are none of
677
+ the three and are not block compressed: an entry with
678
+ `"compress": false` keeps them as their file. A bake asks per
679
+ texture, and checks the patterns before it encodes anything, passing
680
+ the runtime's matcher and scan in (the entry itself has no runtime):
681
+
682
+ ```ts
683
+ import { glob } from "flux:fs"
684
+ import { matchesGlob } from "flux:path"
685
+ import { textureSettings, textureSettingFor, unmatchedTextureFiles } from "@solidrt/core/textures"
686
+
687
+ let settings = textureSettings(await file("package.json").json())
688
+ let unmatched = await unmatchedTextureFiles(settings, (pattern) => glob(pattern, { cwd: "." }))
689
+ if (unmatched.length > 0) throw new Error("No file matches " + unmatched.join(", "))
690
+ let setting = textureSettingFor(settings, "color", { file: "assets/tiles/ground.png" }, matchesGlob)
691
+ if (setting.compress) bytes = await encodeTexture(decodeImage(bytes), { codec: setting.codec, quality: setting.quality, srgb: true, mipmap: true })
692
+ ```
693
+
694
+ A texture embedded in another file (an image inside a .glb) is
695
+ `{ file, name }`, and a pattern picks it as `<file>#<name>`;
696
+ `unmatchedTextureNames` reports the names a file does not have.
697
+
615
698
  ## Minimal app, core primitives only (verified to render)
616
699
 
617
700
  ```tsx
package/README.md CHANGED
@@ -35,7 +35,7 @@ render(() => <App />)
35
35
  Run the app:
36
36
 
37
37
  ```sh
38
- bunx srt run src/index.tsx
38
+ bun run srt run src/index.tsx
39
39
  ```
40
40
 
41
41
  Optionally, create a `tsconfig.json` to enable type recognition for SolidRT elements:
@@ -92,7 +92,12 @@ Rules, in order of leverage:
92
92
  while a condition holds is an effect returning it:
93
93
  `createEffect(() => active(), on => { if (!on) return; return onFrame(...) })`
94
94
  - the apply phase has no owner, so onFrame registers nothing there and
95
- the returned cleanup is the handle the effect disposes.
95
+ the returned cleanup is the handle the effect disposes. A loop that
96
+ follows time but changes the picture only now and then (a clip stepping
97
+ at 8 fps, a clock face) registers with `onFrame(fn, { demand: false })`:
98
+ it is called on every frame the runtime runs, requests none itself, and
99
+ the app presents only when the body writes something. `frameTime()`
100
+ reads the current frame's tick with no callback at all.
96
101
  5. repaintBoundary works like Flutter's: transforms and opacity on the
97
102
  boundary node itself (or any ancestor) are hoisted out of the cache and
98
103
  applied at composite time, so animating x/y/scale/rotate/opacity of a
@@ -144,7 +149,7 @@ Rules, in order of leverage:
144
149
  anything needing what is behind it does not - and is split from content
145
150
  invalidation, so a params-only change re-runs the pass against the cached
146
151
  snapshot instead of re-rasterizing. A window shader's output is invisible
147
- to get_snapshot and every other MCP tool; `bunx srt render` is the only
152
+ to get_snapshot and every other MCP tool; `bun run srt render` is the only
148
153
  way to see it (see @solidrt/cli AGENTS.md).
149
154
  8. `flux:wasm` runs a pure interpreter (wasmi, no JIT), so temper browser
150
155
  expectations - but do not write it off for compute. A genuinely numeric
package/docs/index.md CHANGED
@@ -98,6 +98,15 @@ bubble the same way, starting at the focused node - or at the window root
98
98
  when nothing is focused, so `onKeyDown` on the window is where app-global
99
99
  shortcuts live.
100
100
 
101
+ Every input event carries `timeStamp`, in milliseconds: when the input
102
+ happened, not when the handler ran. Time a press, the gap between two taps
103
+ or a speed with it. A pointer move also carries `predicted`. Touch arrives
104
+ in batches, and when one is late the runtime bridges the frame with a
105
+ predicted position so a drag does not stall; that move has `predicted:
106
+ true`. Draw with it, but leave it out of anything you measure or keep, such
107
+ as a velocity or a stroke: if the finger had stopped, the next move reports
108
+ where it really is, with an earlier `timeStamp`.
109
+
101
110
  Text entry goes to the focused node's `onTextInput`. Focusing a field never
102
111
  raises the on-screen keyboard by itself - a tap on the field (or an explicit
103
112
  `startTextInput()`) does, and never while a physical keyboard is attached.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.63",
3
+ "version": "0.0.65",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -19,6 +19,7 @@
19
19
  "./microphone": "./src/microphone.ts",
20
20
  "./speech-recognition": "./src/speech-recognition.ts",
21
21
  "./text-input": "./src/text-input.ts",
22
+ "./textures": "./src/textures.ts",
22
23
  "./video": "./src/video.ts",
23
24
  "./jsx-runtime": "./jsx-runtime.d.ts",
24
25
  "./jsx-runtime-dev": "./jsx-runtime.d.ts"
@@ -32,11 +33,12 @@
32
33
  "AGENTS.md"
33
34
  ],
34
35
  "devDependencies": {
35
- "@solidrt/flux-types": "0.0.63"
36
+ "@solidrt/flux-types": "0.0.65",
37
+ "@solidrt/test": "0.0.65"
36
38
  },
37
39
  "peerDependencies": {
38
- "@solidjs/signals": "2.0.0-rc.9",
39
- "@solidjs/universal": "2.0.0-rc.9",
40
- "solid-js": "2.0.0-rc.9"
40
+ "@solidjs/signals": "2.0.0-rc.13",
41
+ "@solidjs/universal": "2.0.0-rc.13",
42
+ "solid-js": "2.0.0-rc.13"
41
43
  }
42
44
  }
@@ -308,7 +308,8 @@ export const BLEND_SECONDS = 0.5
308
308
 
309
309
  /** A shot's recording target: what its control drives. `camera()` is
310
310
  * the shot's latest full camera (the control's partial updates merged
311
- * over the initial one). */
311
+ * over the initial one; an undefined field is absent and keeps its
312
+ * value, as the real target's setCamera treats it). */
312
313
  export type ShotTarget<C> = { setCamera(update: Partial<C>): void; camera(): C }
313
314
 
314
315
  export type ShotBlendOptions = {
@@ -379,7 +380,13 @@ export function createShotBlend<C extends object>(push: (camera: C) => void, mix
379
380
  shots.set(name, s)
380
381
  return {
381
382
  setCamera(update) {
382
- Object.assign(s.latest, update)
383
+ // A component's props arrive as explicit undefineds for the
384
+ // unset ones; copying those would blank a field the mix then
385
+ // reads (NaN, or a missing vector).
386
+ for (let key of Object.keys(update) as (keyof C)[]) {
387
+ let value = update[key]
388
+ if (value !== undefined) s.latest[key] = value as C[keyof C]
389
+ }
383
390
  if (s === live && from === null) pushNow()
384
391
  },
385
392
  camera: () => ({ ...s.latest }),
package/src/core.ts CHANGED
@@ -38,9 +38,12 @@ function syncInterest(nodeId: number): void {
38
38
  /**
39
39
  * A pointer fact with no per-node fields: window coordinates plus pointer
40
40
  * identity and modifiers. `target` is the deepest node under the pointer
41
- * (0 when nothing is hit).
41
+ * (0 when nothing is hit). `timeStamp` and `predicted` are
42
+ * `PointerEvent`'s.
42
43
  */
43
44
  export interface GlobalPointerEvent {
45
+ timeStamp: number
46
+ predicted: boolean
44
47
  clientX: number
45
48
  clientY: number
46
49
  target: number
@@ -70,6 +73,8 @@ export function onPointerMove(fn: (e: GlobalPointerEvent) => void): () => void {
70
73
  if (globalMoveSubs.size === 1) {
71
74
  globalMoveUnsub = on("pointerMove", (raw: any) => {
72
75
  let e: GlobalPointerEvent = {
76
+ timeStamp: raw.timeStamp,
77
+ predicted: raw.predicted,
73
78
  clientX: raw.clientX,
74
79
  clientY: raw.clientY,
75
80
  target: raw.target,
package/src/double-tap.ts CHANGED
@@ -63,7 +63,7 @@ export function createTapSequence(options: TapSequenceOptions) {
63
63
  }
64
64
  return {
65
65
  down(e: PointerEvent) {
66
- let now = performance.now()
66
+ let now = e.timeStamp
67
67
  if (first) {
68
68
  if (first.upAt === null) {
69
69
  // A second finger while the first is down: neither is a tap.
@@ -88,7 +88,7 @@ export function createTapSequence(options: TapSequenceOptions) {
88
88
  },
89
89
  up(e: PointerEvent) {
90
90
  if (!first || first.id !== e.pointerId || first.upAt !== null) return
91
- let now = performance.now()
91
+ let now = e.timeStamp
92
92
  if (now - first.downAt > TAP_MAX_MS) {
93
93
  settle(false)
94
94
  return
package/src/gamepad.ts CHANGED
@@ -43,6 +43,33 @@ export interface GamepadState {
43
43
 
44
44
  let gamepadsAccessor: (() => (GamepadState | null)[]) | undefined
45
45
 
46
+ // When each pad button last changed: per slot, button name to the
47
+ // `timeStamp` of the "gamepads" event that pressed or released it. Kept
48
+ // here, where every snapshot passes once, because a reader of the
49
+ // accessor sees only the latest one.
50
+ let buttonTimes: Map<string, number>[] = []
51
+ let lastPads: (GamepadState | null)[] = []
52
+
53
+ function recordButtonTimes(pads: (GamepadState | null)[], at: number): void {
54
+ for (let slot = 0; slot < Math.max(pads.length, lastPads.length); slot++) {
55
+ let before = lastPads[slot]?.buttons ?? []
56
+ let after = pads[slot]?.buttons ?? []
57
+ let times = (buttonTimes[slot] ??= new Map())
58
+ for (let name of after) if (!before.includes(name)) times.set(name, at)
59
+ for (let name of before) if (!after.includes(name)) times.set(name, at)
60
+ }
61
+ lastPads = pads
62
+ }
63
+
64
+ /** When the button `name` of the pad in `slot` was last pressed or
65
+ * released, as the `timeStamp` (ms, the clock of `PointerEvent.timeStamp`)
66
+ * of the pad snapshot that carried the change; null when it never
67
+ * changed. Needs `gamepads()` to have been read: the snapshots are
68
+ * watched from its first read on. */
69
+ export function gamepadButtonChangedAt(slot: number, name: string): number | null {
70
+ return buttonTimes[slot]?.get(name) ?? null
71
+ }
72
+
46
73
  /**
47
74
  * Connected gamepads as a reactive accessor. Slots are stable web-style: a
48
75
  * pad keeps its index for its whole connection, disconnecting leaves a null
@@ -56,7 +83,11 @@ export function gamepads(): (GamepadState | null)[] {
56
83
  // and the first read is usually a tracked scope (see environment.ts).
57
84
  runWithOwner(null, () => {
58
85
  let [pads, setPads] = createSignal<(GamepadState | null)[]>([])
59
- on("gamepads", (e: { pads?: (GamepadState | null)[] }) => setPads(e.pads ?? []))
86
+ on("gamepads", (e: { pads?: (GamepadState | null)[]; timeStamp: number }) => {
87
+ let next = e.pads ?? []
88
+ recordButtonTimes(next, e.timeStamp)
89
+ setPads(next)
90
+ })
60
91
  gamepadsAccessor = pads
61
92
  })
62
93
  }
package/src/gpu.ts CHANGED
@@ -80,13 +80,16 @@ export type { FilterMode, WrapMode, TextureBinding, TextureBindings } from "flux
80
80
  // createCubeTexture, createMutableTexture), fixed for the id's lifetime like
81
81
  // the sampler state. "rgba8" (default), "rgba8-srgb" (decodes to linear
82
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.
83
+ // (Float32Array payload, nearest/texelFetch sampling only, no readback), the
84
+ // HDR image format "rgba16f" (Float32Array payload packed to half float,
85
+ // filterable) and the integer data-texture format "rgba32ui" (Uint32Array
86
+ // payload, a uvec4 per texel through a usampler2D, nearest/texelFetch only)
87
+ // and the block-compressed formats "etc2-rgba8"/"etc2-rgba8-srgb" (GLES 3.0
88
+ // core, every device) and "bc7-rgba8"/"bc7-rgba8-srgb" (desktops, behind
89
+ // limits.bc7Textures): raw 4x4 blocks, the full mip chain when mipmap is
90
+ // set, create-once and sample-only - see TextureFormat in flux:gpu for each
91
+ // format's contract. Draw targets (createDrawTarget, createCubeDrawTarget)
92
+ // take the renderable subset: "rgba8", "rgba8-srgb", "rgba16f".
90
93
  export type TextureFormatOptions = { format?: gpu.TextureFormat }
91
94
  export type { TextureFormat } from "flux:gpu"
92
95
 
@@ -245,7 +248,7 @@ export { captureSnapshot, readTexture } from "flux:gpu"
245
248
  * scope.
246
249
  */
247
250
  export function createTexture(
248
- data: Uint8Array | Float32Array,
251
+ data: Uint8Array | Float32Array | Uint32Array,
249
252
  width: number,
250
253
  height: number,
251
254
  opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
@@ -279,7 +282,7 @@ export function createTexture(
279
282
  * reactive owner, `{ autoFree: false }` or `destroyTexture` otherwise).
280
283
  */
281
284
  export function createCubeTexture(
282
- faces: (Uint8Array | Float32Array)[] | (Uint8Array | Float32Array)[][],
285
+ faces: (Uint8Array | Float32Array | Uint32Array)[] | (Uint8Array | Float32Array | Uint32Array)[][],
283
286
  size: number,
284
287
  opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
285
288
  ): gpu.TextureId {
@@ -300,7 +303,7 @@ export function createCubeTexture(
300
303
  * flux:gpu) yourself.
301
304
  */
302
305
  export function createMutableTexture(
303
- data: Uint8Array | Float32Array,
306
+ data: Uint8Array | Float32Array | Uint32Array,
304
307
  width: number,
305
308
  height: number,
306
309
  opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
@@ -757,4 +760,16 @@ export function beginBufferWrite(id: gpu.BufferId): Float32Array {
757
760
  */
758
761
  export function writeBuffer(id: gpu.BufferId, data: ArrayBuffer | ArrayBufferView, byteOffset?: number): void {
759
762
  gpu.writeBuffer(id, toUint8(data), byteOffset)
763
+ }
764
+
765
+ /**
766
+ * Hands an ordered instance buffer's full record set to the engine - the
767
+ * write-once form: the engine keeps the one CPU copy and sorts,
768
+ * republishes and count-dials from it on its own; the caller keeps
769
+ * nothing and partial rewrites are off the table. Hand off before the
770
+ * entry attaches over the buffer. See `transferRecords` in flux:gpu for
771
+ * the full contract.
772
+ */
773
+ export function transferRecords(id: gpu.BufferId, records: ArrayBuffer | ArrayBufferView): void {
774
+ gpu.transferRecords(id, toUint8(records))
760
775
  }
package/src/image.ts CHANGED
@@ -6,10 +6,18 @@
6
6
 
7
7
  import { createMemo, onCleanup } from "@solidjs/signals"
8
8
  import { decodeImage, type DecodedImage } from "flux:image"
9
+ import type { TextureCodec } from "flux:image"
9
10
  import { createTexture, destroyTexture, type TextureId } from "./gpu"
11
+ import type { TextureCodec as SettingsCodec } from "./textures"
10
12
 
11
- export { decodeImage, encodeImage } from "flux:image"
12
- export type { DecodedImage } from "flux:image"
13
+ export { decodeImage, encodeImage, encodeTexture, transcodeTexture } from "flux:image"
14
+ export type { DecodedImage, TextureCodec, TranscodedTexture } from "flux:image"
15
+
16
+ // The codecs the texture settings name (./textures, which takes no type
17
+ // from the runtime) are the encoder's: a codec added to one side alone
18
+ // stops this line from compiling.
19
+ type SameType<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false
20
+ const SETTINGS_NAME_THE_ENCODERS_CODECS: SameType<SettingsCodec, TextureCodec> = true
13
21
 
14
22
  export type ImageSource = string | Uint8Array
15
23
 
package/src/index.ts CHANGED
@@ -3,8 +3,8 @@ export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables,
3
3
  export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
4
4
  export { parseColor, mixColors, withAlpha, brightness, createLinearGradient, createRadialGradient } from "./color"
5
5
  export type { Gradient, GradientStop } from "./color"
6
- export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, onLink, onSuspend, onQuit, exit, background, registerProtocolHandler } from "./window"
7
- export type { BackEvent, LifecycleHandler } from "./window"
6
+ export { onFrame, frameTime, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, onLink, onSuspend, onQuit, exit, background, registerProtocolHandler } from "./window"
7
+ export type { BackEvent, FrameOptions, LifecycleHandler } from "./window"
8
8
  export { windowSize, safeArea, displayScale, windowFocused, keyboardHeight, lockPointer, pointerLocked } from "./window"
9
9
  export { env } from "./environment"
10
10
  export type { InputDevices, SystemTheme, Orientation, Visibility, Launch } from "./environment"
@@ -14,10 +14,12 @@ export { capabilities } from "./capabilities"
14
14
  export type { Capabilities, WindowSizeClass } from "./capabilities"
15
15
  export { createCubeTexture, createTexture } from "./gpu"
16
16
  export type { TextureId } from "./gpu"
17
- export { createImage, decodeImage, encodeImage } from "./image"
17
+ export { createImage, decodeImage, encodeImage, encodeTexture, transcodeTexture } from "./image"
18
18
  export { createCursor } from "./cursor"
19
19
  export type { CursorFrame, CursorId, CursorName, CursorOptions } from "./cursor"
20
- export type { DecodedImage, ImageSource } from "./image"
20
+ export type { DecodedImage, ImageSource, TextureCodec, TranscodedTexture } from "./image"
21
+ export { isKtx2, textureSettings, textureSettingFor, unmatchedTextureFiles, unmatchedTextureNames, DEFAULT_TEXTURE_SETTINGS } from "./textures"
22
+ export type { GlobMatcher, GlobScan, TextureFileRule, TextureKind, TextureSetting, TextureSettings, TextureSource } from "./textures"
21
23
  export { parseSvg, svg } from "./svg"
22
24
  export { Logo } from "./logo"
23
25
  export type { LogoProps } from "./logo"
@@ -67,6 +69,8 @@ export type {
67
69
  TextEvent,
68
70
  TextInputHints,
69
71
  PaintProps,
72
+ ShadowProps,
73
+ FilterProps,
70
74
  BlendMode,
71
75
  DrawStyle,
72
76
  StrokeCap,
@@ -1,8 +1,8 @@
1
- // A gamepad as input sources, over any accessor of pad snapshots: sticks
1
+ // A gamepad as input sources, over any reader of pad snapshots: sticks
2
2
  // and the dpad as vec2, triggers and shoulders as axes, any button as a
3
- // button, any raw axis by name. Runtime-free (the accessor is injected,
3
+ // button, any raw axis by name. Runtime-free (the reader is injected,
4
4
  // the public `gamepad` in input-gamepad.ts hands in core's gamepads()),
5
- // so the join logic below runs headless in the checks.
5
+ // so the join logic below runs headless in the tests.
6
6
  //
7
7
  // Every read is reactive when the accessor is (gamepads() is a signal),
8
8
  // so a stick moved while a control's frame loop is off wakes it. Sticks
@@ -33,7 +33,14 @@ const LISTEN_THRESHOLD = 0.5
33
33
  // saved binding restores onto the device the app hands in.
34
34
  const DEVICE = "gamepad"
35
35
 
36
- export type PadsAccessor = () => (GamepadState | null)[]
36
+ /** What a gamepad device reads: the pad snapshots by slot (reactive),
37
+ * and when a slot's button last changed (the `timeStamp` of the snapshot
38
+ * that pressed or released it, null when it never did), which is what a
39
+ * button source reports as its `changedAt`. */
40
+ export interface PadsReader {
41
+ pads(): (GamepadState | null)[]
42
+ buttonChangedAt(slot: number, name: string): number | null
43
+ }
37
44
 
38
45
  export interface GamepadDevice extends InputDevice {
39
46
  readonly name: "gamepad"
@@ -74,9 +81,10 @@ const DPAD_BUTTONS = ["dpadUp", "dpadDown", "dpadLeft", "dpadRight"]
74
81
 
75
82
  /**
76
83
  * The sources over `pads()` (the pads this device reads now, reactive),
77
- * with `slot()` as the device's slot and `who` as the label prefix.
84
+ * with `slot()` as the device's slot, `buttonChangedAt` as the time a
85
+ * button of the pads it reads last changed, and `who` as the label prefix.
78
86
  */
79
- export function createGamepadDevice(pads: () => GamepadState[], slot: () => number | undefined, who: string): GamepadDevice {
87
+ export function createGamepadDevice(pads: () => GamepadState[], slot: () => number | undefined, buttonChangedAt: (name: string) => number | null, who: string): GamepadDevice {
80
88
  let sumAxis = (read: (pad: GamepadState) => number) => (): number => {
81
89
  let sum = 0
82
90
  for (let pad of pads()) sum += read(pad)
@@ -145,7 +153,7 @@ export function createGamepadDevice(pads: () => GamepadState[], slot: () => numb
145
153
  if (typeof name !== "string" || name.length === 0) throw new Error(`gamepad.button: expected a button name, got ${String(name)}`)
146
154
  let source = buttons.get(name)
147
155
  if (!source) {
148
- source = { kind: "button", label: `${who} ${name}`, id: `${DEVICE}:button:${name}`, device: DEVICE, rate: anyButton(name) }
156
+ source = { kind: "button", label: `${who} ${name}`, id: `${DEVICE}:button:${name}`, device: DEVICE, rate: anyButton(name), changedAt: () => buttonChangedAt(name) }
149
157
  buttons.set(name, source)
150
158
  }
151
159
  return source
@@ -216,22 +224,34 @@ export function createGamepadDevice(pads: () => GamepadState[], slot: () => numb
216
224
  }
217
225
 
218
226
  /** One slot, or every connected pad when `slot` is undefined. */
219
- export function createGamepadSlot(read: PadsAccessor, slot: number | undefined): GamepadDevice {
227
+ export function createGamepadSlot(read: PadsReader, slot: number | undefined): GamepadDevice {
220
228
  if (slot !== undefined && !(Number.isInteger(slot) && slot >= 0)) throw new Error(`gamepad: slot must be a non-negative integer, got ${String(slot)}`)
221
229
  let pads = (): GamepadState[] => {
222
- let all = read()
230
+ let all = read.pads()
223
231
  if (slot === undefined) return all.filter((p): p is GamepadState => p !== null)
224
232
  let pad = all[slot]
225
233
  return pad ? [pad] : []
226
234
  }
227
- return createGamepadDevice(pads, () => slot, slot === undefined ? "gamepad" : `gamepad ${slot}`)
235
+ // Every pad: the latest change on any slot, as the button reads pressed
236
+ // while any of them holds it.
237
+ let changedAt = (name: string): number | null => {
238
+ if (slot !== undefined) return read.buttonChangedAt(slot, name)
239
+ let latest: number | null = null
240
+ let count = untrack(read.pads).length
241
+ for (let i = 0; i < count; i++) {
242
+ let at = read.buttonChangedAt(i, name)
243
+ if (at !== null && (latest === null || at > latest)) latest = at
244
+ }
245
+ return latest
246
+ }
247
+ return createGamepadDevice(pads, () => slot, changedAt, slot === undefined ? "gamepad" : `gamepad ${slot}`)
228
248
  }
229
249
 
230
250
  // Slots held by joining devices, shared so a pad joins once.
231
251
  let claimed = new Set<number>()
232
252
 
233
253
  /** A device that claims the next unclaimed pad to press any button. */
234
- export function createGamepadJoin(read: PadsAccessor): GamepadDevice {
254
+ export function createGamepadJoin(read: PadsReader): GamepadDevice {
235
255
  // ownedWrite: the claim lands inside an effect, the release in a cleanup.
236
256
  let [slot, setSlot] = createSignal<number | undefined>(undefined, { ownedWrite: true })
237
257
  let mine: number | undefined
@@ -239,7 +259,7 @@ export function createGamepadJoin(read: PadsAccessor): GamepadDevice {
239
259
  // the device only when there is none to tie it to.
240
260
  let dispose = createRoot(dispose => {
241
261
  createEffect(
242
- () => read(),
262
+ () => read.pads(),
243
263
  pads => {
244
264
  if (mine !== undefined) return
245
265
  for (let i = 0; i < pads.length; i++) {
@@ -263,8 +283,12 @@ export function createGamepadJoin(read: PadsAccessor): GamepadDevice {
263
283
  let pads = (): GamepadState[] => {
264
284
  let s = slot()
265
285
  if (s === undefined) return []
266
- let pad = read()[s]
286
+ let pad = read.pads()[s]
267
287
  return pad ? [pad] : []
268
288
  }
269
- return createGamepadDevice(pads, slot, "gamepad (joined)")
289
+ let changedAt = (name: string): number | null => {
290
+ let s = untrack(slot)
291
+ return s === undefined ? null : read.buttonChangedAt(s, name)
292
+ }
293
+ return createGamepadDevice(pads, slot, changedAt, "gamepad (joined)")
270
294
  }
@@ -6,14 +6,16 @@
6
6
  // split screen that seats players in pick-up order. The sources and the
7
7
  // join logic live in input-gamepad-device.ts, runtime-free.
8
8
 
9
- import { gamepads } from "./gamepad"
9
+ import { gamepadButtonChangedAt, gamepads } from "./gamepad"
10
10
  import { createGamepadJoin, createGamepadSlot } from "./input-gamepad-device"
11
- import type { GamepadDevice } from "./input-gamepad-device"
11
+ import type { GamepadDevice, PadsReader } from "./input-gamepad-device"
12
12
 
13
13
  export type { GamepadDevice } from "./input-gamepad-device"
14
14
 
15
+ let reader: PadsReader = { pads: gamepads, buttonChangedAt: gamepadButtonChangedAt }
16
+
15
17
  export function gamepad(slot?: number): GamepadDevice {
16
- return createGamepadSlot(gamepads, slot)
18
+ return createGamepadSlot(reader, slot)
17
19
  }
18
20
 
19
21
  /**
@@ -21,4 +23,4 @@ export function gamepad(slot?: number): GamepadDevice {
21
23
  * any button, then that pad for good (`slot` resolves, reactively). The
22
24
  * claim is released when the creating scope is disposed.
23
25
  */
24
- gamepad.next = (): GamepadDevice => createGamepadJoin(gamepads)
26
+ gamepad.next = (): GamepadDevice => createGamepadJoin(reader)