@solidrt/core 0.0.59 → 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.
@@ -0,0 +1,428 @@
1
+ // The pure half every camera control shares (okf/design/camera-controls.md):
2
+ // the ease, the framing of a followed point, and the lanes. Runtime-free,
3
+ // so the 2d and 3d controls import it and their headless checks run it
4
+ // on the bare flux binary.
5
+ //
6
+ // Ease: every self-driven motion (a glide, a damped wheel notch, a follow)
7
+ // closes the gap to its goal by the same exponential fraction per frame,
8
+ // 1 - e^(-rate * dt), never a fixed fraction, so it is frame-rate
9
+ // independent and reads the same in 2d and 3d.
10
+ //
11
+ // Framing (Cinemachine's Position Composer): a followed point is measured
12
+ // as an offset from the pivot in viewport fractions. Inside the dead zone
13
+ // the camera does not respond; in the band between the dead zone and the
14
+ // hard limits (the soft zone) it eases the point back toward the dead
15
+ // edge, damped per axis; beyond the hard limits the point is clamped
16
+ // inside at once, whatever the damping - the guarantee that a fast target
17
+ // never leaves the screen. Lookahead projects the point ahead along its
18
+ // velocity, the velocity estimated from successive points and smoothed,
19
+ // so a noisy input does not jitter the camera.
20
+ //
21
+ // Lanes: additive offsets summed on top of the pose at push time and
22
+ // never written into it, so pose() stays what the app set or the follow
23
+ // reached: a persistent screen offset (Babylon's targetScreenOffset,
24
+ // Godot's offset), and shakes - decaying oscillations that sum
25
+ // (Cinemachine Impulse, Phaser's shake). Both in viewport fractions; the
26
+ // control maps them to its own space at push.
27
+
28
+ import { createMemo, createSignal } from "@solidjs/signals"
29
+
30
+ export type Vec2 = [number, number]
31
+
32
+ /**
33
+ * A control's frame-loop gate: `active()` is reactive, true while the
34
+ * control's own motion (`busy()`: a glide, a follow, a shake, an eased
35
+ * return) or any of its axis rates (`rates()`) has work. `notify()` is
36
+ * called at the end of every entry that may start or stop a motion. The
37
+ * plain flag is the truth and the signal mirrors it: a signal read
38
+ * between flushes reports the value before the queued writes, so a
39
+ * control comparing against its own signal drops the write back to
40
+ * false once a true is queued (a check rig that never flushes mid-run
41
+ * shows it; a live app, which flushes every frame, hides it).
42
+ */
43
+ export function createActivity(busy: () => boolean, rates: () => boolean) {
44
+ // ownedWrite: entries run from component bodies and handlers alike.
45
+ let [motion, setMotion] = createSignal(false, { ownedWrite: true })
46
+ let on = false
47
+ let active = createMemo(() => motion() || rates())
48
+ return {
49
+ active,
50
+ notify() {
51
+ let now = busy()
52
+ if (now !== on) {
53
+ on = now
54
+ setMotion(now)
55
+ }
56
+ },
57
+ }
58
+ }
59
+
60
+ // E-foldings per second toward a goal: high enough that a wheel notch
61
+ // reads as one push, low enough to look smooth.
62
+ export const GLIDE_EASE = 9
63
+ // A motion lands - snaps to its goal and stops - once this fraction of
64
+ // its initial gap remains: under a pixel on any pose component a glide
65
+ // moves through at ordinary distances.
66
+ export const GLIDE_EPSILON = 0.001
67
+ // Follow damping, e-foldings per second (Godot's default
68
+ // position_smoothing_speed).
69
+ export const FOLLOW_EASE = 5
70
+ // A shake's default oscillation rate, cycles per second: a hand-held
71
+ // judder, the range Cinemachine's 6D shake presets sit in.
72
+ export const SHAKE_FREQUENCY = 12
73
+
74
+ /** The fraction of the remaining gap an ease at `rate` closes over dt. */
75
+ export let easeStep = (rate: number, dt: number): number => 1 - Math.exp(-rate * dt)
76
+
77
+ /** A zone's size as fractions of the viewport, centred on the pivot. */
78
+ export type Zone = { width: number; height: number }
79
+
80
+ /** How a follow chases its point (the framing stage). Every field is
81
+ * read where it applies, so a live options object changes it on the
82
+ * next update. */
83
+ export type FollowOptions = {
84
+ /** How long the follow takes to close in, as a multiple of the built-in
85
+ * settle time (1, the default): 2 trails twice as lazily, 0 snaps.
86
+ * Per axis as `{ x, y }` (2d) or `{ x, y, z }` (3d: right, up,
87
+ * forward) - a platformer's tight horizontal and lazy vertical. */
88
+ damping?: number | { x?: number; y?: number; z?: number }
89
+ /** The point roams inside it without moving the camera. Default 0. */
90
+ deadZone?: Zone
91
+ /** The point is clamped inside it at once; the band between the dead
92
+ * zone and this is where the damping applies. Default: none (the whole
93
+ * plane); Cinemachine's default for a game camera is 0.8 x 0.8. */
94
+ hardLimits?: Zone
95
+ /** Frame ahead of the point by `time` seconds of its velocity, the
96
+ * velocity smoothed over `smoothing` seconds (default 0: raw). */
97
+ lookahead?: { time: number; smoothing?: number }
98
+ }
99
+
100
+ /** A per-axis correction: how far the camera must move its view so the
101
+ * point returns toward the pivot, in the offset's units, and whether the
102
+ * framing has settled (nothing left to move that a frame would show). */
103
+ export type Framing = { x: number; y: number; settled: boolean }
104
+
105
+ let dampingOf = (d: FollowOptions["damping"], axis: "x" | "y" | "z"): number => {
106
+ if (d === undefined) return 1
107
+ if (typeof d === "number") return d
108
+ return d[axis] ?? 1
109
+ }
110
+
111
+ /** The ease rate of one follow axis, e-foldings per second (Infinity for
112
+ * damping 0: at once). */
113
+ export let followRate = (options: FollowOptions | undefined, axis: "x" | "y" | "z"): number => {
114
+ let d = dampingOf(options?.damping, axis)
115
+ return d <= 0 ? Infinity : FOLLOW_EASE / d
116
+ }
117
+
118
+ // One axis of the zones: the correction for an offset `o` from the pivot
119
+ // given the half-sizes of the dead zone and the hard limits, eased by k
120
+ // (the fraction of the soft overshoot closed this frame; 1 snaps).
121
+ let frameAxis = (o: number, dead: number, hard: number, k: number): number => {
122
+ let a = Math.abs(o)
123
+ if (a <= dead) return 0
124
+ let sign = o < 0 ? -1 : 1
125
+ let over = Math.min(a, hard) - dead
126
+ let beyond = a > hard ? a - hard : 0
127
+ return sign * (beyond + over * k)
128
+ }
129
+
130
+ /**
131
+ * Frame a followed point: `offset` is the point's position relative to
132
+ * the pivot in viewport fractions (x right, y down), the result the
133
+ * amount the view must move so the point comes back inside the zones,
134
+ * in the same units (subtract it from the point's screen position). The
135
+ * hard part applies whole; the soft part closes by the axis's ease over
136
+ * dt. `epsilon` (viewport fractions) is the remainder under which the
137
+ * soft part snaps and the framing reports settled, so a resting follow
138
+ * writes nothing.
139
+ */
140
+ export function frame(offset: Vec2, options: FollowOptions | undefined, dt: number, epsilon: number): Framing {
141
+ let dead = options?.deadZone
142
+ let hard = options?.hardLimits
143
+ let dw = (dead?.width ?? 0) / 2
144
+ let dh = (dead?.height ?? 0) / 2
145
+ let hw = hard ? Math.max(hard.width / 2, dw) : Infinity
146
+ let hh = hard ? Math.max(hard.height / 2, dh) : Infinity
147
+ let kx = easeStep(followRate(options, "x"), dt)
148
+ let ky = easeStep(followRate(options, "y"), dt)
149
+ let x = frameAxis(offset[0], dw, hw, kx)
150
+ let y = frameAxis(offset[1], dh, hh, ky)
151
+ // What the soft part would still leave after this frame: under epsilon
152
+ // the whole overshoot applies and the framing rests.
153
+ let restX = frameAxis(offset[0], dw, hw, 1)
154
+ let restY = frameAxis(offset[1], dh, hh, 1)
155
+ if (Math.hypot(restX - x, restY - y) < epsilon) return { x: restX, y: restY, settled: true }
156
+ return { x, y, settled: false }
157
+ }
158
+
159
+ /** Zone fractions must be within 0..1 (a zone wider than the viewport
160
+ * frames nothing); throws with the control's prefix. */
161
+ export function checkFollowOptions(prefix: string, options: FollowOptions | undefined): void {
162
+ if (!options) return
163
+ let fraction = (what: string, v: number | undefined) => {
164
+ if (v !== undefined && !(Number.isFinite(v) && v >= 0 && v <= 1)) throw new Error(`${prefix}: ${what} must be within 0..1, got ${v}`)
165
+ }
166
+ let nonNegative = (what: string, v: number | undefined) => {
167
+ if (v !== undefined && !(Number.isFinite(v) && v >= 0)) throw new Error(`${prefix}: ${what} must be a non-negative number, got ${v}`)
168
+ }
169
+ fraction("follow.deadZone.width", options.deadZone?.width)
170
+ fraction("follow.deadZone.height", options.deadZone?.height)
171
+ fraction("follow.hardLimits.width", options.hardLimits?.width)
172
+ fraction("follow.hardLimits.height", options.hardLimits?.height)
173
+ let d = options.damping
174
+ if (typeof d === "number") nonNegative("follow.damping", d)
175
+ else if (d) {
176
+ nonNegative("follow.damping.x", d.x)
177
+ nonNegative("follow.damping.y", d.y)
178
+ nonNegative("follow.damping.z", d.z)
179
+ }
180
+ nonNegative("follow.lookahead.time", options.lookahead?.time)
181
+ nonNegative("follow.lookahead.smoothing", options.lookahead?.smoothing)
182
+ }
183
+
184
+ /**
185
+ * The lookahead filter over a followed point of any dimension: feed it
186
+ * the point each frame and read the predicted point - the point plus
187
+ * `time` seconds of its velocity, the velocity estimated from successive
188
+ * points and smoothed over `smoothing` seconds (an exponential average).
189
+ * reset() forgets the velocity (a teleport, a new follow).
190
+ */
191
+ export function createLookahead() {
192
+ let prev: number[] | null = null
193
+ let velocity: number[] = []
194
+ return {
195
+ predict(point: ArrayLike<number>, options: FollowOptions["lookahead"], dt: number, out: number[]): number[] {
196
+ let n = point.length
197
+ if (prev === null || prev.length !== n) {
198
+ prev = Array.from(point)
199
+ velocity = new Array(n).fill(0)
200
+ }
201
+ if (dt > 0) {
202
+ let smoothing = options?.smoothing ?? 0
203
+ let k = smoothing > 0 ? easeStep(1 / smoothing, dt) : 1
204
+ for (let i = 0; i < n; i++) {
205
+ let v = (point[i]! - prev[i]!) / dt
206
+ velocity[i] = velocity[i]! + (v - velocity[i]!) * k
207
+ prev[i] = point[i]!
208
+ }
209
+ }
210
+ let time = options?.time ?? 0
211
+ for (let i = 0; i < n; i++) out[i] = point[i]! + velocity[i]! * time
212
+ return out
213
+ },
214
+ reset() {
215
+ prev = null
216
+ },
217
+ }
218
+ }
219
+
220
+ // A shake in flight: a damped oscillation along a direction, its amplitude
221
+ // falling linearly to zero over the duration.
222
+ type Shake = { strength: number; duration: number; frequency: number; dx: number; dy: number; phase: number; t: number }
223
+
224
+ /**
225
+ * The lanes: a persistent `offset` (viewport fractions, x right, y down)
226
+ * and shakes summed over it. `step(dt)` advances the shakes, `total()`
227
+ * is what a control adds to its pose at push, `active()` whether a shake
228
+ * is still running (the frame-loop gate's lane half).
229
+ */
230
+ export function createLanes() {
231
+ let shakes: Shake[] = []
232
+ let total: Vec2 = [0, 0]
233
+ return {
234
+ /** Start a shake: `strength` is its peak in viewport fractions,
235
+ * `duration` seconds, `frequency` cycles per second (default
236
+ * SHAKE_FREQUENCY). `direction` fixes its axis (a unit vector, y
237
+ * down); default random, so repeated hits read as a judder, not a
238
+ * metronome. Shakes sum. */
239
+ shake(strength: number, duration: number, opts?: { frequency?: number; direction?: Vec2 }) {
240
+ let dir = opts?.direction
241
+ let angle = Math.random() * 2 * Math.PI
242
+ let dx = dir ? dir[0] : Math.cos(angle)
243
+ let dy = dir ? dir[1] : Math.sin(angle)
244
+ let len = Math.hypot(dx, dy) || 1
245
+ shakes.push({ strength, duration, frequency: opts?.frequency ?? SHAKE_FREQUENCY, dx: dx / len, dy: dy / len, phase: dir ? Math.PI / 2 : Math.random() * 2 * Math.PI, t: 0 })
246
+ },
247
+ step(dt: number) {
248
+ if (shakes.length === 0) return
249
+ let kept: Shake[] = []
250
+ for (let s of shakes) {
251
+ s.t += dt
252
+ if (s.t < s.duration) kept.push(s)
253
+ }
254
+ shakes = kept
255
+ },
256
+ /** The summed lane offset now: `offset` plus every running shake. */
257
+ total(offset: Vec2 | undefined): Vec2 {
258
+ total[0] = offset?.[0] ?? 0
259
+ total[1] = offset?.[1] ?? 0
260
+ for (let s of shakes) {
261
+ let amplitude = s.strength * (1 - s.t / s.duration)
262
+ let wave = Math.sin(2 * Math.PI * s.frequency * s.t + s.phase) * amplitude
263
+ total[0] += s.dx * wave
264
+ total[1] += s.dy * wave
265
+ }
266
+ return total
267
+ },
268
+ active: () => shakes.length > 0,
269
+ clear() {
270
+ shakes = []
271
+ },
272
+ }
273
+ }
274
+
275
+ export type Lanes = ReturnType<typeof createLanes>
276
+
277
+ /** A lane offset must be a finite pair; a shake's strength and duration
278
+ * finite and positive. Throws with the control's prefix. */
279
+ export function checkShake(prefix: string, strength: number, duration: number, opts?: { frequency?: number; direction?: Vec2 }): void {
280
+ if (!(Number.isFinite(strength) && strength >= 0)) throw new Error(`${prefix}: shake strength must be a non-negative number, got ${strength}`)
281
+ if (!(Number.isFinite(duration) && duration > 0)) throw new Error(`${prefix}: shake duration must be a positive number of seconds, got ${duration}`)
282
+ if (opts?.frequency !== undefined && !(Number.isFinite(opts.frequency) && opts.frequency > 0)) throw new Error(`${prefix}: shake frequency must be a positive number, got ${opts.frequency}`)
283
+ if (opts?.direction !== undefined && !(Array.isArray(opts.direction) && opts.direction.length === 2 && Number.isFinite(opts.direction[0]) && Number.isFinite(opts.direction[1]))) {
284
+ throw new Error(`${prefix}: shake direction must be [dx, dy], got ${JSON.stringify(opts.direction)}`)
285
+ }
286
+ }
287
+
288
+ export function checkOffset(prefix: string, offset: Vec2 | undefined): void {
289
+ if (offset === undefined) return
290
+ if (!(Array.isArray(offset) && offset.length === 2 && Number.isFinite(offset[0]) && Number.isFinite(offset[1]))) throw new Error(`${prefix}: offset must be [x, y] viewport fractions, got ${JSON.stringify(offset)}`)
291
+ }
292
+
293
+ // ---- Shots and blends ----
294
+ //
295
+ // Cinemachine's architecture of a game camera, over the controls: several
296
+ // cameras (shots), each driven by its own control into a RECORDING target
297
+ // instead of the scene, a priority that says which is live, and a blend
298
+ // between the outgoing output and the new live shot over a fixed time.
299
+ // The blender owns the one push to the real target; a live shot's push
300
+ // goes straight through at rest, so a still camera costs no frames, and
301
+ // a blend runs on update(dt). A switch mid-blend starts from the output
302
+ // of that moment, so a quick back-and-forth never jumps.
303
+
304
+ // The default blend time, seconds: short enough for a game cut to read as
305
+ // deliberate, long enough to see (Cinemachine's default is 2 s, a
306
+ // cinematic pace; maps use a third of a second).
307
+ export const BLEND_SECONDS = 0.5
308
+
309
+ /** A shot's recording target: what its control drives. `camera()` is
310
+ * the shot's latest full camera (the control's partial updates merged
311
+ * over the initial one). */
312
+ export type ShotTarget<C> = { setCamera(update: Partial<C>): void; camera(): C }
313
+
314
+ export type ShotBlendOptions = {
315
+ /** The default blend time in seconds when the live shot changes
316
+ * (BLEND_SECONDS); 0 cuts. */
317
+ blend?: number
318
+ }
319
+
320
+ /**
321
+ * The generic blender: `push` writes the output camera to the real
322
+ * target, `mix(a, b, t)` interpolates two full cameras (the package
323
+ * decides the space: zoom in log space, ortho extents, ...), `initial`
324
+ * is the camera every shot starts from. A shot is live when it is the
325
+ * enabled one with the highest priority (the most recently enabled on a
326
+ * tie); with none enabled nothing is pushed. The blend eases in and out
327
+ * (smoothstep) over its time, and lands exactly.
328
+ */
329
+ export function createShotBlend<C extends object>(push: (camera: C) => void, mix: (a: C, b: C, t: number) => C, initial: C, options: ShotBlendOptions = {}) {
330
+ type Shot = { name: string; priority: number; latest: C; enabled: boolean; order: number }
331
+ let shots = new Map<string, Shot>()
332
+ let live: Shot | null = null
333
+ // The output as last pushed, the start of a blend when frozen.
334
+ let current: C = { ...initial }
335
+ let from: C | null = null
336
+ let progress = 0
337
+ let duration = 0
338
+ let order = 0
339
+ let activity = createActivity(() => from !== null, () => false)
340
+ let choose = (): Shot | null => {
341
+ let best: Shot | null = null
342
+ for (let s of shots.values()) {
343
+ if (!s.enabled) continue
344
+ if (best === null || s.priority > best.priority || (s.priority === best.priority && s.order > best.order)) best = s
345
+ }
346
+ return best
347
+ }
348
+ let smooth = (t: number) => t * t * (3 - 2 * t)
349
+ let pushNow = () => {
350
+ if (live === null) return
351
+ current = from === null ? { ...live.latest } : mix(from, live.latest, smooth(progress))
352
+ push(current)
353
+ }
354
+ let relive = (blend: number | undefined) => {
355
+ let seconds = blend ?? options.blend ?? BLEND_SECONDS
356
+ if (!(Number.isFinite(seconds) && seconds >= 0)) throw new Error(`createShotBlend: blend must be a non-negative number of seconds, got ${seconds}`)
357
+ let next = choose()
358
+ if (next === live) return
359
+ if (live !== null && next !== null && seconds > 0) {
360
+ from = { ...current }
361
+ progress = 0
362
+ duration = seconds
363
+ } else from = null
364
+ live = next
365
+ pushNow()
366
+ activity.notify()
367
+ }
368
+ let named = (name: string, what: string): Shot => {
369
+ let s = shots.get(name)
370
+ if (!s) throw new Error(`createShotBlend: ${what}("${name}"): no such shot (${[...shots.keys()].join(", ") || "none"})`)
371
+ return s
372
+ }
373
+ return {
374
+ /** Declare a shot; returns the target its control drives. */
375
+ shot(name: string, opts?: { priority?: number }): ShotTarget<C> {
376
+ if (typeof name !== "string" || name.length === 0) throw new Error("createShotBlend: a shot needs a name")
377
+ if (shots.has(name)) throw new Error(`createShotBlend: shot "${name}" exists`)
378
+ let s: Shot = { name, priority: opts?.priority ?? 0, latest: { ...initial }, enabled: false, order: 0 }
379
+ shots.set(name, s)
380
+ return {
381
+ setCamera(update) {
382
+ Object.assign(s.latest, update)
383
+ if (s === live && from === null) pushNow()
384
+ },
385
+ camera: () => ({ ...s.latest }),
386
+ }
387
+ },
388
+ /** Enable a shot: it goes live when its priority wins, blending from
389
+ * the current output over `blend` seconds (the default otherwise). */
390
+ activate(name: string, opts?: { blend?: number }) {
391
+ let s = named(name, "activate")
392
+ s.enabled = true
393
+ s.order = ++order
394
+ relive(opts?.blend)
395
+ },
396
+ /** Disable a shot: the next by priority goes live, blended. */
397
+ deactivate(name: string, opts?: { blend?: number }) {
398
+ named(name, "deactivate").enabled = false
399
+ relive(opts?.blend)
400
+ },
401
+ /** Forget a shot (an unmounted component): disabled first, so the
402
+ * next by priority goes live, blended; its name is free again. */
403
+ remove(name: string, opts?: { blend?: number }) {
404
+ named(name, "remove").enabled = false
405
+ relive(opts?.blend)
406
+ shots.delete(name)
407
+ },
408
+ /** The live shot's name, or null. */
409
+ live: () => (live === null ? null : live.name),
410
+ /** The output as last pushed (a fresh object). */
411
+ camera: (): C => ({ ...current }),
412
+ /** Reactive: whether a blend is in flight - the frame-loop gate. */
413
+ active: activity.active,
414
+ /** Advance a blend over dt seconds and push; returns whether the
415
+ * output changed. A live shot's own pushes need no update. */
416
+ update(dt: number): boolean {
417
+ if (!Number.isFinite(dt)) throw new Error(`createShotBlend: update dt must be a finite number, got ${dt}`)
418
+ if (from === null || dt <= 0) return false
419
+ progress = Math.min(1, progress + dt / duration)
420
+ pushNow()
421
+ if (progress >= 1) from = null
422
+ activity.notify()
423
+ return true
424
+ },
425
+ }
426
+ }
427
+
428
+ export type ShotBlend<C extends object> = ReturnType<typeof createShotBlend<C>>
package/src/cursor.ts ADDED
@@ -0,0 +1,113 @@
1
+ // Image cursors: decode once, register with the platform, reference from any
2
+ // element's `cursor` prop by handle. The named shapes need no setup
3
+ // (`cursor="pointer"`); this is the `url()` half of CSS `cursor` through the
4
+ // SolidRT lens: a handle created once rather than a URL resolved per element,
5
+ // since the platform holds the image and the app decodes it.
6
+
7
+ import { getOwner, onCleanup } from "@solidjs/signals"
8
+ import { decodeImage } from "flux:image"
9
+ import {
10
+ createCursor as registerCursor,
11
+ dropCursor,
12
+ type CursorFrame as NativeFrame,
13
+ type CursorImage as NativeImage,
14
+ } from "flux:rendertree"
15
+
16
+ /** A registered image cursor (createCursor), for an element's `cursor` prop. */
17
+ export type CursorId = number & { readonly __cursor: unique symbol }
18
+
19
+ /**
20
+ * The named cursors: the CSS `cursor` keywords the platform has a shape for,
21
+ * plus `"none"` to hide the cursor. Keywords without a platform shape
22
+ * (grab, zoom-in, ...) are image cursors here (createCursor).
23
+ */
24
+ export type CursorName =
25
+ | "default"
26
+ | "pointer"
27
+ | "text"
28
+ | "wait"
29
+ | "progress"
30
+ | "crosshair"
31
+ | "move"
32
+ | "not-allowed"
33
+ | "ns-resize"
34
+ | "ew-resize"
35
+ | "nesw-resize"
36
+ | "nwse-resize"
37
+ | "n-resize"
38
+ | "e-resize"
39
+ | "s-resize"
40
+ | "w-resize"
41
+ | "ne-resize"
42
+ | "nw-resize"
43
+ | "se-resize"
44
+ | "sw-resize"
45
+ | "none"
46
+
47
+ /** One frame of an animated cursor (see CursorOptions.frames). */
48
+ export interface CursorFrame {
49
+ /** The frame's encoded image (png, webp, ...). Every frame decodes to the same size. */
50
+ image: Uint8Array
51
+ /** HiDPI alternates keyed by scale: `{ 2: bytes }` must decode to exactly twice the image's size. */
52
+ alternates?: Record<number, Uint8Array>
53
+ /** How long the frame shows, in milliseconds. 0 holds it, which ends a one-shot animation. */
54
+ duration: number
55
+ }
56
+
57
+ export interface CursorOptions {
58
+ /** The cursor's encoded image (png, webp, ...). Use `frames` instead for an animated cursor. */
59
+ image?: Uint8Array
60
+ /** HiDPI alternates of `image` keyed by scale: `{ 2: bytes }` must decode to exactly twice its size. */
61
+ alternates?: Record<number, Uint8Array>
62
+ /** Frames of an animated cursor, played in order and looped (unless a frame's duration is 0). */
63
+ frames?: CursorFrame[]
64
+ /** The click point, in the 1x image's pixels. Default: the top-left corner. */
65
+ hotspot?: [number, number]
66
+ /** Pass false to keep the cursor past the owning scope and drop it yourself. */
67
+ autoFree?: boolean
68
+ }
69
+
70
+ /**
71
+ * Registers an image cursor and returns the handle an element's `cursor` prop
72
+ * takes: `<view cursor={id}>`. Synchronous, from bytes you hold (a
73
+ * `with { type: "binary" }` import, or a file read); the platform keeps the
74
+ * image, so one handle serves any number of elements. Supply a 2x alternate
75
+ * for HiDPI displays: without one the platform scales the 1x image. A 32x32
76
+ * base is the common size; keep it under 128x128, the ceiling shared across
77
+ * platforms. The cursor is dropped with the owning reactive scope; outside
78
+ * one (or with `autoFree: false`) it lives until the app ends.
79
+ */
80
+ export function createCursor(opts: CursorOptions): CursorId {
81
+ if (!opts || typeof opts !== "object") throw new Error("createCursor: expected an options object")
82
+ let hasImage = opts.image !== undefined
83
+ let hasFrames = opts.frames !== undefined
84
+ if (hasImage === hasFrames) throw new Error("createCursor: pass either image or frames")
85
+ let frames: CursorFrame[] = hasFrames ? opts.frames! : [{ image: opts.image!, alternates: opts.alternates, duration: 0 }]
86
+ if (frames.length === 0) throw new Error("createCursor: frames is empty")
87
+ let native = frames.map(decodeFrame)
88
+ let [hotX, hotY] = opts.hotspot ?? [0, 0]
89
+ let id = registerCursor(native, hotX, hotY) as CursorId
90
+ if (opts.autoFree !== false && getOwner()) onCleanup(() => dropCursor(id))
91
+ return id
92
+ }
93
+
94
+ // Decodes a frame's image and alternates to straight-alpha RGBA8 (what the
95
+ // platform's cursor surfaces take) and checks each alternate against its
96
+ // scale, so a mismatched asset fails here with the sizes named.
97
+ function decodeFrame(frame: CursorFrame): NativeFrame {
98
+ if (!(frame.image instanceof Uint8Array)) throw new Error("createCursor: image must be a Uint8Array")
99
+ if (!(Number.isInteger(frame.duration) && frame.duration >= 0))
100
+ throw new Error(`createCursor: duration must be a whole number of milliseconds, got ${frame.duration}`)
101
+ let base = decodeImage(frame.image, { alpha: "straight" })
102
+ let images: NativeImage[] = [base]
103
+ for (let [key, bytes] of Object.entries(frame.alternates ?? {})) {
104
+ let scale = Number(key)
105
+ if (!(scale > 1)) throw new Error(`createCursor: alternate scale ${key} must be above 1`)
106
+ let alt = decodeImage(bytes, { alpha: "straight" })
107
+ let [w, h] = [base.width * scale, base.height * scale]
108
+ if (alt.width !== w || alt.height !== h)
109
+ throw new Error(`createCursor: the ${scale}x alternate is ${alt.width}x${alt.height}, expected ${w}x${h}`)
110
+ images.push(alt)
111
+ }
112
+ return { images, duration: frame.duration }
113
+ }