@solidrt/core 0.0.60 → 0.0.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/transform.ts CHANGED
@@ -2,6 +2,8 @@ import { onSettled } from "@solidjs/signals"
2
2
  import { on } from "srt:events"
3
3
  import type { PointerEvent } from "./types"
4
4
  import { arena, type ArenaOwner } from "./arena"
5
+ import { createVelocityTracker, flingVelocity } from "./velocity"
6
+ import type { Velocity } from "./velocity"
5
7
 
6
8
  // Focal travel or span change in logical pixels before the transform
7
9
  // activates; the same threshold createPan uses, so the two race fairly.
@@ -53,7 +55,10 @@ export interface TransformOptions {
53
55
  onTransformStart?: () => void
54
56
  /** Streams one delta per frame; compose them multiplicatively (scale) / additively (dx, dy, rotation). */
55
57
  onTransformMove?: (t: TransformDelta) => void
56
- onTransformEnd?: () => void
58
+ /** The last finger's lift, with the focal point's velocity at it in
59
+ * parent-frame pixels per second (velocity.ts; zero after a rest or
60
+ * under the fling minimum). */
61
+ onTransformEnd?: (velocity: Velocity) => void
57
62
  }
58
63
 
59
64
  // The merged transform recognizer: pan + pinch + rotate as ONE gesture over
