@vsreact/core 0.0.2 → 0.0.3

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/animation.ts CHANGED
@@ -68,3 +68,74 @@ export function useTween({ duration, delay = 0, easing = Easing.outCubic, onComp
68
68
  export function lerp(from: number, to: number, t: number): number {
69
69
  return from + (to - from) * t;
70
70
  }
71
+
72
+ // ── springs ────────────────────────────────────────────────────────────
73
+
74
+ export interface SpringOptions {
75
+ /** Spring constant — higher snaps faster. Default 170. */
76
+ stiffness?: number;
77
+ /** Velocity drag — higher settles with less bounce. Default 24. */
78
+ damping?: number;
79
+ mass?: number;
80
+ /** Distance from target below which the spring snaps and stops. */
81
+ restDelta?: number;
82
+ }
83
+
84
+ /** One semi-implicit Euler integration step; exported for tests and for
85
+ driving springs from your own loops. Returns [position, velocity]. */
86
+ export function springStep(
87
+ position: number,
88
+ velocity: number,
89
+ target: number,
90
+ { stiffness = 170, damping = 24, mass = 1 }: SpringOptions,
91
+ dtMs: number,
92
+ ): [number, number] {
93
+ const dt = dtMs / 1000;
94
+ const acceleration = (-stiffness * (position - target) - damping * velocity) / mass;
95
+ const v = velocity + acceleration * dt;
96
+ return [position + v * dt, v];
97
+ }
98
+
99
+ /**
100
+ * A value that springs toward `target` whenever it changes — for
101
+ * interactive motion where a fixed-duration tween feels wrong (toggle
102
+ * thumbs, drawers, meters chasing levels). Runs on the host timer like
103
+ * useTween; starts at rest on the initial target.
104
+ */
105
+ export function useSpring(target: number, options: SpringOptions = {}): number {
106
+ const [value, setValue] = useState(target);
107
+ const state = useRef({ position: target, velocity: 0 });
108
+ const targetRef = useRef(target);
109
+ targetRef.current = target;
110
+ const optionsRef = useRef(options);
111
+ optionsRef.current = options;
112
+
113
+ useEffect(() => {
114
+ const restDelta = optionsRef.current.restDelta ?? 0.001;
115
+ if (
116
+ Math.abs(state.current.position - targetRef.current) <= restDelta &&
117
+ Math.abs(state.current.velocity) <= restDelta
118
+ ) {
119
+ return;
120
+ }
121
+
122
+ const id = setInterval(() => {
123
+ const s = state.current;
124
+ const [p, v] = springStep(s.position, s.velocity, targetRef.current, optionsRef.current, FRAME_MS);
125
+ s.position = p;
126
+ s.velocity = v;
127
+
128
+ if (Math.abs(p - targetRef.current) <= restDelta && Math.abs(v) <= restDelta) {
129
+ s.position = targetRef.current;
130
+ s.velocity = 0;
131
+ clearInterval(id);
132
+ }
133
+
134
+ setValue(s.position);
135
+ }, FRAME_MS);
136
+
137
+ return () => clearInterval(id);
138
+ }, [target]);
139
+
140
+ return value;
141
+ }
@@ -0,0 +1,131 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ const batches: unknown[][][] = [];
4
+ const nativeCalls: Array<{ name: string; args: any }> = [];
5
+ let paramGetResult: any = { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB" };
6
+
7
+ (globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
8
+ batches.push(JSON.parse(json));
9
+ };
10
+ (globalThis as Record<string, any>).__vsreact_nativeCall = (name: string, argsJson: string) => {
11
+ const args = JSON.parse(argsJson);
12
+ nativeCalls.push({ name, args });
13
+ if (name === "param:get") return JSON.stringify(paramGetResult);
14
+ return "null";
15
+ };
16
+
17
+ import { render, unmount, Slider, Toggle, XYPad, Segmented, ParamSegmented, ParamToggle } from "./index";
18
+
19
+ const allOps = () => batches.flat();
20
+ const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
21
+ const dispatch = (msg: unknown) =>
22
+ (globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
23
+
24
+ /** First node id whose registered listeners include `type`. */
25
+ const nodeWithListener = (type: string): number => {
26
+ const props: any = opsNamed("setProps").find((op: any) => op[2]?.listeners?.includes(type));
27
+ expect(props).toBeDefined();
28
+ return props[1];
29
+ };
30
+
31
+ beforeEach(() => {
32
+ unmount();
33
+ batches.length = 0;
34
+ nativeCalls.length = 0;
35
+ paramGetResult = { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB" };
36
+ });
37
+
38
+ describe("vertical Slider", () => {
39
+ test("drag up increases the value; fill rises from the bottom", () => {
40
+ const seen: number[] = [];
41
+ render(<Slider vertical height={100} value={0.5} onChange={(v) => seen.push(v)} />);
42
+
43
+ const id = nodeWithListener("drag");
44
+ dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
45
+ dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 0, dy: -25, x: 0, y: -25 } });
46
+ expect(seen.at(-1)).toBeCloseTo(0.75);
47
+
48
+ dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 0, dy: 80, x: 0, y: 80 } });
49
+ expect(seen.at(-1)).toBe(0); // clamped
50
+
51
+ // Fill height reflects the (still-controlled) 0.5 value.
52
+ const fill: any = opsNamed("setProps").find((op: any) => op[2]?.style?.height === 50);
53
+ expect(fill).toBeDefined();
54
+ });
55
+ });
56
+
57
+ describe("Toggle", () => {
58
+ test("click flips; disabled ignores clicks", () => {
59
+ const seen: boolean[] = [];
60
+ render(<Toggle on={false} onChange={(v) => seen.push(v)} />);
61
+
62
+ const id = nodeWithListener("click");
63
+ dispatch({ kind: "event", nodeId: id, type: "click" });
64
+ expect(seen).toEqual([true]);
65
+
66
+ unmount();
67
+ batches.length = 0;
68
+ render(<Toggle on disabled onChange={(v) => seen.push(v)} />);
69
+ expect(opsNamed("setProps").some((op: any) => op[2]?.listeners?.includes("click"))).toBe(false);
70
+ });
71
+
72
+ test("ParamToggle reads the param and writes a begin/set/end gesture", () => {
73
+ paramGetResult = { value: 0, text: "Off", name: "Bypass", label: "" };
74
+ render(<ParamToggle paramId="bypass" />);
75
+
76
+ const id = nodeWithListener("click");
77
+ dispatch({ kind: "event", nodeId: id, type: "click" });
78
+
79
+ const names = nativeCalls.map((c) => c.name);
80
+ expect(names).toContain("param:begin");
81
+ expect(names).toContain("param:end");
82
+ const set = nativeCalls.find((c) => c.name === "param:set");
83
+ expect(set?.args).toEqual({ id: "bypass", value: 1 });
84
+ });
85
+ });
86
+
87
+ describe("XYPad", () => {
88
+ test("drag maps both axes: right = +x, up = +y, clamped", () => {
89
+ const seen: Array<[number, number]> = [];
90
+ render(
91
+ <XYPad x={0.5} y={0.5} width={100} height={100} onChange={(x, y) => seen.push([x, y])} />,
92
+ );
93
+
94
+ const id = nodeWithListener("drag");
95
+ dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
96
+ dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 25, dy: -25, x: 0, y: 0 } });
97
+ expect(seen.at(-1)?.[0]).toBeCloseTo(0.75);
98
+ expect(seen.at(-1)?.[1]).toBeCloseTo(0.75);
99
+
100
+ dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 999, dy: 999, x: 0, y: 0 } });
101
+ expect(seen.at(-1)).toEqual([1, 0]);
102
+ });
103
+ });
104
+
105
+ describe("Segmented", () => {
106
+ test("clicking a segment reports its index", () => {
107
+ const seen: number[] = [];
108
+ render(<Segmented options={["SINE", "SAW", "SQR"]} index={0} onChange={(i) => seen.push(i)} />);
109
+
110
+ const clickables = opsNamed("setProps").filter((op: any) =>
111
+ op[2]?.listeners?.includes("click"),
112
+ );
113
+ expect(clickables.length).toBe(3);
114
+
115
+ dispatch({ kind: "event", nodeId: (clickables[2] as any)[1], type: "click" });
116
+ expect(seen).toEqual([2]);
117
+ });
118
+
119
+ test("ParamSegmented maps value→index and writes index/(n−1)", () => {
120
+ paramGetResult = { value: 0.5, text: "Saw", name: "Shape", label: "" };
121
+ render(<ParamSegmented paramId="shape" options={["SINE", "SAW", "SQR"]} />);
122
+
123
+ const clickables = opsNamed("setProps").filter((op: any) =>
124
+ op[2]?.listeners?.includes("click"),
125
+ );
126
+ dispatch({ kind: "event", nodeId: (clickables[0] as any)[1], type: "click" });
127
+
128
+ const set = nativeCalls.find((c) => c.name === "param:set");
129
+ expect(set?.args).toEqual({ id: "shape", value: 0 });
130
+ });
131
+ });
package/src/controls.tsx CHANGED
@@ -1,13 +1,17 @@
1
- // Knob and Sliderthe classic VST controls, drawn by the VSReacT painter
2
- // (arc styles) and driven by drag gestures.
1
+ // The built-in VST controls knobs, sliders, toggles, XY pads, segmented
2
+ // switches drawn by the VSReacT painter and driven by drag gestures.
3
+ // Each has a Param* variant bound to an APVTS parameter.
3
4
 
