@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.
@@ -17,7 +17,9 @@
17
17
  //
18
18
  // Held state is a count behind a signal, so a source's rate is reactive
19
19
  // (a held key wakes a control's frame loop) and a blur - the up that never
20
- // arrives once focus has left - clears it. Composites clamp nothing: the
20
+ // arrives once focus has left - clears it. A source also reports when its
21
+ // keys last changed (the key event's timeStamp; nothing after a blur,
22
+ // which is no release by the player). Composites clamp nothing: the
21
23
  // map's combination does (W and D together read [1, -1] before the
22
24
  // vec2 clamp to unit length, so diagonals walk at the same speed).
23
25
  //
@@ -60,9 +62,11 @@ let matches = (event: KeyEvent, key: string): boolean => {
60
62
  return key.length === 4 && key.startsWith("Key") && event.key.toLowerCase() === key[3]!.toLowerCase()
61
63
  }
62
64
 
63
- // A set of specs with a reactive "how many are held" count.
65
+ // A set of specs with a reactive "how many are held" count, and the time
66
+ // of the key event that last changed which are held.
64
67
  function held(specs: Spec[]) {
65
68
  let down = new Set<string>()
69
+ let changedAt: number | null = null
66
70
  // ownedWrite: a map forwards key events from wherever its handlers sit.
67
71
  let [count, setCount] = createSignal(0, { ownedWrite: true })
68
72
  return {
@@ -72,14 +76,18 @@ function held(specs: Spec[]) {
72
76
  // on its most specific matches (a repeat under a newly held Shift
73
77
  // moves "Tab" to "Shift+Tab" and back), an up frees them all.
74
78
  let onKey = specs.filter(s => matches(event, s.key))
79
+ let before = [...down].join()
75
80
  for (let s of onKey) down.delete(s.text)
76
81
  if (isDown) for (let s of mostSpecific(onKey, s => s.mods, event)) down.add(s.text)
82
+ if ([...down].join() !== before) changedAt = event.timeStamp
77
83
  setCount(down.size)
78
84
  },
79
85
  blur() {
86
+ if (down.size > 0) changedAt = null
80
87
  down.clear()
81
88
  setCount(0)
82
89
  },
90
+ changedAt: () => changedAt,
83
91
  has(text: string) {
84
92
  return down.has(text)
85
93
  },
@@ -95,6 +103,7 @@ function key(spec: string): InputSource<"button"> {
95
103
  id: `${DEVICE}:key:${spec}`,
96
104
  device: DEVICE,
97
105
  rate: () => state.count() > 0,
106
+ changedAt: state.changedAt,
98
107
  key: state.key,
99
108
  blur: state.blur,
100
109
  }
package/src/input-map.ts CHANGED
@@ -76,7 +76,8 @@ export type DeviceName = "keyboard" | "gamepad" | "pointer"
76
76
  * One device control as an input source. `rate` reads its current value
77
77
  * (reactive); `deltas` subscribes a sink to the amounts a gesture
78
78
  * produces; `key`/`blur` receive the key events a map forwards
79
- * (InputMap.handlers). A source may carry any subset.
79
+ * (InputMap.handlers); `changedAt` says when the rate last changed. A
80
+ * source may carry any subset.
80
81
  */
81
82
  export interface InputSource<K extends ActionKind = ActionKind> {
82
83
  readonly kind: K
@@ -90,6 +91,16 @@ export interface InputSource<K extends ActionKind = ActionKind> {
90
91
  * may leave it out and never counts as the active device. */
91
92
  readonly device?: DeviceName
92
93
  rate?: () => ActionValue<K>
94
+ /**
95
+ * When `rate` last changed: the `timeStamp` (ms) of the input event that
96
+ * changed it, or null when the value was dropped and not let go by the
97
+ * player (a blur releases every held key with no event), and before any
98
+ * change. What `tap` and `doubleTap` measure with, so a button source
99
+ * has to carry it to sit under one: the time travels with the value,
100
+ * since whoever sees the change sees it after the event is gone. Not
101
+ * reactive.
102
+ */
103
+ changedAt?: () => number | null
93
104
  deltas?: (sink: DeltaSink) => () => void
94
105
  key?: (event: KeyEvent, down: boolean) => void
95
106
  blur?: () => void
@@ -454,7 +454,7 @@ export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed
454
454
  if (downs.size === 1) {
455
455
  opening = { id: e.pointerId, x: e.clientX, y: e.clientY, mods: mods(e), alone: true }
456
456
  finger.reset()
457
- finger.push(e.clientX, e.clientY)
457
+ finger.push(e.clientX, e.clientY, e.timeStamp)
458
458
  } else if (opening) opening.alone = false
459
459
  }
460
460
  },
@@ -465,7 +465,7 @@ export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed
465
465
  transform.handlers.onPointerMove(e)
466
466
  hold.move(e)
467
467
  taps.move(e)
468
- if (opening && opening.id === e.pointerId) finger.push(e.clientX, e.clientY)
468
+ if (opening && opening.id === e.pointerId && !e.predicted) finger.push(e.clientX, e.clientY, e.timeStamp)
469
469
  },
470
470
  onPointerUp(e) {
471
471
  if (downs.get(e.pointerId) !== (e.button ?? PRIMARY)) return
@@ -475,7 +475,7 @@ export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed
475
475
  taps.up(e)
476
476
  if (opening && opening.id === e.pointerId) {
477
477
  if (opening.alone && downs.size === 0) {
478
- let direction: SwipeDirection | null = classifySwipe(finger.velocity(), { dx: e.clientX - opening.x, dy: e.clientY - opening.y })
478
+ let direction: SwipeDirection | null = classifySwipe(finger.velocity(e.timeStamp), { dx: e.clientX - opening.x, dy: e.clientY - opening.y })
479
479
  if (direction) swipe.fire(opening.mods, direction)
480
480
  }
481
481
  opening = null
@@ -6,11 +6,15 @@
6
6
  // the plain press on another action, and the consumer reads one bool.
7
7
  //
8
8
  // Timing: an interaction watches its source's rate under a root of its
9
- // own and keeps time with setTimeout, so it works headless and needs no
10
- // frame loop; the root is disposed with the creating scope (a component
11
- // body) and lives for the session without one. `tap` and `doubleTap`
12
- // are edges, not levels: they read pressed for one task, long enough
13
- // for a map's onPress to fire, then release on their own.
9
+ // own, so it works headless and needs no frame loop; the root is disposed
10
+ // with the creating scope (a component body) and lives for the session
11
+ // without one. `hold` keeps time with setTimeout. `tap` and `doubleTap`
12
+ // measure between the source's edges with the source's own time
13
+ // (`changedAt`, the timeStamp of the input event behind each edge): they
14
+ // see an edge at the flush, after the event is gone, so they read no
15
+ // clock of their own, and a source without `changedAt` cannot sit under
16
+ // them. They are edges, not levels: they read pressed for one task, long
17
+ // enough for a map's onPress to fire, then release on their own.
14
18
  //
15
19
  // Every processor composes the source's id (`invert(gamepad:leftStick)`,
16
20
  // `hold(400,keyboard:key:Space)`), so a saved binding restores through
@@ -49,6 +53,13 @@ let checkButtonSource = (what: string, source: InputSource): void => {
49
53
  if (source.kind !== "button") throw new Error(`${what}: "${source.label}" is a ${source.kind}, not a button`)
50
54
  }
51
55
 
56
+ let checkTimedSource = (what: string, source: InputSource): void => {
57
+ checkButtonSource(what, source)
58
+ if (typeof source.changedAt !== "function") {
59
+ throw new Error(`${what}: "${source.label}" does not say when it changes (no changedAt), so its presses cannot be timed`)
60
+ }
61
+ }
62
+
52
63
  let checkMs = (what: string, ms: number): void => {
53
64
  if (!Number.isFinite(ms) || ms < 0) throw new Error(`${what}: ms must be a non-negative number, got ${String(ms)}`)
54
65
  }
@@ -98,11 +109,13 @@ export function scale<K extends "axis" | "vec2">(source: InputSource<K>, factor:
98
109
  }
99
110
 
100
111
  // A button source derived from one button source's press and release
101
- // edges: `edge(down, now)` runs on each, with `set` for the derived
102
- // state and `later` for a timer that the disposal clears.
112
+ // edges: `edge(down, at)` runs on each, with `set` for the derived
113
+ // state and `later` for a timer that the disposal clears. `at` is the
114
+ // source's time for the edge (its changedAt), null when it has none: a
115
+ // source that carries no time, or a value that was dropped (a blur).
103
116
  type EdgeHooks = { set: (pressed: boolean) => void; later: (ms: number, run: () => void) => void; clearLater: () => void }
104
117
 
105
- function derived(source: InputSource<"button">, label: string, id: string, edge: (down: boolean, now: number, hooks: EdgeHooks) => void): InputSource<"button"> {
118
+ function derived(source: InputSource<"button">, label: string, id: string, edge: (down: boolean, at: number | null, hooks: EdgeHooks) => void): InputSource<"button"> {
106
119
  // ownedWrite: the edges land inside an effect and timers.
107
120
  let [pressed, setPressed] = createSignal(false, { ownedWrite: true })
108
121
  let timer: ReturnType<typeof setTimeout> | null = null
@@ -124,7 +137,7 @@ function derived(source: InputSource<"button">, label: string, id: string, edge:
124
137
  createEffect(
125
138
  () => source.rate!(),
126
139
  (down, prev) => {
127
- if (down !== prev) edge(down, performance.now(), hooks)
140
+ if (down !== prev) edge(down, source.changedAt?.() ?? null, hooks)
128
141
  },
129
142
  { defer: true },
130
143
  )
@@ -151,7 +164,7 @@ let pulse = (hooks: EdgeHooks): void => {
151
164
  export function hold(source: InputSource<"button">, ms = HOLD_MS): InputSource<"button"> {
152
165
  checkButtonSource("hold", source)
153
166
  checkMs("hold", ms)
154
- return derived(source, `${source.label} (hold ${ms} ms)`, `hold(${ms},${source.id})`, (down, _now, hooks) => {
167
+ return derived(source, `${source.label} (hold ${ms} ms)`, `hold(${ms},${source.id})`, (down, _at, hooks) => {
155
168
  if (down) hooks.later(ms, () => hooks.set(true))
156
169
  else {
157
170
  hooks.clearLater()
@@ -161,40 +174,55 @@ export function hold(source: InputSource<"button">, ms = HOLD_MS): InputSource<"
161
174
  }
162
175
 
163
176
  /** A press released within `ms` (default 200): pressed for one task on the
164
- * release, so onPress fires once per tap and a long press is not one. */
177
+ * release, so onPress fires once per tap and a long press is not one. A
178
+ * press the player did not let go (a blur dropped it) is no tap. The
179
+ * source has to say when it changes (`changedAt`). */
165
180
  export function tap(source: InputSource<"button">, ms = TAP_MS): InputSource<"button"> {
166
- checkButtonSource("tap", source)
181
+ checkTimedSource("tap", source)
167
182
  checkMs("tap", ms)
168
- let downAt = 0
169
- return derived(source, `${source.label} (tap)`, `tap(${ms},${source.id})`, (down, now, hooks) => {
170
- if (down) downAt = now
171
- else if (now - downAt <= ms) pulse(hooks)
183
+ let downAt: number | null = null
184
+ return derived(source, `${source.label} (tap)`, `tap(${ms},${source.id})`, (down, at, hooks) => {
185
+ if (down) downAt = at
186
+ else {
187
+ if (at !== null && downAt !== null && at - downAt <= ms) pulse(hooks)
188
+ downAt = null
189
+ }
172
190
  })
173
191
  }
174
192
 
175
193
  /** Two taps (each within `tapMs`, default 200) at most `gapMs` (default
176
- * 300) apart: pressed for one task on the second release. */
194
+ * 300) apart: pressed for one task on the second release. A press the
195
+ * player did not let go (a blur dropped it) is no tap and ends the pair.
196
+ * The source has to say when it changes (`changedAt`). */
177
197
  export function doubleTap(source: InputSource<"button">, gapMs = DOUBLE_TAP_GAP_MS, tapMs = TAP_MS): InputSource<"button"> {
178
- checkButtonSource("doubleTap", source)
198
+ checkTimedSource("doubleTap", source)
179
199
  checkMs("doubleTap", gapMs)
180
200
  checkMs("doubleTap", tapMs)
181
- let downAt = 0
201
+ let downAt: number | null = null
182
202
  let lastTap = -Infinity
183
- return derived(source, `${source.label} (double tap)`, `doubleTap(${gapMs},${tapMs},${source.id})`, (down, now, hooks) => {
203
+ return derived(source, `${source.label} (double tap)`, `doubleTap(${gapMs},${tapMs},${source.id})`, (down, at, hooks) => {
184
204
  if (down) {
185
- downAt = now
205
+ downAt = at
186
206
  return
187
207
  }
188
- if (now - downAt > tapMs) return
189
- if (now - lastTap <= gapMs) {
208
+ let pressedAt = downAt
209
+ downAt = null
210
+ if (at === null || pressedAt === null) {
211
+ lastTap = -Infinity
212
+ return
213
+ }
214
+ if (at - pressedAt > tapMs) return
215
+ if (at - lastTap <= gapMs) {
190
216
  lastTap = -Infinity
191
217
  pulse(hooks)
192
- } else lastTap = now
218
+ } else lastTap = at
193
219
  })
194
220
  }
195
221
 
196
222
  /** Pressed while every source is: Shift+click on one action, a two-button
197
- * combo on a pad. Key events reach each keyboard source among them. */
223
+ * combo on a pad. Key events reach each keyboard source among them. It
224
+ * changes when the last of its sources to change did (`changedAt`, when
225
+ * each of them carries one). */
198
226
  export function chord(...sources: InputSource<"button">[]): InputSource<"button"> {
199
227
  if (sources.length < 2) throw new Error("chord: needs at least two button sources")
200
228
  for (let s of sources) checkButtonSource("chord", s)
@@ -204,6 +232,18 @@ export function chord(...sources: InputSource<"button">[]): InputSource<"button"
204
232
  id: `chord(${sources.map(s => s.id).join(",")})`,
205
233
  device: sources[0]!.device,
206
234
  rate: () => sources.every(s => s.rate?.() === true),
235
+ changedAt: sources.every(s => s.changedAt)
236
+ ? () => {
237
+ let latest: number | null = null
238
+ for (let s of sources) {
239
+ let at = s.changedAt!()
240
+ // One source dropped (a blur): so was the chord.
241
+ if (at === null) return null
242
+ if (latest === null || at > latest) latest = at
243
+ }
244
+ return latest
245
+ }
246
+ : undefined,
207
247
  key: (event, down) => {
208
248
  for (let s of sources) s.key?.(event, down)
209
249
  },
package/src/pan.ts CHANGED
@@ -108,7 +108,7 @@ export function createPan(options: PanOptions) {
108
108
  armed = null
109
109
  origin = { x: e.parentX, y: e.parentY }
110
110
  tracker.reset()
111
- tracker.push(e.parentX, e.parentY)
111
+ if (!e.predicted) tracker.push(e.parentX, e.parentY, e.timeStamp)
112
112
  options.onPanStart?.()
113
113
  } else {
114
114
  // The arena is resolved against us; the drag belongs elsewhere.
@@ -117,7 +117,8 @@ export function createPan(options: PanOptions) {
117
117
  return
118
118
  }
119
119
  if (active === e.pointerId && origin) {
120
- tracker.push(e.parentX, e.parentY)
120
+ // The pan follows a predicted position; its velocity does not.
121
+ if (!e.predicted) tracker.push(e.parentX, e.parentY, e.timeStamp)
121
122
  options.onPanMove?.(e.parentX - origin.x, e.parentY - origin.y)
122
123
  origin = { x: e.parentX, y: e.parentY }
123
124
  }
@@ -126,7 +127,7 @@ export function createPan(options: PanOptions) {
126
127
  if (active === e.pointerId) {
127
128
  // The up is not a sample: it sits where the last move left the
128
129
  // pointer, and its age is what tells a rest from a flick.
129
- let velocity = flingVelocity(tracker.velocity())
130
+ let velocity = flingVelocity(tracker.velocity(e.timeStamp))
130
131
  reset()
131
132
  options.onPanEnd?.(velocity)
132
133
  } else if (armed === e.pointerId) {
package/src/renderer.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // createErrorBoundary comes from signals, not solid-js: solid-js marks it
2
2
  // @internal (an app writes <Errored>, a renderer reaches for the primitive)
3
- // and strips it from its .d.ts, so the solid-js re-export types as any.
3
+ // and leaves it out of its public types.
4
4
  import { createErrorBoundary, createRoot, onCleanup, NotReadyError, untrack } from "@solidjs/signals"
5
5
  import { createRenderer } from "@solidjs/universal"
6
6
  import type { Element } from "solid-js"
@@ -61,7 +61,8 @@ declare module "*.srtm" {
61
61
  // stream ("pointerMove"/"pointerDown"/... consumed by window.ts),
62
62
  // "pointerFrame" (the move-batch terminator: fires after all of a frame's
63
63
  // pointer moves have dispatched, every pointer the same age - multi-pointer
64
- // recognizers measure there), and "render" (the per-frame signal).
64
+ // recognizers measure there; its `timeStamp` is theirs), and "render" (the
65
+ // per-frame signal).
65
66
  declare module "srt:events" {
66
67
  export function on(event: string, callback: (data: any) => void): () => void
67
68
  export function once(event: string, callback: (data: any) => void): () => void
@@ -235,3 +236,69 @@ declare module "srt:apps" {
235
236
  declare module "srt:render" {
236
237
  export function renderFrame(): void
237
238
  }
239
+
240
+ // The engine verbs of an app test (lattice, dev client only), which
241
+ // @solidrt/test builds its surface on; tests import that, not this.
242
+ // The verbs work only in an engine a test host built (`srt test`) and throw
243
+ // anywhere else. App time there is frame / frameRate and nothing else: no
244
+ // frame runs unless one is asked for.
245
+ declare module "srt:test" {
246
+ /**
247
+ * Runs one frame: timers due by its time fire, then frame callbacks and
248
+ * the flush, then the draw if anything demanded one. Fulfills once the
249
+ * frame has run. One frame at a time: throws while the previous one is
250
+ * still pending.
251
+ */
252
+ export function frame(): Promise<void>
253
+ /** The frames per second this engine steps at (60 unless set). */
254
+ export function frameRate(): number
255
+ /** Another frame rate, a positive integer. Throws once a frame has run. */
256
+ export function setFrameRate(fps: number): void
257
+ /** App time as of the last frame, in milliseconds; 0 before the first. */
258
+ export function time(): number
259
+ /**
260
+ * Fulfills once the window's size has reached the engine, at once when
261
+ * it already has. No frame runs for it and no time passes: a mounted
262
+ * window builds its first frame on that size.
263
+ */
264
+ export function windowReady(): Promise<void>
265
+ /**
266
+ * Expands `events` (JSON text, the control API's `/input` event shape)
267
+ * into the steps that are sent and returns what passes before each:
268
+ * `[ms, frames]` per step, at least that much time and that many frames.
269
+ * Throws on an invalid event, before anything is sent. The steps are then
270
+ * sent with `inputStep`, in order.
271
+ */
272
+ export function inputPlan(events: string): [ms: number, frames: number][]
273
+ /**
274
+ * Sends step `index` of the current plan into the input pipeline: a
275
+ * down, an up, a key or a wheel is dispatched on arrival, ahead of the
276
+ * next frame; a move with that frame.
277
+ */
278
+ export function inputStep(index: number): void
279
+ /**
280
+ * Runs frames until the app is at rest: nothing in flight, no timer due,
281
+ * no frame demanded. Work in flight is waited for with no app time
282
+ * passing. Rejects once `maxMs` of app time have passed without rest,
283
+ * with what is left in the message.
284
+ */
285
+ export function settle(maxMs: number): Promise<void>
286
+ /**
287
+ * Delivers a link the way an OS-routed one arrives (the raw string, on
288
+ * the `link` event, ahead of the next frame). Returns whether anything
289
+ * listens for links.
290
+ */
291
+ export function link(link: string): boolean
292
+ /**
293
+ * Calls a debug command the app registered with `registerDebug`. `args`
294
+ * and the result are JSON text (null for no args). Throws on an unknown
295
+ * name, a throwing command and a result JSON cannot carry.
296
+ */
297
+ export function debug(name: string, args: string | null): string
298
+ /**
299
+ * The pixels a node's subtree paints: RGBA8, premultiplied, rows top to
300
+ * bottom, at the display scale. Drawn at once from the tree as it is; no
301
+ * frame runs and no app time passes.
302
+ */
303
+ export function capture(node: number): { width: number; height: number; data: Uint8Array }
304
+ }
package/src/swipe.ts CHANGED
@@ -80,8 +80,8 @@ export function createSwipe(options: SwipeOptions) {
80
80
  // The down's window position and the finger's window-px velocity, over
81
81
  // every event the wrapped pan sees.
82
82
  let down: { id: number; x: number; y: number } | null = null
83
- // The lift's window position, set just before the pan's up runs.
84
- let lift: { x: number; y: number } | null = null
83
+ // The lift's window position and time, set just before the pan's up runs.
84
+ let lift: { x: number; y: number; at: number } | null = null
85
85
  let finger = createVelocityTracker()
86
86
  let pan = createPan({
87
87
  get axis() {
@@ -92,7 +92,7 @@ export function createSwipe(options: SwipeOptions) {
92
92
  onPanEnd: velocity => {
93
93
  let direction: SwipeDirection | null = null
94
94
  if (down && lift) {
95
- direction = classifySwipe(finger.velocity(), { dx: lift.x - down.x, dy: lift.y - down.y }, directions())
95
+ direction = classifySwipe(finger.velocity(lift.at), { dx: lift.x - down.x, dy: lift.y - down.y }, directions())
96
96
  }
97
97
  if (direction) options.onSwipe?.(direction, velocity)
98
98
  options.onSwipeEnd?.(velocity)
@@ -104,16 +104,16 @@ export function createSwipe(options: SwipeOptions) {
104
104
  if (down === null && (e.button == null || e.button === 0)) {
105
105
  down = { id: e.pointerId, x: e.clientX, y: e.clientY }
106
106
  finger.reset()
107
- finger.push(e.clientX, e.clientY)
107
+ finger.push(e.clientX, e.clientY, e.timeStamp)
108
108
  }
109
109
  pan.handlers.onPointerDown(e)
110
110
  },
111
111
  onPointerMove: (e: PointerEvent) => {
112
- if (down && down.id === e.pointerId) finger.push(e.clientX, e.clientY)
112
+ if (down && down.id === e.pointerId && !e.predicted) finger.push(e.clientX, e.clientY, e.timeStamp)
113
113
  pan.handlers.onPointerMove(e)
114
114
  },
115
115
  onPointerUp: (e: PointerEvent) => {
116
- if (down && down.id === e.pointerId) lift = { x: e.clientX, y: e.clientY }
116
+ if (down && down.id === e.pointerId) lift = { x: e.clientX, y: e.clientY, at: e.timeStamp }
117
117
  pan.handlers.onPointerUp(e)
118
118
  if (down && down.id === e.pointerId) {
119
119
  down = null