@@ -147,6 +152,10 @@ export function createTransform(options: TransformOptions) {
147
152
  // immediately would bake a jolt into the next delta. A rebase frame
148
153
  // emits nothing.
149
154
  let rebase = false
155
+ // Parent-frame focal positions per delivering flush, for the lift's
156
+ // velocity; a rebase translates the history by the focal jump so a
157
+ // finger joining or leaving keeps it continuous.
158
+ let tracker = createVelocityTracker()
150
159
 
151
160
  // Centroids in all three frames, the span in window pixels (it feeds the
152
161
  // finger-travel gates), the pair angle in the parent frame.
@@ -193,6 +202,7 @@ export function createTransform(options: TransformOptions) {
193
202
  pinch = false
194
203
  dirty = false
195
204
  rebase = false
205
+ tracker.reset()
196
206
  }
197
207
  let cancel = reset
198
208
  let owner: ArenaOwner = { cancel }
@@ -204,8 +214,12 @@ export function createTransform(options: TransformOptions) {
204
214
  if (rebase) {
205
215
  // Anchor from same-age positions; emits nothing - an activation or
206
216
  // set change must not produce a jump delta. Motion that arrived in
207
- // the same batch folds into the anchor.
217
+ // the same batch folds into the anchor (and into the velocity
218
+ // history's shift, one frame the fit never sees).
219
+ let prev = ref
208
220
  ref = measure()
221
+ if (prev) tracker.shift(ref.px - prev.px, ref.py - prev.py)
222
+ tracker.push(ref.px, ref.py)
209
223
  spanBase = ref.span
210
224
  smoothSpan = ref.span
211
225
  rebase = false
@@ -215,6 +229,7 @@ export function createTransform(options: TransformOptions) {
215
229
  if (!dirty || !ref) return
216
230
  dirty = false
217
231
  let m = measure()
232
+ tracker.push(m.px, m.py)
218
233
  let prevSpan = smoothSpan
219
234
  smoothSpan += (m.span - smoothSpan) * SPAN_SMOOTH
220
235
  spanRate += (Math.abs(smoothSpan - prevSpan) - spanRate) * QUIET_SMOOTH
@@ -299,6 +314,7 @@ export function createTransform(options: TransformOptions) {
299
314
  pinch = Math.abs(m.span - ref.span) >= SLOP
300
315
  spanRate = pinch ? 1 : 0
301
316
  rebase = true
317
+ tracker.reset()
302
318
  options.onTransformStart?.()
303
319
  return
304
320
  }
@@ -310,12 +326,16 @@ export function createTransform(options: TransformOptions) {
310
326
  arena.release(e.pointerId, owner)
311
327
  pointers.delete(e.pointerId)
312
328
  if (pointers.size === 0) {
329
+ // The lift's own position is not measured (no terminator
330
+ // follows it); the fit over the delivered frames is the speed.
331
+ let velocity = flingVelocity(tracker.velocity())
313
332
  active = false
314
333
  ref = null
315
334
  pinch = false
316
335
  dirty = false
317
336
  rebase = false
318
- options.onTransformEnd?.()
337
+ tracker.reset()
338
+ options.onTransformEnd?.(velocity)
319
339
  } else {
320
340
  rebase = true
321
341
  pinch = pinch && pointers.size >= 2
package/src/types.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  /// <reference path="./runtime-modules.d.ts" />
3
3
 
4
4
  import type { Gradient } from "./color"
5
+ import type { CursorId, CursorName } from "./cursor"
5
6
  import type { ProgramId, TextureBindings, TextureId } from "flux:gpu"
6
7
  import type { TextInputHints } from "flux:rendertree"
7
8
  import type { Element } from "solid-js"
@@ -172,6 +173,11 @@ export interface PaintProps {
172
173
  strokeCap?: StrokeCap
173
174
  strokeJoin?: StrokeJoin
174
175
  strokeMiter?: number
176
+ /**
177
+ * Stroke thickness in logical px; default 1 (SVG's). 0 draws no stroke at
178
+ * all, like CSS `border-width: 0` - never a hairline - so a stroke-only
179
+ * shape (or a line) at 0 draws nothing.
180
+ */
175
181
  strokeWidth?: number
176
182
  }
177
183
 
@@ -380,6 +386,13 @@ export interface PointerProps {
380
386
  */
381
387
  focusable?: boolean
382
388
  pointerEvents?: "auto" | "none" | "all"
389
+ /**
390
+ * The mouse cursor while this element is hovered: a platform shape by its
391
+ * CSS name, `"none"` to hide it, or a createCursor handle for an image.
392
+ * The innermost hovered element that sets one wins; none on the path
393
+ * means the default arrow. Mouse and pen; touch shows no cursor.
394
+ */
395
+ cursor?: CursorName | CursorId
383
396
  }
384
397
 
385
398
  /**
@@ -643,17 +656,22 @@ export interface TransitionProps {
643
656
  */
644
657
  stagger?: number
645
658
  /**
646
- * Layout slide: when a layout moves this element (a row removed
647
- * above it, one inserted, a `<For>` reorder, a resize), it slides
648
- * from where it was painted to its new box on this motion instead
649
- * of jumping, and is hit-tested where it is drawn. A reflow
659
+ * Layout slide: when a layout moves or resizes this element (a row
660
+ * removed above it, one inserted, a `<For>` reorder, a card growing
661
+ * to fit more content, a window resize), it slides from the box it
662
+ * was painted at to its new box on this motion instead of jumping,
663
+ * position and size, and is painted, clipped, hit-tested and
664
+ * backdrop-filtered at the box it is drawn at. While its size is in
665
+ * flight its children are laid out against that box every frame
666
+ * (text re-wraps, rows re-flow), as under a CSS width transition; a
667
+ * child's own `layout` slide yields to the parent's motion. A reflow
650
668
  * mid-slide retargets (a spring keeps its velocity). Its first
651
669
  * layout, a reparent and a `display: none` toggle snap. The slide is
652
670
  * parent-relative: a parent that moves carries its children at
653
671
  * once, so declare it on every level that should glide. `true`
654
672
  * borrows the `all` entry's motion (an error without one); `all`
655
- * alone never slides. Position only today: a size change snaps.
656
- * Settles with onTransitionEnd property `"layout"`.
673
+ * alone never slides. Settles with onTransitionEnd property
674
+ * `"layout"`.
657
675
  */
658
676
  layout?: Omit<TransitionSpring, "from" | "exit"> | Omit<TransitionTween, "from" | "exit"> | TransitionShorthand | boolean
659
677
  } & {
@@ -804,7 +822,16 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
804
822
  * pixels beneath at that point in the frame, so treat it as a deliberate
805
823
  * panel, not a casual style. Inside a `repaintBoundary="snapshot"`
806
824
  * subtree it sees only that boundary's own offscreen content, never what
807
- * is behind the boundary (the same containment `blendMode` has there).
825
+ * is behind the boundary (the same containment `blendMode` has there),
826
+ * and under an ancestor with a `filter` it sees that ancestor's
827
+ * composited layer. An ancestor's `opacity` is not a containment: the
828
+ * glass keeps its blur through the fade (the runtime re-filters the
829
+ * window ahead of the fading group at the group's opacity), so a panel
830
+ * entering with `from: { opacity: 0 }` frosts in as it appears. That
831
+ * re-filter is an approximation: the group's sharp pixels then draw over
832
+ * the blurred ones at its opacity, so mid-fade the glass reads partly
833
+ * sharp, most at a low opacity; exact group blending would need the
834
+ * capture inside the group's layer, which the renderer cannot do.
808
835
  */
809
836
  backdropFilter?: FilterProps
810
837
  }
@@ -1052,6 +1079,16 @@ export interface TextProps extends PaintProps, PointerProps, TextRunProps {
1052
1079
  */
1053
1080
  export interface TextureProps extends PaintProps, PointerProps {
1054
1081
  src?: TextureId
1082
+ /**
1083
+ * Corner radius, as on a rect: the image is drawn as a rounded rect filled
1084
+ * with the texture, in one draw and with no clip, so a frosted or image
1085
+ * pane keeps rounded corners at the cost of the plain draw (a rounded
1086
+ * `clipRadius` around it costs a tiled GPU a third of the frame per ten
1087
+ * panes while the box is in flight). A single number rounds all four
1088
+ * corners; an array is [top-left, top-right, bottom-right, bottom-left].
1089
+ * Animatable in its single-number form, like a rect's.
1090
+ */
1091
+ radius?: number | [number, number, number, number]
1055
1092
  /**
1056
1093
  * How the texture's pixels map to the element box (CSS object-fit).
1057
1094
  * "fill" (default) stretches; "cover" and "none" crop; "contain" and
@@ -0,0 +1,134 @@
1
+ // The velocity tracker every recognizer reads at a lift: the pointer's
2
+ // speed from the positions of its last VELOCITY_WINDOW_MS as a
3
+ // least-squares line, not the last two samples. The final sample before a
4
+ // lift is often stationary, and frame batching makes the last delta a
5
+ // whole frame old, so a two-point estimate reads anything from zero to a
6
+ // jolt; a fit over the window reads the finger's actual speed. One
7
+ // estimator whatever recognizer asks (createPan, createTransform, the
8
+ // pointer feed), so the 2d camera and a control after it never re-derive
9
+ // their own.
10
+ //
11
+ // Samples are positions in whatever frame the caller measures (the
12
+ // recognizers push the node's parent frame, so the velocity applies 1:1
13
+ // as their deltas do; a swipe classifier pushes window pixels, the
14
+ // finger's own travel) with performance.now() at handler time, the
15
+ // precedent in transform.ts: PointerEvent carries no timestamp.
16
+
17
+ // Fit horizon: Flutter's and Android's VelocityTracker window.
18
+ const VELOCITY_WINDOW_MS = 100
19
+ // Largest speed reported, px/s (Flutter's kMaxFlingVelocity): a sensor
20
+ // glitch cannot launch the content across the world.
21
+ const VELOCITY_MAX = 8000
22
+ // A position unchanged for this long at the read means the finger rested
23
+ // before lifting: the velocity is zero, whatever the window still holds.
24
+ // Measured from the last sample that MOVED, not the newest sample's age:
25
+ // the runtime's resampler delivers an extrapolated step and its correction
26
+ // up to two frames after the finger stopped, and any same-position
27
+ // re-delivery must not restart the clock either.
28
+ const VELOCITY_REST_MS = 50
29
+ // Ring capacity: a 100 ms window holds 12 samples at 120 Hz; the rest is
30
+ // room for a burst of same-frame samples.
31
+ const VELOCITY_SAMPLES = 20
32
+ /** Release speeds under this (px/s, Flutter's kMinFlingVelocity) are not a
33
+ * fling: a recognizer delivers zero for them, so a finger that slowed to a
34
+ * stop leaves the content where it is. */
35
+ export const FLING_MIN_VELOCITY = 50
36
+
37
+ export type Velocity = { vx: number; vy: number }
38
+
39
+ export interface VelocityTracker {
40
+ /** Record a position; `at` defaults to performance.now(). */
41
+ push(x: number, y: number, at?: number): void
42
+ /** Translate every sample: a recognizer rebasing its reference point
43
+ * (a finger joining or leaving a transform) keeps the history
44
+ * continuous instead of dropping it. */
45
+ shift(dx: number, dy: number): void
46
+ reset(): void
47
+ /** The speed at `at` (default now), px/s per axis: a least-squares fit
48
+ * over the window, zero after a rest, clamped to VELOCITY_MAX. */
49
+ velocity(at?: number): Velocity
50
+ }
51
+
52
+ const ZERO: Velocity = { vx: 0, vy: 0 }
53
+
54
+ /** Zero under FLING_MIN_VELOCITY, else the velocity itself. */
55
+ export let flingVelocity = (v: Velocity): Velocity => (Math.hypot(v.vx, v.vy) < FLING_MIN_VELOCITY ? ZERO : v)
56
+
57
+ export function createVelocityTracker(): VelocityTracker {
58
+ let xs = new Float64Array(VELOCITY_SAMPLES)
59
+ let ys = new Float64Array(VELOCITY_SAMPLES)
60
+ let ts = new Float64Array(VELOCITY_SAMPLES)
61
+ // Ring: `count` samples ending at index `head - 1`.
62
+ let head = 0
63
+ let count = 0
64
+ // When the position last changed (the rest rule's clock).
65
+ let movedAt = -Infinity
66
+ return {
67
+ push(x, y, at = performance.now()) {
68
+ if (count === 0) movedAt = at
69
+ else {
70
+ let last = (head - 1 + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
71
+ if (xs[last] !== x || ys[last] !== y) movedAt = at
72
+ }
73
+ xs[head] = x
74
+ ys[head] = y
75
+ ts[head] = at
76
+ head = (head + 1) % VELOCITY_SAMPLES
77
+ if (count < VELOCITY_SAMPLES) count++
78
+ },
79
+ shift(dx, dy) {
80
+ for (let i = 0; i < count; i++) {
81
+ let k = (head - 1 - i + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
82
+ xs[k] = xs[k]! + dx
83
+ ys[k] = ys[k]! + dy
84
+ }
85
+ },
86
+ reset() {
87
+ head = 0
88
+ count = 0
89
+ },
90
+ velocity(at = performance.now()) {
91
+ if (count < 2) return ZERO
92
+ if (at - movedAt > VELOCITY_REST_MS) return ZERO
93
+ // Means over the window, then the slope of each axis against time.
94
+ let n = 0
95
+ let tm = 0
96
+ let xm = 0
97
+ let ym = 0
98
+ for (let i = 0; i < count; i++) {
99
+ let k = (head - 1 - i + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
100
+ if (at - ts[k]! > VELOCITY_WINDOW_MS) break
101
+ n++
102
+ tm += ts[k]!
103
+ xm += xs[k]!
104
+ ym += ys[k]!
105
+ }
106
+ if (n < 2) return ZERO
107
+ tm /= n
108
+ xm /= n
109
+ ym /= n
110
+ let tt = 0
111
+ let tx = 0
112
+ let ty = 0
113
+ for (let i = 0; i < n; i++) {
114
+ let k = (head - 1 - i + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
115
+ let dt = ts[k]! - tm
116
+ tt += dt * dt
117
+ tx += dt * (xs[k]! - xm)
118
+ ty += dt * (ys[k]! - ym)
119
+ }
120
+ // Every sample the same age (one batched frame): no slope to read.
121
+ if (tt === 0) return ZERO
122
+ // Slopes are px per ms; the result is px per second.
123
+ let vx = (tx / tt) * 1000
124
+ let vy = (ty / tt) * 1000
125
+ let speed = Math.hypot(vx, vy)
126
+ if (speed > VELOCITY_MAX) {
127
+ let f = VELOCITY_MAX / speed
128
+ vx *= f
129
+ vy *= f
130
+ }
131
+ return { vx, vy }
132
+ },
133
+ }
134
+ }
package/src/video.ts CHANGED
@@ -8,9 +8,11 @@
8
8
  // A plane player (`present: "plane"`, Android) has no texture: the platform
9
9
  // composites the picture fullscreen beneath the UI on the video's own clock,
10
10
  // and the UI simply draws over it. It exists from open until the owner is
11
- // disposed, and it streams: the source may be a URL, read as it plays. On a
12
- // platform without a plane the open fails (error() with kind "no-plane"),
13
- // and an app that runs everywhere falls back to a texture player itself.
11
+ // disposed. On a platform without a plane the open fails (error() with kind
12
+ // "no-plane"), and an app that runs everywhere falls back to a texture
13
+ // player itself. Both players stream (the source may be a URL, read as it
14
+ // plays) and both run off the frame loop: a texture player's frames are
15
+ // decoded on their own thread and shown at the UI's cadence.
14
16
  //
15
17
  // The imperative primitive lives in the `flux:video` module; import { open }
16
18
  // from "flux:video" for non-reactive use.
@@ -56,9 +58,8 @@ export type VideoStream = {
56
58
  /** Pause playback; the current frame stays displayed. */
57
59
  pause(): void
58
60
  /**
59
- * Seek to a time in seconds. A no-op while opening, on a texture player,
60
- * and on a source that cannot seek (see `seekable`). While paused the
61
- * target frame is shown.
61
+ * Seek to a time in seconds. A no-op while opening and on a source that
62
+ * cannot seek (see `seekable`). While paused the target frame is shown.
62
63
  */
63
64
  seek(seconds: number): void
64
65
  /** Whether `seek` does anything (plain read, not a signal). */
package/src/window.ts CHANGED
@@ -169,15 +169,22 @@ let refreshRate = 60
169
169
 
170
170
  /**
171
171
  * Calls `fn` before every frame is painted: `tick` is the time in ms, `frame` is
172
- * the present count, and `rate` is the current refresh rate in Hz. `tick` is paced
173
- * by the runtime (one refresh period per present, slow-corrected toward the wall
174
- * clock) so animations driven off it stay smooth even when swap-return times
175
- * jitter, and it is continuous across hot reloads: every tick an instance
176
- * sees is on one timebase, so dt is well-defined from the second call on.
177
- * Timers freeze together with frame callbacks under the dev tools' clock
178
- * control, but measure their delays against the wall clock, not this paced
179
- * timeline. performance.now() is not on it either: it is real elapsed time
180
- * for measuring work; Date.now() is calendar time.
172
+ * the present count, and `rate` is the current refresh rate in Hz. `tick`
173
+ * advances by the display refreshes since the previous frame, as the runtime
174
+ * counts them: exactly one refresh period per frame while the app keeps the
175
+ * refresh rate, and whole multiples of it when a frame takes longer (the
176
+ * requestAnimationFrame contract; a 21 fps app on a 90 Hz display sees
177
+ * 44 and 56 ms deltas, because that is how long each frame was on screen).
178
+ * Deltas are therefore honest and never lag the wall clock; an app that
179
+ * wants motion smoother than its own frame rate steps its simulation at a
180
+ * fixed rate or averages the deltas itself. A gap over half a second (the
181
+ * app was backgrounded) advances one period, not the gap. The timebase is
182
+ * continuous across hot reloads: every tick an instance sees is on one
183
+ * timebase, so dt is well-defined from the second call on. Timers freeze
184
+ * together with frame callbacks under the dev tools' clock control, but
185
+ * measure their delays against the wall clock, not this frame timeline.
186
+ * performance.now() is not on it either: it is real elapsed time for
187
+ * measuring work; Date.now() is calendar time.
181
188
  * Returns a cleanup function; also auto-cleans within an owned scope. Outside
182
189
  * one - an effect's apply phase, an event handler - the returned cleanup is
183
190
  * the only handle, so return it from the apply (`return onFrame(...)`) to run