@solidrt/core 0.0.64 → 0.0.66

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
@@ -92,7 +92,7 @@ real design decision, not an afterthought:
92
92
 
93
93
  ```sh
94
94
  bun add @solidrt/core # the renderer
95
- bun add -d @solidrt/cli # the `srt` tool (see its AGENTS.md)
95
+ bun add -d @solidrt/cli # the `sol` tool (see its AGENTS.md)
96
96
  ```
97
97
 
98
98
  Core primitives alone are enough to build a full app. The optional extensions
@@ -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, `sol 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, `sol 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 sol 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 sol 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
@@ -203,7 +208,7 @@ let n = await worker.crunch(bytes) // main keeps rendering meanwhil
203
208
  ```
204
209
 
205
210
  The bundler builds each such module as its own bundle and ships it with the
206
- app (dev pushes and `srt pack` alike). Rules: main may only `import type`
211
+ app (dev pushes and `sol pack` alike). Rules: main may only `import type`
207
212
  from an isolate module (a value import is a build error); arguments and
208
213
  results are copies (numbers, strings, byte buffers, arrays, plain objects -
209
214
  no functions, no class instances); the child has the non-gui `flux:*`
@@ -243,7 +248,7 @@ not infer it from the desktop number.
243
248
  - **Some paints cost a frame each on a tiled mobile GPU.** Measured on a
244
249
  mid-range 2020 tablet (Adreno 610, Impeller GLES) with ten panes sliding
245
250
  and resizing on a layout transition, each row by subtraction from the
246
- compositor's own present record (`srt android --census`), against a
251
+ compositor's own present record (`sol android --census`), against a
247
252
  frame that ran at 60 fps with a 13 ms GPU span:
248
253
  - a 3-stop linear-gradient fill per pane: +15 ms, 20 fps. A gradient is
249
254
  shaded per pixel per frame; the same tint as a flat `d-rect` costs
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.
@@ -167,7 +176,7 @@ animation and audio, not the persistence moment.
167
176
  A link from outside (a custom scheme link the OS routed to the app, or one
168
177
  a dev tool sent) arrives raw: `env.launchLink` is the one this run of the
169
178
  app was started with (null without; a hot reload starts at the location
170
- the app last reported, see `reportLocation` in `srt:dev`), `onLink` fires
179
+ the app last reported, see `reportLocation` in `sol:dev`), `onLink` fires
171
180
  for one arriving while the app runs. What a link names is the app's to decide, and it is untrusted input:
172
181
  validate before acting on any part of it. A router turns links into screens
173
182
  for you; without one, parse it yourself. The scheme a packed app answers to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.64",
3
+ "version": "0.0.66",
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.64"
36
+ "@solidrt/flux-types": "0.0.66",
37
+ "@solidrt/test": "0.0.66"
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
  }
package/src/camera.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import { createSignal, onCleanup } from "@solidjs/signals"
10
10
  import { listCameras, open } from "flux:camera"
11
11
  import type { TextureId } from "flux:gpu"
12
- import { on } from "srt:events"
12
+ import { on } from "sol:events"
13
13
 
14
14
  export type CameraFacing = "front" | "back" | "unknown"
15
15
 
package/src/core.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as tree from "flux:rendertree"
2
2
  import type { TextureId } from "flux:gpu"
3
3
  import { createSignal, getOwner, onCleanup } from "@solidjs/signals"
4
- import { on } from "srt:events"
4
+ import { on } from "sol:events"
5
5
 
6
6
  let handlers = new Map<number, Map<string, Function>>()
7
7
 
@@ -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
@@ -1,5 +1,5 @@
1
1
  import { createSignal, runWithOwner } from "@solidjs/signals"
2
- import { on } from "srt:events"
2
+ import { on } from "sol:events"
3
3
  import { onPointerMove } from "./core"
4
4
  import { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } from "./window"
5
5
 
@@ -16,7 +16,7 @@ import { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } fro
16
16
  // runs, which is usually inside a component or a computation (a memo's first
17
17
  // read of env.inputDevices, a HUD effect's first read of pointerLocked()).
18
18
  // Created there, the fact would belong to that reader: sticky events replay
19
- // their cached value synchronously on subscribe (srt:events' on()), so the
19
+ // their cached value synchronously on subscribe (sol:events' on()), so the
20
20
  // on() call writes the signal it just created inside the reader's owned
21
21
  // scope - a render-time write, which the guard rejects - and a root or a
22
22
  // subscription created there is disposed with the reader. The facts are
@@ -274,7 +274,7 @@ export let env = {
274
274
  },
275
275
  /**
276
276
  * The link this process was started with, or null: a custom scheme link
277
- * the OS routed to the app (Android intent data; `srt render --link`),
277
+ * the OS routed to the app (Android intent data; `sol render --link`),
278
278
  * raw, exactly as it arrived. What it names is the app's to decide, and
279
279
  * it is untrusted input: validate before acting on any part of it. A link
280
280
  * arriving while the app runs is an event instead: `onLink`. Fixed for the
package/src/gamepad.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createSignal, runWithOwner } from "@solidjs/signals"
2
- import { on } from "srt:events"
2
+ import { on } from "sol:events"
3
3
 
4
4
  // Gamepad State: a reactive mirror of the runtime's sticky "gamepads" event.
5
5
  //
@@ -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
@@ -84,11 +84,12 @@ export type { FilterMode, WrapMode, TextureBinding, TextureBindings } from "flux
84
84
  // HDR image format "rgba16f" (Float32Array payload packed to half float,
85
85
  // filterable) and the integer data-texture format "rgba32ui" (Uint32Array
86
86
  // payload, a uvec4 per texel through a usampler2D, nearest/texelFetch only)
87
- // - see TextureFormat in flux:gpu for each format's contract.
88
- // Draw targets (createDrawTarget, createCubeDrawTarget) take the renderable
89
- // subset: "rgba8", "rgba8-srgb", "rgba16f".
90
- // "etc2-rgba8" (compressed) is a reserved future value of the same
91
- // vocabulary.
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".
92
93
  export type TextureFormatOptions = { format?: gpu.TextureFormat }
93
94
  export type { TextureFormat } from "flux:gpu"
94
95
 
@@ -346,7 +347,7 @@ export function createMutableTexture(
346
347
  * `packed` as a keyword, so `vec4 packed = texture(...)` fails with a syntax
347
348
  * error that does not name the identifier - pick another name.
348
349
  *
349
- * A bad source is a runtime failure, not one `srt check` catches: the compile
350
+ * A bad source is a runtime failure, not one `sol check` catches: the compile
350
351
  * throws at this call, so a shader created in a component body takes the app
351
352
  * to the error window unless an `<Errored>` closer in the tree claims it.
352
353
  */
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"
@@ -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)