4
5
  import { useRef } from "react";
5
6
  import { View, Text } from "./primitives";
6
7
  import { useParameter } from "./parameters";
8
+ import { useSpring } from "./animation";
9
+
10
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
7
11
 
8
12
  /** Vertical-drag-to-value mapping shared by Knob (and tested in isolation). */
9
13
  export function dragToValue(startValue: number, dy: number, sensitivity = 0.005): number {
10
- return Math.min(1, Math.max(0, startValue - dy * sensitivity));
14
+ return clamp01(startValue - dy * sensitivity);
11
15
  }
12
16
 
13
17
  const ARC_START = -135;
@@ -110,7 +114,12 @@ export function ParamKnob({ paramId, label, size, trackColor, valueColor }: Para
110
114
 
111
115
  export interface SliderProps {
112
116
  value: number; // normalized 0..1
117
+ /** Track length when horizontal (default 160). */
113
118
  width?: number;
119
+ /** Track length when vertical (default 160). */
120
+ height?: number;
121
+ /** Vertical fader — drag up for more, fill rises from the bottom. */
122
+ vertical?: boolean;
114
123
  label?: string;
115
124
  disabled?: boolean;
116
125
  trackColor?: string;
@@ -123,6 +132,8 @@ export interface SliderProps {
123
132
  export function Slider({
124
133
  value,
125
134
  width = 160,
135
+ height = 160,
136
+ vertical,
126
137
  label,
127
138
  disabled,
128
139
  trackColor = "#2A2F27",
@@ -132,27 +143,54 @@ export function Slider({
132
143
  onEnd,
133
144
  }: SliderProps) {
134
145
  const startValue = useRef(0);
135
- const clamped = Math.min(1, Math.max(0, value));
146
+ const clamped = clamp01(value);
147
+
148
+ const onDragStart = disabled
149
+ ? undefined
150
+ : () => {
151
+ startValue.current = clamped;
152
+ onBegin?.();
153
+ };
154
+ const onDragEnd = disabled ? undefined : () => onEnd?.();
155
+
156
+ if (vertical) {
157
+ return (
158
+ <View className="items-center gap-2">
159
+ <View
160
+ className={`w-[18] relative ${disabled ? "opacity-40" : "cursor-pointer"}`}
161
+ style={{ height }}
162
+ onDragStart={onDragStart}
163
+ onDrag={disabled ? undefined : (e) => onChange(clamp01(startValue.current - e.dy / height))}
164
+ onDragEnd={onDragEnd}
165
+ >
166
+ <View
167
+ className="absolute w-[4] rounded-full left-[7] top-0 bottom-0"
168
+ style={{ backgroundColor: trackColor }}
169
+ />
170
+ <View
171
+ className="absolute w-[4] rounded-full left-[7] bottom-0"
172
+ style={{ height: clamped * height, backgroundColor: valueColor }}
173
+ />
174
+ <View
175
+ className="absolute w-[12] h-[12] rounded-full left-[3]"
176
+ style={{ top: (1 - clamped) * (height - 12), backgroundColor: valueColor }}
177
+ />
178
+ </View>
179
+ {label !== undefined ? (
180
+ <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
181
+ ) : null}
182
+ </View>
183
+ );
184
+ }
136
185
 
137
186
  return (
138
187
  <View className="gap-2">
139
188
  <View
140
189
  className={`h-[18] justify-center relative ${disabled ? "opacity-40" : "cursor-pointer"}`}
141
190
  style={{ width }}
142
- onDragStart={
143
- disabled
144
- ? undefined
145
- : () => {
146
- startValue.current = clamped;
147
- onBegin?.();
148
- }
149
- }
150
- onDrag={
151
- disabled
152
- ? undefined
153
- : (e) => onChange(Math.min(1, Math.max(0, startValue.current + e.dx / width)))
154
- }
155
- onDragEnd={disabled ? undefined : () => onEnd?.()}
191
+ onDragStart={onDragStart}
192
+ onDrag={disabled ? undefined : (e) => onChange(clamp01(startValue.current + e.dx / width))}
193
+ onDragEnd={onDragEnd}
156
194
  >
157
195
  <View className="h-[4] rounded-full" style={{ backgroundColor: trackColor }} />
158
196
  <View
@@ -175,10 +213,12 @@ export interface ParamSliderProps {
175
213
  paramId: string;
176
214
  label?: string;
177
215
  width?: number;
216
+ height?: number;
217
+ vertical?: boolean;
178
218
  }
179
219
 
180
220
  /** A Slider bound to an APVTS parameter (via ParameterBridge). */
181
- export function ParamSlider({ paramId, label, width }: ParamSliderProps) {
221
+ export function ParamSlider({ paramId, label, width, height, vertical }: ParamSliderProps) {
182
222
  const param = useParameter(paramId);
183
223
 
184
224
  return (
@@ -186,9 +226,302 @@ export function ParamSlider({ paramId, label, width }: ParamSliderProps) {
186
226
  value={param.value}
187
227
  label={label ?? param.name.toUpperCase()}
188
228
  width={width}
229
+ height={height}
230
+ vertical={vertical}
189
231
  onChange={param.set}
190
232
  onBegin={param.begin}
191
233
  onEnd={param.end}
192
234
  />
193
235
  );
194
236
  }
237
+
238
+ // ── Toggle ─────────────────────────────────────────────────────────────
239
+
240
+ export interface ToggleProps {
241
+ on: boolean;
242
+ label?: string;
243
+ /** Track height; width is 1.8×. Default 22. */
244
+ size?: number;
245
+ disabled?: boolean;
246
+ trackColor?: string;
247
+ onColor?: string;
248
+ thumbColor?: string;
249
+ onChange: (on: boolean) => void;
250
+ }
251
+
252
+ /** A switch with a spring-animated thumb — bypass, on/off, A/B. */
253
+ export function Toggle({
254
+ on,
255
+ label,
256
+ size = 22,
257
+ disabled,
258
+ trackColor = "#2A2F27",
259
+ onColor = "#C6F135",
260
+ thumbColor = "#F4F4F5",
261
+ onChange,
262
+ }: ToggleProps) {
263
+ const t = useSpring(on ? 1 : 0, { stiffness: 300, damping: 28 });
264
+ const trackWidth = Math.round(size * 1.8);
265
+ const thumb = size - 6;
266
+ const travel = trackWidth - thumb - 6;
267
+
268
+ return (
269
+ <View className="items-center gap-2">
270
+ <View
271
+ className={`relative rounded-full ${disabled ? "opacity-40" : "cursor-pointer"}`}
272
+ style={{ width: trackWidth, height: size, backgroundColor: on ? onColor : trackColor }}
273
+ onClick={disabled ? undefined : () => onChange(!on)}
274
+ >
275
+ <View
276
+ className="absolute rounded-full"
277
+ style={{
278
+ width: thumb,
279
+ height: thumb,
280
+ top: 3,
281
+ left: 3 + clamp01(t) * travel,
282
+ backgroundColor: thumbColor,
283
+ }}
284
+ />
285
+ </View>
286
+ {label !== undefined ? (
287
+ <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
288
+ ) : null}
289
+ </View>
290
+ );
291
+ }
292
+
293
+ export interface ParamToggleProps {
294
+ paramId: string;
295
+ label?: string;
296
+ size?: number;
297
+ trackColor?: string;
298
+ onColor?: string;
299
+ thumbColor?: string;
300
+ }
301
+
302
+ /** A Toggle bound to a bool-style APVTS parameter (on = value ≥ 0.5). */
303
+ export function ParamToggle({ paramId, label, ...rest }: ParamToggleProps) {
304
+ const param = useParameter(paramId);
305
+
306
+ return (
307
+ <Toggle
308
+ on={param.value >= 0.5}
309
+ label={label ?? param.name.toUpperCase()}
310
+ onChange={(next) => {
311
+ param.begin();
312
+ param.set(next ? 1 : 0);
313
+ param.end();
314
+ }}
315
+ {...rest}
316
+ />
317
+ );
318
+ }
319
+
320
+ // ── XYPad ──────────────────────────────────────────────────────────────
321
+
322
+ export interface XYPadProps {
323
+ /** Normalized 0..1. */
324
+ x: number;
325
+ /** Normalized 0..1 — 1 is the TOP of the pad (drag up for more). */
326
+ y: number;
327
+ width?: number;
328
+ height?: number;
329
+ label?: string;
330
+ disabled?: boolean;
331
+ trackColor?: string;
332
+ valueColor?: string;
333
+ onChange: (x: number, y: number) => void;
334
+ onBegin?: () => void;
335
+ onEnd?: () => void;
336
+ }
337
+
338
+ /** A 2D drag pad with crosshair — filter cutoff/resonance, pan/depth… */
339
+ export function XYPad({
340
+ x,
341
+ y,
342
+ width = 160,
343
+ height = 160,
344
+ label,
345
+ disabled,
346
+ trackColor = "#161B17",
347
+ valueColor = "#C6F135",
348
+ onChange,
349
+ onBegin,
350
+ onEnd,
351
+ }: XYPadProps) {
352
+ const start = useRef({ x: 0, y: 0 });
353
+ const cxv = clamp01(x);
354
+ const cyv = clamp01(y);
355
+ const thumb = 14;
356
+ const tx = cxv * (width - thumb);
357
+ const ty = (1 - cyv) * (height - thumb);
358
+
359
+ return (
360
+ <View className="items-center gap-2">
361
+ <View
362
+ className={`relative rounded-lg overflow-hidden border ${disabled ? "opacity-40" : "cursor-pointer"}`}
363
+ style={{ width, height, backgroundColor: trackColor, borderColor: "#00000055" }}
364
+ onDragStart={
365
+ disabled
366
+ ? undefined
367
+ : () => {
368
+ start.current = { x: cxv, y: cyv };
369
+ onBegin?.();
370
+ }
371
+ }
372
+ onDrag={
373
+ disabled
374
+ ? undefined
375
+ : (e) =>
376
+ onChange(
377
+ clamp01(start.current.x + e.dx / width),
378
+ clamp01(start.current.y - e.dy / height),
379
+ )
380
+ }
381
+ onDragEnd={disabled ? undefined : () => onEnd?.()}
382
+ >
383
+ <View
384
+ className="absolute left-0 right-0 h-[1]"
385
+ style={{ top: ty + thumb / 2, backgroundColor: valueColor, opacity: 0.35 }}
386
+ />
387
+ <View
388
+ className="absolute top-0 bottom-0 w-[1]"
389
+ style={{ left: tx + thumb / 2, backgroundColor: valueColor, opacity: 0.35 }}
390
+ />
391
+ <View
392
+ className="absolute rounded-full"
393
+ style={{ width: thumb, height: thumb, left: tx, top: ty, backgroundColor: valueColor }}
394
+ />
395
+ </View>
396
+ {label !== undefined ? (
397
+ <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
398
+ ) : null}
399
+ </View>
400
+ );
401
+ }
402
+
403
+ export interface ParamXYPadProps {
404
+ paramX: string;
405
+ paramY: string;
406
+ width?: number;
407
+ height?: number;
408
+ label?: string;
409
+ trackColor?: string;
410
+ valueColor?: string;
411
+ }
412
+
413
+ /** An XYPad driving two APVTS parameters at once. */
414
+ export function ParamXYPad({ paramX, paramY, label, ...rest }: ParamXYPadProps) {
415
+ const px = useParameter(paramX);
416
+ const py = useParameter(paramY);
417
+
418
+ return (
419
+ <XYPad
420
+ x={px.value}
421
+ y={py.value}
422
+ label={label ?? `${px.name} / ${py.name}`.toUpperCase()}
423
+ onChange={(nx, ny) => {
424
+ px.set(nx);
425
+ py.set(ny);
426
+ }}
427
+ onBegin={() => {
428
+ px.begin();
429
+ py.begin();
430
+ }}
431
+ onEnd={() => {
432
+ px.end();
433
+ py.end();
434
+ }}
435
+ {...rest}
436
+ />
437
+ );
438
+ }
439
+
440
+ // ── Segmented ──────────────────────────────────────────────────────────
441
+
442
+ export interface SegmentedProps {
443
+ options: string[];
444
+ index: number;
445
+ label?: string;
446
+ disabled?: boolean;
447
+ trackColor?: string;
448
+ activeColor?: string;
449
+ textColor?: string;
450
+ activeTextColor?: string;
451
+ onChange: (index: number) => void;
452
+ }
453
+
454
+ /** A row of exclusive options — oscillator shapes, filter modes, A/B/C. */
455
+ export function Segmented({
456
+ options,
457
+ index,
458
+ label,
459
+ disabled,
460
+ trackColor = "#2A2F27",
461
+ activeColor = "#C6F135",
462
+ textColor = "#a1a1aa",
463
+ activeTextColor = "#09090b",
464
+ onChange,
465
+ }: SegmentedProps) {
466
+ const current = Math.min(options.length - 1, Math.max(0, index));
467
+
468
+ return (
469
+ <View className="items-center gap-2">
470
+ <View
471
+ className={`flex-row rounded-lg p-[3] gap-[3] ${disabled ? "opacity-40" : ""}`}
472
+ style={{ backgroundColor: trackColor }}
473
+ >
474
+ {options.map((option, i) => (
475
+ <View
476
+ key={`${option}-${i}`}
477
+ className={`px-3 py-[6] rounded-md ${disabled ? "" : "cursor-pointer"}`}
478
+ style={{ backgroundColor: i === current ? activeColor : "#00000000" }}
479
+ onClick={disabled ? undefined : () => onChange(i)}
480
+ >
481
+ <Text
482
+ className="text-[11] font-bold tracking-wide"
483
+ style={{ color: i === current ? activeTextColor : textColor }}
484
+ >
485
+ {option}
486
+ </Text>
487
+ </View>
488
+ ))}
489
+ </View>
490
+ {label !== undefined ? (
491
+ <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
492
+ ) : null}
493
+ </View>
494
+ );
495
+ }
496
+
497
+ export interface ParamSegmentedProps {
498
+ paramId: string;
499
+ options: string[];
500
+ label?: string;
501
+ trackColor?: string;
502
+ activeColor?: string;
503
+ textColor?: string;
504
+ activeTextColor?: string;
505
+ }
506
+
507
+ /** A Segmented bound to a choice-style APVTS parameter: the normalized
508
+ value maps to an option index (index / (count − 1)). */
509
+ export function ParamSegmented({ paramId, options, label, ...rest }: ParamSegmentedProps) {
510
+ const param = useParameter(paramId);
511
+ const count = Math.max(1, options.length);
512
+ const index = Math.round(param.value * (count - 1));
513
+
514
+ return (
515
+ <Segmented
516
+ options={options}
517
+ index={index}
518
+ label={label ?? param.name.toUpperCase()}
519
+ onChange={(i) => {
520
+ param.begin();
521
+ param.set(count <= 1 ? 0 : i / (count - 1));
522
+ param.end();
523
+ }}
524
+ {...rest}
525
+ />
526
+ );
527
+ }
package/src/cx.ts ADDED
@@ -0,0 +1,33 @@
1
+ // cx — compose conditional className strings (a tiny clsx).
2
+
3
+ export type ClassValue =
4
+ | string
5
+ | number
6
+ | null
7
+ | false
8
+ | undefined
9
+ | ClassValue[]
10
+ | Record<string, unknown>;
11
+
12
+ /**
13
+ * `cx("flex", active && "bg-red-500", { "opacity-40": disabled })`
14
+ * → `"flex bg-red-500 opacity-40"`.
15
+ */
16
+ export function cx(...inputs: ClassValue[]): string {
17
+ const out: string[] = [];
18
+
19
+ for (const input of inputs) {
20
+ if (!input && input !== 0) continue;
21
+
22
+ if (typeof input === "string" || typeof input === "number") {
23
+ out.push(String(input));
24
+ } else if (Array.isArray(input)) {
25
+ const nested = cx(...input);
26
+ if (nested) out.push(nested);
27
+ } else if (typeof input === "object") {
28
+ for (const [key, value] of Object.entries(input)) if (value) out.push(key);
29
+ }
30
+ }
31
+
32
+ return out.join(" ");
33
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,18 @@
1
+ // Convenience hooks over the native bridge.
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { native } from "./native";
5
+
6
+ /**
7
+ * Subscribes to a C++ event (RootView::sendNativeEvent) for the lifetime
8
+ * of the component. The handler can close over fresh state — it is kept
9
+ * current without resubscribing.
10
+ *
11
+ * useNativeEvent("download:progress", (p) => setProgress(p.ratio));
12
+ */
13
+ export function useNativeEvent(name: string, handler: (payload: any) => void): void {
14
+ const handlerRef = useRef(handler);
15
+ handlerRef.current = handler;
16
+
17
+ useEffect(() => native.on(name, (payload) => handlerRef.current(payload)), [name]);
18
+ }