@vsreact/core 0.0.9 → 0.0.11
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/dist/components.d.ts +6 -0
- package/dist/components.js +3 -0
- package/dist/controls.d.ts +10 -4
- package/dist/controls.js +20 -14
- package/dist/feedback.d.ts +24 -0
- package/dist/feedback.js +31 -0
- package/dist/fields.d.ts +79 -0
- package/dist/fields.js +73 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/specialty.d.ts +109 -0
- package/dist/specialty.js +186 -0
- package/package.json +1 -1
- package/src/components.ts +37 -0
- package/src/controls.test.tsx +231 -0
- package/src/controls.tsx +100 -25
- package/src/feedback.tsx +99 -0
- package/src/fields.tsx +288 -0
- package/src/index.ts +37 -0
- package/src/specialty.tsx +514 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The flagship-visual tier — Output-style macro pads, hardware knobs,
|
|
3
|
+
// crossfader strips, and value-reactive orbs. Everything is painted
|
|
4
|
+
// natively from Views, arcs, and shadows; the motion runs on the host
|
|
5
|
+
// scheduler.
|
|
6
|
+
import { useRef, useState } from "react";
|
|
7
|
+
import { View, Text } from "./primitives";
|
|
8
|
+
import { useParameter } from "./parameters";
|
|
9
|
+
import { useInterval } from "./hooks";
|
|
10
|
+
const clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
11
|
+
/** The centerpiece macro control: a circular 2D pad whose concentric
|
|
12
|
+
rings breathe with the values — x spreads them, y drives their
|
|
13
|
+
intensity. One drag, two parameters, instrument-grade presence. */
|
|
14
|
+
export function MacroPad({ x, y, size = 220, labelX, labelY, disabled, rings = 9, animate = true, color = "#C6F135", trackColor = "#101210", onChange, onBegin, onEnd, }) {
|
|
15
|
+
const start = useRef({ x: 0, y: 0 });
|
|
16
|
+
const [phase, setPhase] = useState(0);
|
|
17
|
+
useInterval(() => setPhase((p) => (p + 1) % 1000), animate && !disabled ? 50 : null);
|
|
18
|
+
const cx = clamp01(x);
|
|
19
|
+
const cy = clamp01(y);
|
|
20
|
+
const thumb = 12;
|
|
21
|
+
const tx = cx * (size - thumb);
|
|
22
|
+
const ty = (1 - cy) * (size - thumb);
|
|
23
|
+
const ringViews = [];
|
|
24
|
+
for (let i = 0; i < rings; i++) {
|
|
25
|
+
const t = (i + 1) / rings;
|
|
26
|
+
// x spreads the rings outward; the phase makes them breathe gently.
|
|
27
|
+
const spread = 0.3 + 0.7 * Math.pow(t, 1.6 - cx * 1.2);
|
|
28
|
+
const breathe = 1 + 0.02 * Math.sin(phase / 6 + i * 0.9);
|
|
29
|
+
const ringSize = Math.min(size - 2, size * spread * breathe);
|
|
30
|
+
// y drives intensity; outer rings fade.
|
|
31
|
+
const opacity = clamp01((0.12 + 0.5 * cy) * (1.15 - t) * (0.8 + 0.2 * Math.sin(phase / 9 + i)));
|
|
32
|
+
ringViews.push(_jsx(View, { className: "absolute rounded-full border", style: {
|
|
33
|
+
width: ringSize,
|
|
34
|
+
height: ringSize,
|
|
35
|
+
left: (size - ringSize) / 2,
|
|
36
|
+
top: (size - ringSize) / 2,
|
|
37
|
+
borderColor: color,
|
|
38
|
+
opacity,
|
|
39
|
+
} }, i));
|
|
40
|
+
}
|
|
41
|
+
return (_jsxs(View, { className: `relative rounded-full overflow-hidden border ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width: size, height: size, backgroundColor: trackColor, borderColor: "#00000066" }, onDragStart: disabled
|
|
42
|
+
? undefined
|
|
43
|
+
: () => {
|
|
44
|
+
start.current = { x: cx, y: cy };
|
|
45
|
+
onBegin?.();
|
|
46
|
+
}, onDrag: disabled
|
|
47
|
+
? undefined
|
|
48
|
+
: (e) => onChange(clamp01(start.current.x + e.dx / size), clamp01(start.current.y - e.dy / size)), onDragEnd: disabled ? undefined : () => onEnd?.(), onDoubleClick: disabled ? undefined : () => onChange(0.5, 0.5), children: [ringViews, _jsx(View, { className: "absolute rounded-full", style: {
|
|
49
|
+
width: thumb,
|
|
50
|
+
height: thumb,
|
|
51
|
+
left: tx,
|
|
52
|
+
top: ty,
|
|
53
|
+
backgroundColor: color,
|
|
54
|
+
shadowColor: color,
|
|
55
|
+
shadowRadius: 10,
|
|
56
|
+
} }), labelY !== undefined ? (_jsx(Text, { className: "absolute text-[9] font-bold tracking-widest", style: { left: 10, top: size / 2 - 6, color, opacity: 0.75 }, children: labelY })) : null, labelX !== undefined ? (_jsx(Text, { className: "absolute text-[9] font-bold tracking-widest", style: { bottom: 10, left: size / 2 - 24, color, opacity: 0.75 }, children: labelX })) : null] }));
|
|
57
|
+
}
|
|
58
|
+
/** A MacroPad driving two host parameters — labels default to their
|
|
59
|
+
names. */
|
|
60
|
+
export function ParamMacroPad({ paramX, paramY, labelX, labelY, ...rest }) {
|
|
61
|
+
const px = useParameter(paramX);
|
|
62
|
+
const py = useParameter(paramY);
|
|
63
|
+
return (_jsx(MacroPad, { x: px.value, y: py.value, labelX: labelX ?? px.name.toUpperCase(), labelY: labelY ?? py.name.toUpperCase(), onChange: (nx, ny) => {
|
|
64
|
+
px.set(nx);
|
|
65
|
+
py.set(ny);
|
|
66
|
+
}, onBegin: () => {
|
|
67
|
+
px.begin();
|
|
68
|
+
py.begin();
|
|
69
|
+
}, onEnd: () => {
|
|
70
|
+
px.end();
|
|
71
|
+
py.end();
|
|
72
|
+
}, ...rest }));
|
|
73
|
+
}
|
|
74
|
+
// ── HardwareKnob ───────────────────────────────────────────────────────
|
|
75
|
+
const HW_START = -135;
|
|
76
|
+
const HW_END = 135;
|
|
77
|
+
/** The skeuomorphic knob: a dark hardware cap with a glowing pointer
|
|
78
|
+
notch at the rim and a faint tick track — the audio-ui.com look,
|
|
79
|
+
painted natively. */
|
|
80
|
+
export function HardwareKnob({ value, size = 88, label, text, disabled, defaultValue, wheelSensitivity = 0.4, capColor = "#1B1B1A", pointerColor = "#C6F135", tickColor = "#FFFFFF14", onChange, onBegin, onEnd, }) {
|
|
81
|
+
const startValue = useRef(0);
|
|
82
|
+
const clamped = clamp01(value);
|
|
83
|
+
const angle = HW_START + (HW_END - HW_START) * clamped;
|
|
84
|
+
const nudge = (target) => {
|
|
85
|
+
onBegin?.();
|
|
86
|
+
onChange(clamp01(target));
|
|
87
|
+
onEnd?.();
|
|
88
|
+
};
|
|
89
|
+
return (_jsxs(View, { className: "items-center gap-2", children: [_jsxs(View, { className: `relative ${disabled ? "opacity-40" : "cursor-pointer"}`, style: {
|
|
90
|
+
width: size,
|
|
91
|
+
height: size,
|
|
92
|
+
arcTrackColor: tickColor,
|
|
93
|
+
arcStart: HW_START,
|
|
94
|
+
arcEnd: HW_END,
|
|
95
|
+
arcThickness: 2,
|
|
96
|
+
}, onDragStart: disabled
|
|
97
|
+
? undefined
|
|
98
|
+
: () => {
|
|
99
|
+
startValue.current = clamped;
|
|
100
|
+
onBegin?.();
|
|
101
|
+
}, onDrag: disabled
|
|
102
|
+
? undefined
|
|
103
|
+
: (e) => onChange(clamp01(startValue.current - e.dy * 0.005)), onDragEnd: disabled ? undefined : () => onEnd?.(), onDoubleClick: disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue), onWheel: disabled || wheelSensitivity === 0
|
|
104
|
+
? undefined
|
|
105
|
+
: (e) => nudge(clamped + e.dy * wheelSensitivity), children: [_jsx(View, { className: "absolute rounded-full border items-center justify-center shadow-lg", style: {
|
|
106
|
+
left: size * 0.09,
|
|
107
|
+
top: size * 0.09,
|
|
108
|
+
width: size * 0.82,
|
|
109
|
+
height: size * 0.82,
|
|
110
|
+
backgroundColor: capColor,
|
|
111
|
+
borderColor: "#000000AA",
|
|
112
|
+
}, children: text !== undefined ? (_jsx(Text, { className: "text-text font-bold", style: { fontSize: Math.max(10, size * 0.14) }, children: text })) : null }), _jsx(View, { className: "absolute inset-0", style: {
|
|
113
|
+
arcColor: pointerColor,
|
|
114
|
+
arcStart: HW_START,
|
|
115
|
+
arcEnd: HW_END,
|
|
116
|
+
arcValueStart: angle - 4,
|
|
117
|
+
arcValueEnd: angle + 4,
|
|
118
|
+
arcThickness: Math.max(4, size * 0.075),
|
|
119
|
+
} })] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
|
|
120
|
+
}
|
|
121
|
+
/** A HardwareKnob bound to a host parameter. */
|
|
122
|
+
export function ParamHardwareKnob({ paramId, label, ...rest }) {
|
|
123
|
+
const param = useParameter(paramId);
|
|
124
|
+
return (_jsx(HardwareKnob, { value: param.value, text: param.text, label: label ?? param.name.toUpperCase(), defaultValue: param.defaultValue, onChange: param.set, onBegin: param.begin, onEnd: param.end, ...rest }));
|
|
125
|
+
}
|
|
126
|
+
/** The DRY/WET strip: a wide track with a grippy rectangular handle. */
|
|
127
|
+
export function Crossfader({ value, width = 220, height = 34, labelStart = "DRY", labelEnd = "WET", disabled, trackColor = "#141614", handleColor = "#2E332C", textColor = "#6f6e66", onChange, onBegin, onEnd, }) {
|
|
128
|
+
const startValue = useRef(0);
|
|
129
|
+
const clamped = clamp01(value);
|
|
130
|
+
const handleWidth = 26;
|
|
131
|
+
const travel = width - handleWidth - 6;
|
|
132
|
+
return (_jsxs(View, { className: `relative rounded-lg border justify-center ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width, height, backgroundColor: trackColor, borderColor: "#00000066" }, onDragStart: disabled
|
|
133
|
+
? undefined
|
|
134
|
+
: () => {
|
|
135
|
+
startValue.current = clamped;
|
|
136
|
+
onBegin?.();
|
|
137
|
+
}, onDrag: disabled ? undefined : (e) => onChange(clamp01(startValue.current + e.dx / travel)), onDragEnd: disabled ? undefined : () => onEnd?.(), onDoubleClick: disabled ? undefined : () => {
|
|
138
|
+
onBegin?.();
|
|
139
|
+
onChange(0.5);
|
|
140
|
+
onEnd?.();
|
|
141
|
+
}, children: [_jsx(Text, { className: "absolute text-[8] font-bold tracking-widest", style: { left: 7, top: height / 2 - 5, color: textColor }, children: labelStart }), _jsx(Text, { className: "absolute text-[8] font-bold tracking-widest", style: { right: 7, top: height / 2 - 5, color: textColor }, children: labelEnd }), _jsxs(View, { className: "absolute rounded-md border flex-row items-center justify-center gap-[3]", style: {
|
|
142
|
+
left: 3 + clamped * travel,
|
|
143
|
+
top: 3,
|
|
144
|
+
bottom: 3,
|
|
145
|
+
width: handleWidth,
|
|
146
|
+
backgroundColor: handleColor,
|
|
147
|
+
borderColor: "#00000088",
|
|
148
|
+
}, children: [_jsx(View, { className: "w-[2] h-[12] rounded-full", style: { backgroundColor: "#00000066" } }), _jsx(View, { className: "w-[2] h-[12] rounded-full", style: { backgroundColor: "#00000066" } })] })] }));
|
|
149
|
+
}
|
|
150
|
+
/** A Crossfader bound to a host parameter — the classic mix control. */
|
|
151
|
+
export function ParamCrossfader({ paramId, ...rest }) {
|
|
152
|
+
const param = useParameter(paramId);
|
|
153
|
+
return (_jsx(Crossfader, { value: param.value, onChange: param.set, onBegin: param.begin, onEnd: param.end, ...rest }));
|
|
154
|
+
}
|
|
155
|
+
/** A value-reactive orb: a glowing core emitting echo rings — visual
|
|
156
|
+
feedback for levels, activity, or just presence. */
|
|
157
|
+
export function PulseOrb({ value, size = 120, color = "#C6F135", rings = 4, animate = true, }) {
|
|
158
|
+
const [phase, setPhase] = useState(0);
|
|
159
|
+
useInterval(() => setPhase((p) => (p + 1) % 1000), animate ? 40 : null);
|
|
160
|
+
const level = clamp01(value);
|
|
161
|
+
const core = 14 + level * 12;
|
|
162
|
+
const echoes = [];
|
|
163
|
+
for (let i = 0; i < rings; i++) {
|
|
164
|
+
const t = ((phase * (1 + level * 2) + (i * 100) / rings) % 100) / 100;
|
|
165
|
+
const ringSize = core + t * (size - core - 4);
|
|
166
|
+
const opacity = clamp01((1 - t) * (0.15 + 0.6 * level));
|
|
167
|
+
echoes.push(_jsx(View, { className: "absolute rounded-full border", style: {
|
|
168
|
+
width: ringSize,
|
|
169
|
+
height: ringSize,
|
|
170
|
+
left: (size - ringSize) / 2,
|
|
171
|
+
top: (size - ringSize) / 2,
|
|
172
|
+
borderColor: color,
|
|
173
|
+
opacity,
|
|
174
|
+
} }, i));
|
|
175
|
+
}
|
|
176
|
+
return (_jsxs(View, { className: "relative", style: { width: size, height: size }, children: [echoes, _jsx(View, { className: "absolute rounded-full", style: {
|
|
177
|
+
width: core,
|
|
178
|
+
height: core,
|
|
179
|
+
left: (size - core) / 2,
|
|
180
|
+
top: (size - core) / 2,
|
|
181
|
+
backgroundColor: color,
|
|
182
|
+
shadowColor: color,
|
|
183
|
+
shadowRadius: 6 + level * 14,
|
|
184
|
+
opacity: 0.55 + 0.45 * level,
|
|
185
|
+
} })] }));
|
|
186
|
+
}
|
package/package.json
CHANGED
package/src/components.ts
CHANGED
|
@@ -45,3 +45,40 @@ export { Button } from "./button";
|
|
|
45
45
|
export type { ButtonProps } from "./button";
|
|
46
46
|
export { Tooltip, Modal } from "./popover";
|
|
47
47
|
export type { TooltipProps, ModalProps } from "./popover";
|
|
48
|
+
export {
|
|
49
|
+
NumberBox,
|
|
50
|
+
Checkbox,
|
|
51
|
+
RadioGroup,
|
|
52
|
+
ParamNumberBox,
|
|
53
|
+
ParamCheckbox,
|
|
54
|
+
ParamRadioGroup,
|
|
55
|
+
snapToStep,
|
|
56
|
+
} from "./fields";
|
|
57
|
+
export type {
|
|
58
|
+
NumberBoxProps,
|
|
59
|
+
CheckboxProps,
|
|
60
|
+
RadioGroupProps,
|
|
61
|
+
ParamNumberBoxProps,
|
|
62
|
+
ParamCheckboxProps,
|
|
63
|
+
ParamRadioGroupProps,
|
|
64
|
+
} from "./fields";
|
|
65
|
+
export { ProgressBar, Spinner } from "./feedback";
|
|
66
|
+
export type { ProgressBarProps, SpinnerProps } from "./feedback";
|
|
67
|
+
export {
|
|
68
|
+
MacroPad,
|
|
69
|
+
ParamMacroPad,
|
|
70
|
+
HardwareKnob,
|
|
71
|
+
ParamHardwareKnob,
|
|
72
|
+
Crossfader,
|
|
73
|
+
ParamCrossfader,
|
|
74
|
+
PulseOrb,
|
|
75
|
+
} from "./specialty";
|
|
76
|
+
export type {
|
|
77
|
+
MacroPadProps,
|
|
78
|
+
ParamMacroPadProps,
|
|
79
|
+
HardwareKnobProps,
|
|
80
|
+
ParamHardwareKnobProps,
|
|
81
|
+
CrossfaderProps,
|
|
82
|
+
ParamCrossfaderProps,
|
|
83
|
+
PulseOrbProps,
|
|
84
|
+
} from "./specialty";
|
package/src/controls.test.tsx
CHANGED
|
@@ -542,3 +542,234 @@ describe("0.0.7 — Button, visualizers, hooks", () => {
|
|
|
542
542
|
expect(values.at(-1)).toBe("1->2");
|
|
543
543
|
});
|
|
544
544
|
});
|
|
545
|
+
|
|
546
|
+
describe("0.0.10 — fields & feedback", () => {
|
|
547
|
+
test("snapToStep lands on the grid without float dust", () => {
|
|
548
|
+
const { snapToStep } = require("./index");
|
|
549
|
+
expect(snapToStep(0.3000000004, 0.1, 0)).toBe(0.3);
|
|
550
|
+
expect(snapToStep(7.4, 5, 0)).toBe(5);
|
|
551
|
+
expect(snapToStep(7.6, 5, 0)).toBe(10);
|
|
552
|
+
expect(snapToStep(3.2, 0.5, 1)).toBe(3);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("NumberBox: drag steps, wheel steps, double-click resets, clamps", () => {
|
|
556
|
+
const { NumberBox } = require("./index");
|
|
557
|
+
const seen: number[] = [];
|
|
558
|
+
render(
|
|
559
|
+
<NumberBox value={120} min={40} max={240} step={1} defaultValue={120} onChange={(v: number) => seen.push(v)} />,
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
const id = nodeWithListener("drag");
|
|
563
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
|
|
564
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 0, dy: -40, x: 0, y: 0 } });
|
|
565
|
+
expect(seen.at(-1)).toBe(130); // 40px up = +10 steps
|
|
566
|
+
|
|
567
|
+
dispatch({ kind: "event", nodeId: id, type: "wheel", payload: { dy: 0.1 } });
|
|
568
|
+
expect(seen.at(-1)).toBe(121); // wheel = one step from the controlled 120
|
|
569
|
+
|
|
570
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 0, dy: 9999, x: 0, y: 0 } });
|
|
571
|
+
expect(seen.at(-1)).toBe(40); // clamped at min
|
|
572
|
+
|
|
573
|
+
dispatch({ kind: "event", nodeId: id, type: "dblclick" });
|
|
574
|
+
expect(seen.at(-1)).toBe(120);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("ParamNumberBox shows host text and writes gestures", () => {
|
|
578
|
+
paramGetResult = { value: 0.5, text: "440 Hz", name: "Freq", label: "Hz", defaultValue: 0.5 };
|
|
579
|
+
const { ParamNumberBox } = require("./index");
|
|
580
|
+
render(<ParamNumberBox paramId="freq" />);
|
|
581
|
+
|
|
582
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "440 Hz")).toBe(true);
|
|
583
|
+
|
|
584
|
+
const id = nodeWithListener("wheel");
|
|
585
|
+
dispatch({ kind: "event", nodeId: id, type: "wheel", payload: { dy: 0.1 } });
|
|
586
|
+
const set = nativeCalls.find((c) => c.name === "param:set");
|
|
587
|
+
expect(set?.args.value).toBeCloseTo(0.51);
|
|
588
|
+
expect(nativeCalls.some((c) => c.name === "param:begin")).toBe(true);
|
|
589
|
+
expect(nativeCalls.some((c) => c.name === "param:end")).toBe(true);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("Checkbox toggles; RadioGroup selects; Param twins write gestures", () => {
|
|
593
|
+
const { Checkbox, RadioGroup } = require("./index");
|
|
594
|
+
const checks: boolean[] = [];
|
|
595
|
+
render(<Checkbox checked={false} label="Oversample" onChange={(v: boolean) => checks.push(v)} />);
|
|
596
|
+
dispatch({ kind: "event", nodeId: nodeWithListener("click"), type: "click" });
|
|
597
|
+
expect(checks).toEqual([true]);
|
|
598
|
+
|
|
599
|
+
unmount();
|
|
600
|
+
batches.length = 0;
|
|
601
|
+
const picks: number[] = [];
|
|
602
|
+
render(<RadioGroup options={["OFF", "2X", "4X"]} index={0} onChange={(i: number) => picks.push(i)} />);
|
|
603
|
+
const rows = opsNamed("setProps").filter((op: any) => op[2]?.listeners?.includes("click"));
|
|
604
|
+
expect(rows.length).toBe(3);
|
|
605
|
+
dispatch({ kind: "event", nodeId: (rows[2] as any)[1], type: "click" });
|
|
606
|
+
expect(picks).toEqual([2]);
|
|
607
|
+
|
|
608
|
+
unmount();
|
|
609
|
+
batches.length = 0;
|
|
610
|
+
nativeCalls.length = 0;
|
|
611
|
+
paramGetResult = { value: 0, text: "Off", name: "OS", label: "", defaultValue: 0 };
|
|
612
|
+
const { ParamRadioGroup } = require("./index");
|
|
613
|
+
render(<ParamRadioGroup paramId="os" options={["OFF", "2X", "4X"]} />);
|
|
614
|
+
const prows = opsNamed("setProps").filter((op: any) => op[2]?.listeners?.includes("click"));
|
|
615
|
+
dispatch({ kind: "event", nodeId: (prows[1] as any)[1], type: "click" });
|
|
616
|
+
const set = nativeCalls.find((c) => c.name === "param:set");
|
|
617
|
+
expect(set?.args.value).toBeCloseTo(0.5);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("ProgressBar fill scales; Spinner animates its arc", async () => {
|
|
621
|
+
const { ProgressBar, Spinner } = require("./index");
|
|
622
|
+
render(<ProgressBar value={0.42} width={200} height={8} />);
|
|
623
|
+
const fill = opsNamed("setProps").find((op: any) => op[2]?.style?.width === 84);
|
|
624
|
+
expect(fill).toBeDefined();
|
|
625
|
+
|
|
626
|
+
unmount();
|
|
627
|
+
batches.length = 0;
|
|
628
|
+
render(<Spinner size={28} />);
|
|
629
|
+
const first: any = opsNamed("setProps").find((op: any) => op[2]?.style?.arcValueStart !== undefined);
|
|
630
|
+
expect(first).toBeDefined();
|
|
631
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
632
|
+
const all = opsNamed("setProps").filter((op: any) => op[2]?.style?.arcValueStart !== undefined);
|
|
633
|
+
const angles = all.map((op: any) => op[2].style.arcValueStart);
|
|
634
|
+
expect(Math.max(...angles)).toBeGreaterThan(angles[0]); // it spins
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("GenericEditor renders a live value label under each knob", () => {
|
|
638
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = (name: string, argsJson: string) => {
|
|
639
|
+
const args = JSON.parse(argsJson);
|
|
640
|
+
nativeCalls.push({ name, args });
|
|
641
|
+
if (name === "param:list")
|
|
642
|
+
return JSON.stringify([
|
|
643
|
+
{ id: "gain", name: "Gain", label: "dB", value: 0.5, text: "0.0 dB", defaultValue: 0.5 },
|
|
644
|
+
{ id: "mix", name: "Mix", label: "%", value: 1, text: "100%", defaultValue: 0.5 },
|
|
645
|
+
]);
|
|
646
|
+
if (name === "param:get") {
|
|
647
|
+
const id = args.id;
|
|
648
|
+
return JSON.stringify(
|
|
649
|
+
id === "gain"
|
|
650
|
+
? { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB", defaultValue: 0.5 }
|
|
651
|
+
: { value: 1, text: "100%", name: "Mix", label: "%", defaultValue: 0.5 },
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
return "null";
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const { GenericEditor } = require("./index");
|
|
658
|
+
render(<GenericEditor columns={2} />);
|
|
659
|
+
|
|
660
|
+
// value labels present as text nodes
|
|
661
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "0.0 dB")).toBe(true);
|
|
662
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "100%")).toBe(true);
|
|
663
|
+
// names too
|
|
664
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "GAIN")).toBe(true);
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
describe("0.0.11 — flagship visuals", () => {
|
|
669
|
+
test("MacroPad: drag maps both axes, double-click recenters, rings render", () => {
|
|
670
|
+
const { MacroPad } = require("./index");
|
|
671
|
+
const seen: Array<[number, number]> = [];
|
|
672
|
+
render(
|
|
673
|
+
<MacroPad x={0.5} y={0.5} size={200} animate={false} rings={6} onChange={(x: number, y: number) => seen.push([x, y])} />,
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const id = nodeWithListener("drag");
|
|
677
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
|
|
678
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 50, dy: -50, x: 0, y: 0 } });
|
|
679
|
+
expect(seen.at(-1)?.[0]).toBeCloseTo(0.75);
|
|
680
|
+
expect(seen.at(-1)?.[1]).toBeCloseTo(0.75);
|
|
681
|
+
|
|
682
|
+
dispatch({ kind: "event", nodeId: id, type: "dblclick" });
|
|
683
|
+
expect(seen.at(-1)).toEqual([0.5, 0.5]);
|
|
684
|
+
|
|
685
|
+
// six rings + thumb: ring views carry borderColor + opacity
|
|
686
|
+
const rings = opsNamed("setProps").filter(
|
|
687
|
+
(op: any) => op[2]?.style?.borderColor === "#C6F135" && op[2]?.style?.opacity !== undefined,
|
|
688
|
+
);
|
|
689
|
+
expect(rings.length).toBeGreaterThanOrEqual(6);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("ParamMacroPad opens and closes both gestures", () => {
|
|
693
|
+
const { ParamMacroPad } = require("./index");
|
|
694
|
+
render(<ParamMacroPad paramX="cutoff" paramY="res" animate={false} />);
|
|
695
|
+
|
|
696
|
+
const id = nodeWithListener("drag");
|
|
697
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
|
|
698
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 20, dy: 0, x: 0, y: 0 } });
|
|
699
|
+
dispatch({ kind: "event", nodeId: id, type: "dragend", payload: { dx: 20, dy: 0, x: 0, y: 0 } });
|
|
700
|
+
|
|
701
|
+
expect(nativeCalls.filter((c) => c.name === "param:begin").length).toBe(2);
|
|
702
|
+
expect(nativeCalls.filter((c) => c.name === "param:end").length).toBe(2);
|
|
703
|
+
expect(nativeCalls.filter((c) => c.name === "param:set").length).toBe(2);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test("HardwareKnob: pointer notch tracks the angle; DAW gestures work", () => {
|
|
707
|
+
const { HardwareKnob } = require("./index");
|
|
708
|
+
const seen: number[] = [];
|
|
709
|
+
render(<HardwareKnob value={0.5} defaultValue={0.25} onChange={(v: number) => seen.push(v)} />);
|
|
710
|
+
|
|
711
|
+
// value 0.5 -> angle 0 -> notch arc -4..+4
|
|
712
|
+
const notch: any = opsNamed("setProps").find(
|
|
713
|
+
(op: any) => op[2]?.style?.arcValueStart === -4 && op[2]?.style?.arcValueEnd === 4,
|
|
714
|
+
);
|
|
715
|
+
expect(notch).toBeDefined();
|
|
716
|
+
|
|
717
|
+
const id = nodeWithListener("dblclick");
|
|
718
|
+
dispatch({ kind: "event", nodeId: id, type: "dblclick" });
|
|
719
|
+
expect(seen.at(-1)).toBe(0.25);
|
|
720
|
+
|
|
721
|
+
dispatch({ kind: "event", nodeId: id, type: "wheel", payload: { dy: 0.1 } });
|
|
722
|
+
expect(seen.at(-1)).toBeCloseTo(0.54);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("Crossfader: horizontal drag with travel compensation, labels render", () => {
|
|
726
|
+
const { Crossfader } = require("./index");
|
|
727
|
+
const seen: number[] = [];
|
|
728
|
+
render(<Crossfader value={0.5} width={220} onChange={(v: number) => seen.push(v)} />);
|
|
729
|
+
|
|
730
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "DRY")).toBe(true);
|
|
731
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "WET")).toBe(true);
|
|
732
|
+
|
|
733
|
+
const id = nodeWithListener("drag");
|
|
734
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
|
|
735
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 94, dy: 0, x: 0, y: 0 } }); // travel = 220-26-6 = 188
|
|
736
|
+
expect(seen.at(-1)).toBeCloseTo(1);
|
|
737
|
+
|
|
738
|
+
dispatch({ kind: "event", nodeId: id, type: "dblclick" });
|
|
739
|
+
expect(seen.at(-1)).toBe(0.5);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test("PulseOrb: echo rings advance with the phase", async () => {
|
|
743
|
+
const { PulseOrb } = require("./index");
|
|
744
|
+
render(<PulseOrb value={0.8} size={100} rings={3} />);
|
|
745
|
+
|
|
746
|
+
const sizesAt = () =>
|
|
747
|
+
opsNamed("setProps")
|
|
748
|
+
.filter((op: any) => op[2]?.style?.borderColor === "#C6F135")
|
|
749
|
+
.map((op: any) => op[2].style.width);
|
|
750
|
+
const before = sizesAt().length;
|
|
751
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
752
|
+
expect(sizesAt().length).toBeGreaterThan(before); // new frames landed
|
|
753
|
+
|
|
754
|
+
// the core glows with the level
|
|
755
|
+
const core: any = opsNamed("setProps").find((op: any) => op[2]?.style?.shadowColor === "#C6F135");
|
|
756
|
+
expect(core).toBeDefined();
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("Toggle side labels highlight the active side; Slider barThumb renders a bar", () => {
|
|
760
|
+
const { Toggle: T2, Slider: S2 } = require("./index");
|
|
761
|
+
render(<T2 on={false} offLabel="OFF" onLabel="ON" onChange={() => {}} />);
|
|
762
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "OFF")).toBe(true);
|
|
763
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "ON")).toBe(true);
|
|
764
|
+
|
|
765
|
+
unmount();
|
|
766
|
+
batches.length = 0;
|
|
767
|
+
render(<S2 vertical barThumb height={100} value={0.5} onChange={() => {}} />);
|
|
768
|
+
// bar thumb: 18 wide, 5 tall at the halfway point
|
|
769
|
+
const bar: any = opsNamed("setProps").find(
|
|
770
|
+
(op: any) => op[2]?.style?.width === 18 && op[2]?.style?.height === 5,
|
|
771
|
+
);
|
|
772
|
+
expect(bar).toBeDefined();
|
|
773
|
+
expect(bar[2].style.top).toBeCloseTo(0.5 * 95);
|
|
774
|
+
});
|
|
775
|
+
});
|
package/src/controls.tsx
CHANGED
|
@@ -152,6 +152,8 @@ export interface SliderProps {
|
|
|
152
152
|
height?: number;
|
|
153
153
|
/** Vertical fader — drag up for more, fill rises from the bottom. */
|
|
154
154
|
vertical?: boolean;
|
|
155
|
+
/** Flat hardware-style bar thumb instead of the dot. */
|
|
156
|
+
barThumb?: boolean;
|
|
155
157
|
label?: string;
|
|
156
158
|
disabled?: boolean;
|
|
157
159
|
/** Double-click resets to this (DAW convention). */
|
|
@@ -170,6 +172,7 @@ export function Slider({
|
|
|
170
172
|
width = 160,
|
|
171
173
|
height = 160,
|
|
172
174
|
vertical,
|
|
175
|
+
barThumb,
|
|
173
176
|
label,
|
|
174
177
|
disabled,
|
|
175
178
|
defaultValue,
|
|
@@ -223,10 +226,17 @@ export function Slider({
|
|
|
223
226
|
className="absolute w-[4] rounded-full left-[7] bottom-0"
|
|
224
227
|
style={{ height: clamped * height, backgroundColor: valueColor }}
|
|
225
228
|
/>
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
229
|
+
{barThumb ? (
|
|
230
|
+
<View
|
|
231
|
+
className="absolute w-[18] h-[5] rounded-[2] left-0"
|
|
232
|
+
style={{ top: (1 - clamped) * (height - 5), backgroundColor: valueColor }}
|
|
233
|
+
/>
|
|
234
|
+
) : (
|
|
235
|
+
<View
|
|
236
|
+
className="absolute w-[12] h-[12] rounded-full left-[3]"
|
|
237
|
+
style={{ top: (1 - clamped) * (height - 12), backgroundColor: valueColor }}
|
|
238
|
+
/>
|
|
239
|
+
)}
|
|
230
240
|
</View>
|
|
231
241
|
{label !== undefined ? (
|
|
232
242
|
<Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
|
|
@@ -251,10 +261,17 @@ export function Slider({
|
|
|
251
261
|
className="absolute h-[4] rounded-full top-[7]"
|
|
252
262
|
style={{ width: clamped * width, backgroundColor: valueColor }}
|
|
253
263
|
/>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
264
|
+
{barThumb ? (
|
|
265
|
+
<View
|
|
266
|
+
className="absolute w-[5] h-[18] rounded-[2] top-0"
|
|
267
|
+
style={{ left: clamped * (width - 5), backgroundColor: valueColor }}
|
|
268
|
+
/>
|
|
269
|
+
) : (
|
|
270
|
+
<View
|
|
271
|
+
className="absolute w-[12] h-[12] rounded-full top-[3]"
|
|
272
|
+
style={{ left: clamped * (width - 12), backgroundColor: valueColor }}
|
|
273
|
+
/>
|
|
274
|
+
)}
|
|
258
275
|
</View>
|
|
259
276
|
{label !== undefined ? (
|
|
260
277
|
<Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
|
|
@@ -295,6 +312,9 @@ export function ParamSlider({ paramId, label, ...rest }: ParamSliderProps) {
|
|
|
295
312
|
export interface ToggleProps {
|
|
296
313
|
on: boolean;
|
|
297
314
|
label?: string;
|
|
315
|
+
/** Side captions, hardware style: <Toggle offLabel="OFF" onLabel="ON" />. */
|
|
316
|
+
offLabel?: string;
|
|
317
|
+
onLabel?: string;
|
|
298
318
|
/** Track height; width is 1.8×. Default 22. */
|
|
299
319
|
size?: number;
|
|
300
320
|
disabled?: boolean;
|
|
@@ -308,6 +328,8 @@ export interface ToggleProps {
|
|
|
308
328
|
export function Toggle({
|
|
309
329
|
on,
|
|
310
330
|
label,
|
|
331
|
+
offLabel,
|
|
332
|
+
onLabel,
|
|
311
333
|
size = 22,
|
|
312
334
|
disabled,
|
|
313
335
|
trackColor = "#2A2F27",
|
|
@@ -322,21 +344,39 @@ export function Toggle({
|
|
|
322
344
|
|
|
323
345
|
return (
|
|
324
346
|
<View className="items-center gap-2">
|
|
325
|
-
<View
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
347
|
+
<View className="flex-row items-center gap-2">
|
|
348
|
+
{offLabel !== undefined ? (
|
|
349
|
+
<Text
|
|
350
|
+
className="text-[9] font-bold tracking-widest"
|
|
351
|
+
style={{ color: on ? "#6f6e66" : "#ECF2E8" }}
|
|
352
|
+
>
|
|
353
|
+
{offLabel}
|
|
354
|
+
</Text>
|
|
355
|
+
) : null}
|
|
330
356
|
<View
|
|
331
|
-
className=
|
|
332
|
-
style={{
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
357
|
+
className={`relative rounded-full ${disabled ? "opacity-40" : "cursor-pointer"}`}
|
|
358
|
+
style={{ width: trackWidth, height: size, backgroundColor: on ? onColor : trackColor }}
|
|
359
|
+
onClick={disabled ? undefined : () => onChange(!on)}
|
|
360
|
+
>
|
|
361
|
+
<View
|
|
362
|
+
className="absolute rounded-full"
|
|
363
|
+
style={{
|
|
364
|
+
width: thumb,
|
|
365
|
+
height: thumb,
|
|
366
|
+
top: 3,
|
|
367
|
+
left: 3 + clamp01(t) * travel,
|
|
368
|
+
backgroundColor: thumbColor,
|
|
369
|
+
}}
|
|
370
|
+
/>
|
|
371
|
+
</View>
|
|
372
|
+
{onLabel !== undefined ? (
|
|
373
|
+
<Text
|
|
374
|
+
className="text-[9] font-bold tracking-widest"
|
|
375
|
+
style={{ color: on ? "#ECF2E8" : "#6f6e66" }}
|
|
376
|
+
>
|
|
377
|
+
{onLabel}
|
|
378
|
+
</Text>
|
|
379
|
+
) : null}
|
|
340
380
|
</View>
|
|
341
381
|
{label !== undefined ? (
|
|
342
382
|
<Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
|
|
@@ -592,8 +632,43 @@ export interface GenericEditorProps {
|
|
|
592
632
|
valueColor?: string;
|
|
593
633
|
}
|
|
594
634
|
|
|
595
|
-
/**
|
|
596
|
-
|
|
635
|
+
/** One GenericEditor cell: knob + live value label + name. */
|
|
636
|
+
function GenericEditorKnob({
|
|
637
|
+
paramId,
|
|
638
|
+
size,
|
|
639
|
+
trackColor,
|
|
640
|
+
valueColor,
|
|
641
|
+
}: {
|
|
642
|
+
paramId: string;
|
|
643
|
+
size?: number;
|
|
644
|
+
trackColor?: string;
|
|
645
|
+
valueColor?: string;
|
|
646
|
+
}) {
|
|
647
|
+
const param = useParameter(paramId);
|
|
648
|
+
|
|
649
|
+
return (
|
|
650
|
+
<View className="items-center gap-1">
|
|
651
|
+
<Knob
|
|
652
|
+
value={param.value}
|
|
653
|
+
size={size}
|
|
654
|
+
defaultValue={param.defaultValue}
|
|
655
|
+
trackColor={trackColor}
|
|
656
|
+
valueColor={valueColor}
|
|
657
|
+
onChange={param.set}
|
|
658
|
+
onBegin={param.begin}
|
|
659
|
+
onEnd={param.end}
|
|
660
|
+
/>
|
|
661
|
+
<Text className="text-text text-[11] font-bold">{param.text}</Text>
|
|
662
|
+
<Text className="text-faint text-[9] font-bold tracking-widest">
|
|
663
|
+
{param.name.toUpperCase()}
|
|
664
|
+
</Text>
|
|
665
|
+
</View>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** The zero-effort editor: one knob per APVTS parameter with a live
|
|
670
|
+
value label and name under each, laid out in rows.
|
|
671
|
+
`render(<GenericEditor />)` is a complete, working plugin UI. */
|
|
597
672
|
export function GenericEditor({ columns = 4, size = 72, trackColor, valueColor }: GenericEditorProps) {
|
|
598
673
|
const params = useParameterList();
|
|
599
674
|
|
|
@@ -606,7 +681,7 @@ export function GenericEditor({ columns = 4, size = 72, trackColor, valueColor }
|
|
|
606
681
|
{rows.map((row, index) => (
|
|
607
682
|
<View key={index} className="flex-row gap-7">
|
|
608
683
|
{row.map((param) => (
|
|
609
|
-
<
|
|
684
|
+
<GenericEditorKnob
|
|
610
685
|
key={param.id}
|
|
611
686
|
paramId={param.id}
|
|
612
687
|
size={size}
|