@vsreact/core 0.0.8 → 0.0.10

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.
@@ -8,3 +8,7 @@ export { Button } from "./button";
8
8
  export type { ButtonProps } from "./button";
9
9
  export { Tooltip, Modal } from "./popover";
10
10
  export type { TooltipProps, ModalProps } from "./popover";
11
+ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRadioGroup, snapToStep, } from "./fields";
12
+ export type { NumberBoxProps, CheckboxProps, RadioGroupProps, ParamNumberBoxProps, ParamCheckboxProps, ParamRadioGroupProps, } from "./fields";
13
+ export { ProgressBar, Spinner } from "./feedback";
14
+ export type { ProgressBarProps, SpinnerProps } from "./feedback";
@@ -10,3 +10,5 @@ export { Meter, usePeakHold, peakHoldStep } from "./meter";
10
10
  export { Bars, Waveform, useRollingBuffer, pushRolling } from "./visualizers";
11
11
  export { Button } from "./button";
12
12
  export { Tooltip, Modal } from "./popover";
13
+ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRadioGroup, snapToStep, } from "./fields";
14
+ export { ProgressBar, Spinner } from "./feedback";
@@ -148,8 +148,9 @@ export interface GenericEditorProps {
148
148
  trackColor?: string;
149
149
  valueColor?: string;
150
150
  }
151
- /** The zero-effort editor: one knob per APVTS parameter, laid out in
152
- rows. `render(<GenericEditor />)` is a complete, working plugin UI. */
151
+ /** The zero-effort editor: one knob per APVTS parameter with a live
152
+ value label and name under each, laid out in rows.
153
+ `render(<GenericEditor />)` is a complete, working plugin UI. */
153
154
  export declare function GenericEditor({ columns, size, trackColor, valueColor }: GenericEditorProps): import("react").JSX.Element;
154
155
  export interface SelectProps {
155
156
  options: string[];
package/dist/controls.js CHANGED
@@ -152,14 +152,20 @@ export function ParamSegmented({ paramId, options, label, ...rest }) {
152
152
  param.end();
153
153
  }, ...rest }));
154
154
  }
155
- /** The zero-effort editor: one knob per APVTS parameter, laid out in
156
- rows. `render(<GenericEditor />)` is a complete, working plugin UI. */
155
+ /** One GenericEditor cell: knob + live value label + name. */
156
+ function GenericEditorKnob({ paramId, size, trackColor, valueColor, }) {
157
+ const param = useParameter(paramId);
158
+ return (_jsxs(View, { className: "items-center gap-1", children: [_jsx(Knob, { value: param.value, size: size, defaultValue: param.defaultValue, trackColor: trackColor, valueColor: valueColor, onChange: param.set, onBegin: param.begin, onEnd: param.end }), _jsx(Text, { className: "text-text text-[11] font-bold", children: param.text }), _jsx(Text, { className: "text-faint text-[9] font-bold tracking-widest", children: param.name.toUpperCase() })] }));
159
+ }
160
+ /** The zero-effort editor: one knob per APVTS parameter with a live
161
+ value label and name under each, laid out in rows.
162
+ `render(<GenericEditor />)` is a complete, working plugin UI. */
157
163
  export function GenericEditor({ columns = 4, size = 72, trackColor, valueColor }) {
158
164
  const params = useParameterList();
159
165
  const rows = [];
160
166
  for (let i = 0; i < params.length; i += Math.max(1, columns))
161
167
  rows.push(params.slice(i, i + Math.max(1, columns)));
162
- return (_jsx(View, { className: "flex-1 flex-col items-center justify-center gap-6 p-4", children: rows.map((row, index) => (_jsx(View, { className: "flex-row gap-7", children: row.map((param) => (_jsx(ParamKnob, { paramId: param.id, size: size, trackColor: trackColor, valueColor: valueColor }, param.id))) }, index))) }));
168
+ return (_jsx(View, { className: "flex-1 flex-col items-center justify-center gap-6 p-4", children: rows.map((row, index) => (_jsx(View, { className: "flex-row gap-7", children: row.map((param) => (_jsx(GenericEditorKnob, { paramId: param.id, size: size, trackColor: trackColor, valueColor: valueColor }, param.id))) }, index))) }));
163
169
  }
164
170
  const SELECT_ROW_HEIGHT = 30;
165
171
  /** A dropdown: the menu renders in the overlay layer, positioned under
@@ -0,0 +1,24 @@
1
+ export interface ProgressBarProps {
2
+ /** 0..1. */
3
+ value: number;
4
+ width?: number;
5
+ height?: number;
6
+ /** Renders "42%" alongside the bar. Default false. */
7
+ showPercent?: boolean;
8
+ trackColor?: string;
9
+ color?: string;
10
+ label?: string;
11
+ }
12
+ /** A determinate progress bar — downloads, renders, analysis passes. */
13
+ export declare function ProgressBar({ value, width, height, showPercent, trackColor, color, label, }: ProgressBarProps): import("react").JSX.Element;
14
+ export interface SpinnerProps {
15
+ /** Diameter. Default 28. */
16
+ size?: number;
17
+ thickness?: number;
18
+ color?: string;
19
+ trackColor?: string;
20
+ /** Degrees per 16ms tick. Default 9 (~ one turn / 0.64s). */
21
+ speed?: number;
22
+ }
23
+ /** An indeterminate spinner — a 100° arc chasing its own tail. */
24
+ export declare function Spinner({ size, thickness, color, trackColor, speed, }: SpinnerProps): import("react").JSX.Element;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Feedback components — determinate progress and the indeterminate
3
+ // spinner, both painted natively.
4
+ import { useState } from "react";
5
+ import { View, Text } from "./primitives";
6
+ import { useInterval } from "./hooks";
7
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
8
+ /** A determinate progress bar — downloads, renders, analysis passes. */
9
+ export function ProgressBar({ value, width = 200, height = 8, showPercent = false, trackColor = "#2A2F27", color = "#C6F135", label, }) {
10
+ const level = clamp01(value);
11
+ const bar = (_jsxs(View, { className: "flex-row items-center gap-3", children: [_jsx(View, { className: "rounded-full overflow-hidden", style: { width, height, backgroundColor: trackColor }, children: _jsx(View, { className: "rounded-full", style: { width: level * width, height, backgroundColor: color } }) }), showPercent ? (_jsx(Text, { className: "text-faint text-[11] font-bold", style: { width: 34 }, children: `${Math.round(level * 100)}%` })) : null] }));
12
+ if (label === undefined)
13
+ return bar;
14
+ return (_jsxs(View, { className: "items-center gap-2", children: [bar, _jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })] }));
15
+ }
16
+ /** An indeterminate spinner — a 100° arc chasing its own tail. */
17
+ export function Spinner({ size = 28, thickness, color = "#C6F135", trackColor = "#FFFFFF14", speed = 9, }) {
18
+ const [angle, setAngle] = useState(0);
19
+ useInterval(() => setAngle((a) => (a + speed) % 360), 16);
20
+ return (_jsx(View, { style: {
21
+ width: size,
22
+ height: size,
23
+ arcTrackColor: trackColor,
24
+ arcColor: color,
25
+ arcStart: 0,
26
+ arcEnd: 360,
27
+ arcValueStart: angle,
28
+ arcValueEnd: angle + 100,
29
+ arcThickness: thickness ?? Math.max(2.5, size * 0.11),
30
+ } }));
31
+ }
@@ -0,0 +1,79 @@
1
+ /** Snap onto the step grid without float dust. */
2
+ export declare function snapToStep(value: number, step: number, min: number): number;
3
+ export interface NumberBoxProps {
4
+ value: number;
5
+ min?: number;
6
+ max?: number;
7
+ /** Value change per step; drags move ~a step per 4px. Default 1. */
8
+ step?: number;
9
+ /** Renders the value. Default: trimmed to 2 decimals. */
10
+ format?: (value: number) => string;
11
+ label?: string;
12
+ width?: number;
13
+ disabled?: boolean;
14
+ /** Double-click resets to this. */
15
+ defaultValue?: number;
16
+ trackColor?: string;
17
+ textColor?: string;
18
+ onChange: (value: number) => void;
19
+ onBegin?: () => void;
20
+ onEnd?: () => void;
21
+ }
22
+ /** The draggable number — BPM, milliseconds, semitones. Drag vertically,
23
+ wheel to step, double-click to reset. */
24
+ export declare function NumberBox({ value, min, max, step, format, label, width, disabled, defaultValue, trackColor, textColor, onChange, onBegin, onEnd, }: NumberBoxProps): import("react").JSX.Element;
25
+ export interface ParamNumberBoxProps {
26
+ paramId: string;
27
+ label?: string;
28
+ width?: number;
29
+ /** Normalized step per wheel notch / drag unit. Default 0.01. */
30
+ step?: number;
31
+ }
32
+ /** A NumberBox over a host parameter: shows the host's formatted text,
33
+ drags the normalized value, double-click resets to the default. */
34
+ export declare function ParamNumberBox({ paramId, label, width, step }: ParamNumberBoxProps): import("react").JSX.Element;
35
+ export interface CheckboxProps {
36
+ checked: boolean;
37
+ label?: string;
38
+ disabled?: boolean;
39
+ /** Box edge length. Default 16. */
40
+ size?: number;
41
+ accentColor?: string;
42
+ boxColor?: string;
43
+ textColor?: string;
44
+ onChange: (checked: boolean) => void;
45
+ }
46
+ /** A checkbox row — settings panels, option lists. */
47
+ export declare function Checkbox({ checked, label, disabled, size, accentColor, boxColor, textColor, onChange, }: CheckboxProps): import("react").JSX.Element;
48
+ export interface ParamCheckboxProps {
49
+ paramId: string;
50
+ label?: string;
51
+ size?: number;
52
+ accentColor?: string;
53
+ }
54
+ /** A Checkbox bound to a bool-style parameter (checked = value ≥ 0.5). */
55
+ export declare function ParamCheckbox({ paramId, label, ...rest }: ParamCheckboxProps): import("react").JSX.Element;
56
+ export interface RadioGroupProps {
57
+ options: string[];
58
+ index: number;
59
+ disabled?: boolean;
60
+ /** Row spacing. Default 8. */
61
+ gap?: number;
62
+ accentColor?: string;
63
+ dotColor?: string;
64
+ textColor?: string;
65
+ activeTextColor?: string;
66
+ onChange: (index: number) => void;
67
+ }
68
+ /** Vertical exclusive options with dots — the settings-panel sibling of
69
+ <Segmented>. */
70
+ export declare function RadioGroup({ options, index, disabled, gap, accentColor, dotColor, textColor, activeTextColor, onChange, }: RadioGroupProps): import("react").JSX.Element;
71
+ export interface ParamRadioGroupProps {
72
+ paramId: string;
73
+ options: string[];
74
+ gap?: number;
75
+ accentColor?: string;
76
+ }
77
+ /** A RadioGroup bound to a choice-style parameter (value ↔ index, like
78
+ ParamSegmented). */
79
+ export declare function ParamRadioGroup({ paramId, options, ...rest }: ParamRadioGroupProps): import("react").JSX.Element;
package/dist/fields.js ADDED
@@ -0,0 +1,73 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Field controls — the number box, checkbox, and radio group. Settings
3
+ // panels and fine-value entry, with the same DAW conventions as the
4
+ // knobs: drag, wheel, double-click reset, automation-safe Param twins.
5
+ import { useRef } from "react";
6
+ import { View, Text } from "./primitives";
7
+ import { useParameter } from "./parameters";
8
+ const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
9
+ /** Snap onto the step grid without float dust. */
10
+ export function snapToStep(value, step, min) {
11
+ const steps = Math.round((value - min) / step);
12
+ return Number((min + steps * step).toPrecision(12));
13
+ }
14
+ /** The draggable number — BPM, milliseconds, semitones. Drag vertically,
15
+ wheel to step, double-click to reset. */
16
+ export function NumberBox({ value, min = 0, max = 100, step = 1, format = (v) => String(Math.round(v * 100) / 100), label, width = 84, disabled, defaultValue, trackColor = "#2A2F27", textColor = "#ECF2E8", onChange, onBegin, onEnd, }) {
17
+ const startValue = useRef(0);
18
+ const clamped = clamp(value, min, max);
19
+ const commit = (raw) => onChange(clamp(snapToStep(raw, step, min), min, max));
20
+ const nudge = (raw) => {
21
+ onBegin?.();
22
+ commit(raw);
23
+ onEnd?.();
24
+ };
25
+ return (_jsxs(View, { className: "items-center gap-2", children: [_jsx(View, { className: `items-center rounded-lg border px-3 py-[7] ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width, backgroundColor: trackColor, borderColor: "#00000055" }, onDragStart: disabled
26
+ ? undefined
27
+ : () => {
28
+ startValue.current = clamped;
29
+ onBegin?.();
30
+ }, onDrag: disabled ? undefined : (e) => commit(startValue.current - (e.dy * step) / 4), onDragEnd: disabled ? undefined : () => onEnd?.(), onDoubleClick: disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue), onWheel: disabled ? undefined : (e) => nudge(clamped + (e.dy > 0 ? step : -step)), children: _jsx(Text, { className: "text-[13] font-bold", style: { color: textColor }, children: format(clamped) }) }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
31
+ }
32
+ /** A NumberBox over a host parameter: shows the host's formatted text,
33
+ drags the normalized value, double-click resets to the default. */
34
+ export function ParamNumberBox({ paramId, label, width, step = 0.01 }) {
35
+ const param = useParameter(paramId);
36
+ return (_jsx(NumberBox, { value: param.value, min: 0, max: 1, step: step, format: () => param.text, label: label ?? param.name.toUpperCase(), width: width, defaultValue: param.defaultValue, onChange: param.set, onBegin: param.begin, onEnd: param.end }));
37
+ }
38
+ /** A checkbox row — settings panels, option lists. */
39
+ export function Checkbox({ checked, label, disabled, size = 16, accentColor = "#C6F135", boxColor = "#2A2F27", textColor = "#d4d4d8", onChange, }) {
40
+ return (_jsxs(View, { className: `flex-row items-center gap-2 ${disabled ? "opacity-40" : "cursor-pointer"}`, onClick: disabled ? undefined : () => onChange(!checked), children: [_jsx(View, { className: "items-center justify-center rounded-[4] border", style: {
41
+ width: size,
42
+ height: size,
43
+ backgroundColor: checked ? accentColor : boxColor,
44
+ borderColor: "#00000066",
45
+ }, children: checked ? (_jsx(Text, { className: "font-bold", style: { fontSize: size * 0.7, color: "#09090b" }, children: "\u2713" })) : null }), label !== undefined ? (_jsx(Text, { className: "text-[12]", style: { color: textColor }, children: label })) : null] }));
46
+ }
47
+ /** A Checkbox bound to a bool-style parameter (checked = value ≥ 0.5). */
48
+ export function ParamCheckbox({ paramId, label, ...rest }) {
49
+ const param = useParameter(paramId);
50
+ return (_jsx(Checkbox, { checked: param.value >= 0.5, label: label ?? param.name, onChange: (next) => {
51
+ param.begin();
52
+ param.set(next ? 1 : 0);
53
+ param.end();
54
+ }, ...rest }));
55
+ }
56
+ /** Vertical exclusive options with dots — the settings-panel sibling of
57
+ <Segmented>. */
58
+ export function RadioGroup({ options, index, disabled, gap = 8, accentColor = "#C6F135", dotColor = "#2A2F27", textColor = "#a1a1aa", activeTextColor = "#ECF2E8", onChange, }) {
59
+ const current = Math.min(options.length - 1, Math.max(0, index));
60
+ return (_jsx(View, { className: disabled ? "opacity-40" : "", style: { rowGap: gap }, children: options.map((option, i) => (_jsxs(View, { className: `flex-row items-center gap-2 ${disabled ? "" : "cursor-pointer"}`, onClick: disabled ? undefined : () => onChange(i), children: [_jsx(View, { className: "items-center justify-center rounded-full border", style: { width: 15, height: 15, backgroundColor: dotColor, borderColor: "#00000066" }, children: i === current ? (_jsx(View, { className: "rounded-full", style: { width: 7, height: 7, backgroundColor: accentColor } })) : null }), _jsx(Text, { className: "text-[12]", style: { color: i === current ? activeTextColor : textColor }, children: option })] }, `${option}-${i}`))) }));
61
+ }
62
+ /** A RadioGroup bound to a choice-style parameter (value ↔ index, like
63
+ ParamSegmented). */
64
+ export function ParamRadioGroup({ paramId, options, ...rest }) {
65
+ const param = useParameter(paramId);
66
+ const count = Math.max(1, options.length);
67
+ const index = Math.round(param.value * (count - 1));
68
+ return (_jsx(RadioGroup, { options: options, index: index, onChange: (i) => {
69
+ param.begin();
70
+ param.set(count <= 1 ? 0 : i / (count - 1));
71
+ param.end();
72
+ }, ...rest }));
73
+ }
package/dist/index.d.ts CHANGED
@@ -24,4 +24,8 @@ export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKno
24
24
  export type { KnobProps, SliderProps, ToggleProps, XYPadProps, SegmentedProps, SelectProps, GenericEditorProps, ParamKnobProps, ParamSliderProps, ParamToggleProps, ParamXYPadProps, ParamSegmentedProps, ParamSelectProps, } from "./controls";
25
25
  export { Meter, usePeakHold, peakHoldStep } from "./meter";
26
26
  export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
27
+ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRadioGroup, snapToStep, } from "./fields";
28
+ export type { NumberBoxProps, CheckboxProps, RadioGroupProps, ParamNumberBoxProps, ParamCheckboxProps, ParamRadioGroupProps, } from "./fields";
29
+ export { ProgressBar, Spinner } from "./feedback";
30
+ export type { ProgressBarProps, SpinnerProps } from "./feedback";
27
31
  export type { DragEventPayload, LayoutRect, WheelEventPayload } from "./primitives";
package/dist/index.js CHANGED
@@ -16,3 +16,5 @@ export { useTween, useSpring, springStep, lerp, Easing } from "./animation";
16
16
  export { useParameter, useParameterList } from "./parameters";
17
17
  export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, ParamSelect, dragToValue, } from "./controls";
18
18
  export { Meter, usePeakHold, peakHoldStep } from "./meter";
19
+ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRadioGroup, snapToStep, } from "./fields";
20
+ export { ProgressBar, Spinner } from "./feedback";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vsreact/core",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "Write React. Ship native VST. A React renderer for JUCE audio plugins — QuickJS + Yoga + juce::Graphics, no webview.",
5
5
  "keywords": [
6
6
  "react",
package/src/components.ts CHANGED
@@ -45,3 +45,22 @@ 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";
@@ -542,3 +542,125 @@ 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
+ });
package/src/controls.tsx CHANGED
@@ -592,8 +592,43 @@ export interface GenericEditorProps {
592
592
  valueColor?: string;
593
593
  }
594
594
 
595
- /** The zero-effort editor: one knob per APVTS parameter, laid out in
596
- rows. `render(<GenericEditor />)` is a complete, working plugin UI. */
595
+ /** One GenericEditor cell: knob + live value label + name. */
596
+ function GenericEditorKnob({
597
+ paramId,
598
+ size,
599
+ trackColor,
600
+ valueColor,
601
+ }: {
602
+ paramId: string;
603
+ size?: number;
604
+ trackColor?: string;
605
+ valueColor?: string;
606
+ }) {
607
+ const param = useParameter(paramId);
608
+
609
+ return (
610
+ <View className="items-center gap-1">
611
+ <Knob
612
+ value={param.value}
613
+ size={size}
614
+ defaultValue={param.defaultValue}
615
+ trackColor={trackColor}
616
+ valueColor={valueColor}
617
+ onChange={param.set}
618
+ onBegin={param.begin}
619
+ onEnd={param.end}
620
+ />
621
+ <Text className="text-text text-[11] font-bold">{param.text}</Text>
622
+ <Text className="text-faint text-[9] font-bold tracking-widest">
623
+ {param.name.toUpperCase()}
624
+ </Text>
625
+ </View>
626
+ );
627
+ }
628
+
629
+ /** The zero-effort editor: one knob per APVTS parameter with a live
630
+ value label and name under each, laid out in rows.
631
+ `render(<GenericEditor />)` is a complete, working plugin UI. */
597
632
  export function GenericEditor({ columns = 4, size = 72, trackColor, valueColor }: GenericEditorProps) {
598
633
  const params = useParameterList();
599
634
 
@@ -606,7 +641,7 @@ export function GenericEditor({ columns = 4, size = 72, trackColor, valueColor }
606
641
  {rows.map((row, index) => (
607
642
  <View key={index} className="flex-row gap-7">
608
643
  {row.map((param) => (
609
- <ParamKnob
644
+ <GenericEditorKnob
610
645
  key={param.id}
611
646
  paramId={param.id}
612
647
  size={size}
@@ -0,0 +1,99 @@
1
+ // Feedback components — determinate progress and the indeterminate
2
+ // spinner, both painted natively.
3
+
4
+ import { useState } from "react";
5
+ import { View, Text } from "./primitives";
6
+ import { useInterval } from "./hooks";
7
+
8
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
9
+
10
+ export interface ProgressBarProps {
11
+ /** 0..1. */
12
+ value: number;
13
+ width?: number;
14
+ height?: number;
15
+ /** Renders "42%" alongside the bar. Default false. */
16
+ showPercent?: boolean;
17
+ trackColor?: string;
18
+ color?: string;
19
+ label?: string;
20
+ }
21
+
22
+ /** A determinate progress bar — downloads, renders, analysis passes. */
23
+ export function ProgressBar({
24
+ value,
25
+ width = 200,
26
+ height = 8,
27
+ showPercent = false,
28
+ trackColor = "#2A2F27",
29
+ color = "#C6F135",
30
+ label,
31
+ }: ProgressBarProps) {
32
+ const level = clamp01(value);
33
+
34
+ const bar = (
35
+ <View className="flex-row items-center gap-3">
36
+ <View
37
+ className="rounded-full overflow-hidden"
38
+ style={{ width, height, backgroundColor: trackColor }}
39
+ >
40
+ <View
41
+ className="rounded-full"
42
+ style={{ width: level * width, height, backgroundColor: color }}
43
+ />
44
+ </View>
45
+ {showPercent ? (
46
+ <Text className="text-faint text-[11] font-bold" style={{ width: 34 }}>
47
+ {`${Math.round(level * 100)}%`}
48
+ </Text>
49
+ ) : null}
50
+ </View>
51
+ );
52
+
53
+ if (label === undefined) return bar;
54
+
55
+ return (
56
+ <View className="items-center gap-2">
57
+ {bar}
58
+ <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
59
+ </View>
60
+ );
61
+ }
62
+
63
+ export interface SpinnerProps {
64
+ /** Diameter. Default 28. */
65
+ size?: number;
66
+ thickness?: number;
67
+ color?: string;
68
+ trackColor?: string;
69
+ /** Degrees per 16ms tick. Default 9 (~ one turn / 0.64s). */
70
+ speed?: number;
71
+ }
72
+
73
+ /** An indeterminate spinner — a 100° arc chasing its own tail. */
74
+ export function Spinner({
75
+ size = 28,
76
+ thickness,
77
+ color = "#C6F135",
78
+ trackColor = "#FFFFFF14",
79
+ speed = 9,
80
+ }: SpinnerProps) {
81
+ const [angle, setAngle] = useState(0);
82
+ useInterval(() => setAngle((a) => (a + speed) % 360), 16);
83
+
84
+ return (
85
+ <View
86
+ style={{
87
+ width: size,
88
+ height: size,
89
+ arcTrackColor: trackColor,
90
+ arcColor: color,
91
+ arcStart: 0,
92
+ arcEnd: 360,
93
+ arcValueStart: angle,
94
+ arcValueEnd: angle + 100,
95
+ arcThickness: thickness ?? Math.max(2.5, size * 0.11),
96
+ }}
97
+ />
98
+ );
99
+ }
package/src/fields.tsx ADDED
@@ -0,0 +1,288 @@
1
+ // Field controls — the number box, checkbox, and radio group. Settings
2
+ // panels and fine-value entry, with the same DAW conventions as the
3
+ // knobs: drag, wheel, double-click reset, automation-safe Param twins.
4
+
5
+ import { useRef } from "react";
6
+ import { View, Text } from "./primitives";
7
+ import { useParameter } from "./parameters";
8
+
9
+ const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
10
+
11
+ /** Snap onto the step grid without float dust. */
12
+ export function snapToStep(value: number, step: number, min: number): number {
13
+ const steps = Math.round((value - min) / step);
14
+ return Number((min + steps * step).toPrecision(12));
15
+ }
16
+
17
+ export interface NumberBoxProps {
18
+ value: number;
19
+ min?: number;
20
+ max?: number;
21
+ /** Value change per step; drags move ~a step per 4px. Default 1. */
22
+ step?: number;
23
+ /** Renders the value. Default: trimmed to 2 decimals. */
24
+ format?: (value: number) => string;
25
+ label?: string;
26
+ width?: number;
27
+ disabled?: boolean;
28
+ /** Double-click resets to this. */
29
+ defaultValue?: number;
30
+ trackColor?: string;
31
+ textColor?: string;
32
+ onChange: (value: number) => void;
33
+ onBegin?: () => void;
34
+ onEnd?: () => void;
35
+ }
36
+
37
+ /** The draggable number — BPM, milliseconds, semitones. Drag vertically,
38
+ wheel to step, double-click to reset. */
39
+ export function NumberBox({
40
+ value,
41
+ min = 0,
42
+ max = 100,
43
+ step = 1,
44
+ format = (v) => String(Math.round(v * 100) / 100),
45
+ label,
46
+ width = 84,
47
+ disabled,
48
+ defaultValue,
49
+ trackColor = "#2A2F27",
50
+ textColor = "#ECF2E8",
51
+ onChange,
52
+ onBegin,
53
+ onEnd,
54
+ }: NumberBoxProps) {
55
+ const startValue = useRef(0);
56
+ const clamped = clamp(value, min, max);
57
+
58
+ const commit = (raw: number) => onChange(clamp(snapToStep(raw, step, min), min, max));
59
+
60
+ const nudge = (raw: number) => {
61
+ onBegin?.();
62
+ commit(raw);
63
+ onEnd?.();
64
+ };
65
+
66
+ return (
67
+ <View className="items-center gap-2">
68
+ <View
69
+ className={`items-center rounded-lg border px-3 py-[7] ${disabled ? "opacity-40" : "cursor-pointer"}`}
70
+ style={{ width, backgroundColor: trackColor, borderColor: "#00000055" }}
71
+ onDragStart={
72
+ disabled
73
+ ? undefined
74
+ : () => {
75
+ startValue.current = clamped;
76
+ onBegin?.();
77
+ }
78
+ }
79
+ onDrag={disabled ? undefined : (e) => commit(startValue.current - (e.dy * step) / 4)}
80
+ onDragEnd={disabled ? undefined : () => onEnd?.()}
81
+ onDoubleClick={
82
+ disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue)
83
+ }
84
+ onWheel={
85
+ disabled ? undefined : (e) => nudge(clamped + (e.dy > 0 ? step : -step))
86
+ }
87
+ >
88
+ <Text className="text-[13] font-bold" style={{ color: textColor }}>
89
+ {format(clamped)}
90
+ </Text>
91
+ </View>
92
+ {label !== undefined ? (
93
+ <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
94
+ ) : null}
95
+ </View>
96
+ );
97
+ }
98
+
99
+ export interface ParamNumberBoxProps {
100
+ paramId: string;
101
+ label?: string;
102
+ width?: number;
103
+ /** Normalized step per wheel notch / drag unit. Default 0.01. */
104
+ step?: number;
105
+ }
106
+
107
+ /** A NumberBox over a host parameter: shows the host's formatted text,
108
+ drags the normalized value, double-click resets to the default. */
109
+ export function ParamNumberBox({ paramId, label, width, step = 0.01 }: ParamNumberBoxProps) {
110
+ const param = useParameter(paramId);
111
+
112
+ return (
113
+ <NumberBox
114
+ value={param.value}
115
+ min={0}
116
+ max={1}
117
+ step={step}
118
+ format={() => param.text}
119
+ label={label ?? param.name.toUpperCase()}
120
+ width={width}
121
+ defaultValue={param.defaultValue}
122
+ onChange={param.set}
123
+ onBegin={param.begin}
124
+ onEnd={param.end}
125
+ />
126
+ );
127
+ }
128
+
129
+ export interface CheckboxProps {
130
+ checked: boolean;
131
+ label?: string;
132
+ disabled?: boolean;
133
+ /** Box edge length. Default 16. */
134
+ size?: number;
135
+ accentColor?: string;
136
+ boxColor?: string;
137
+ textColor?: string;
138
+ onChange: (checked: boolean) => void;
139
+ }
140
+
141
+ /** A checkbox row — settings panels, option lists. */
142
+ export function Checkbox({
143
+ checked,
144
+ label,
145
+ disabled,
146
+ size = 16,
147
+ accentColor = "#C6F135",
148
+ boxColor = "#2A2F27",
149
+ textColor = "#d4d4d8",
150
+ onChange,
151
+ }: CheckboxProps) {
152
+ return (
153
+ <View
154
+ className={`flex-row items-center gap-2 ${disabled ? "opacity-40" : "cursor-pointer"}`}
155
+ onClick={disabled ? undefined : () => onChange(!checked)}
156
+ >
157
+ <View
158
+ className="items-center justify-center rounded-[4] border"
159
+ style={{
160
+ width: size,
161
+ height: size,
162
+ backgroundColor: checked ? accentColor : boxColor,
163
+ borderColor: "#00000066",
164
+ }}
165
+ >
166
+ {checked ? (
167
+ <Text className="font-bold" style={{ fontSize: size * 0.7, color: "#09090b" }}>
168
+
169
+ </Text>
170
+ ) : null}
171
+ </View>
172
+ {label !== undefined ? (
173
+ <Text className="text-[12]" style={{ color: textColor }}>
174
+ {label}
175
+ </Text>
176
+ ) : null}
177
+ </View>
178
+ );
179
+ }
180
+
181
+ export interface ParamCheckboxProps {
182
+ paramId: string;
183
+ label?: string;
184
+ size?: number;
185
+ accentColor?: string;
186
+ }
187
+
188
+ /** A Checkbox bound to a bool-style parameter (checked = value ≥ 0.5). */
189
+ export function ParamCheckbox({ paramId, label, ...rest }: ParamCheckboxProps) {
190
+ const param = useParameter(paramId);
191
+
192
+ return (
193
+ <Checkbox
194
+ checked={param.value >= 0.5}
195
+ label={label ?? param.name}
196
+ onChange={(next) => {
197
+ param.begin();
198
+ param.set(next ? 1 : 0);
199
+ param.end();
200
+ }}
201
+ {...rest}
202
+ />
203
+ );
204
+ }
205
+
206
+ export interface RadioGroupProps {
207
+ options: string[];
208
+ index: number;
209
+ disabled?: boolean;
210
+ /** Row spacing. Default 8. */
211
+ gap?: number;
212
+ accentColor?: string;
213
+ dotColor?: string;
214
+ textColor?: string;
215
+ activeTextColor?: string;
216
+ onChange: (index: number) => void;
217
+ }
218
+
219
+ /** Vertical exclusive options with dots — the settings-panel sibling of
220
+ <Segmented>. */
221
+ export function RadioGroup({
222
+ options,
223
+ index,
224
+ disabled,
225
+ gap = 8,
226
+ accentColor = "#C6F135",
227
+ dotColor = "#2A2F27",
228
+ textColor = "#a1a1aa",
229
+ activeTextColor = "#ECF2E8",
230
+ onChange,
231
+ }: RadioGroupProps) {
232
+ const current = Math.min(options.length - 1, Math.max(0, index));
233
+
234
+ return (
235
+ <View className={disabled ? "opacity-40" : ""} style={{ rowGap: gap }}>
236
+ {options.map((option, i) => (
237
+ <View
238
+ key={`${option}-${i}`}
239
+ className={`flex-row items-center gap-2 ${disabled ? "" : "cursor-pointer"}`}
240
+ onClick={disabled ? undefined : () => onChange(i)}
241
+ >
242
+ <View
243
+ className="items-center justify-center rounded-full border"
244
+ style={{ width: 15, height: 15, backgroundColor: dotColor, borderColor: "#00000066" }}
245
+ >
246
+ {i === current ? (
247
+ <View
248
+ className="rounded-full"
249
+ style={{ width: 7, height: 7, backgroundColor: accentColor }}
250
+ />
251
+ ) : null}
252
+ </View>
253
+ <Text className="text-[12]" style={{ color: i === current ? activeTextColor : textColor }}>
254
+ {option}
255
+ </Text>
256
+ </View>
257
+ ))}
258
+ </View>
259
+ );
260
+ }
261
+
262
+ export interface ParamRadioGroupProps {
263
+ paramId: string;
264
+ options: string[];
265
+ gap?: number;
266
+ accentColor?: string;
267
+ }
268
+
269
+ /** A RadioGroup bound to a choice-style parameter (value ↔ index, like
270
+ ParamSegmented). */
271
+ export function ParamRadioGroup({ paramId, options, ...rest }: ParamRadioGroupProps) {
272
+ const param = useParameter(paramId);
273
+ const count = Math.max(1, options.length);
274
+ const index = Math.round(param.value * (count - 1));
275
+
276
+ return (
277
+ <RadioGroup
278
+ options={options}
279
+ index={index}
280
+ onChange={(i) => {
281
+ param.begin();
282
+ param.set(count <= 1 ? 0 : i / (count - 1));
283
+ param.end();
284
+ }}
285
+ {...rest}
286
+ />
287
+ );
288
+ }
package/src/index.ts CHANGED
@@ -71,4 +71,23 @@ export type {
71
71
  } from "./controls";
72
72
  export { Meter, usePeakHold, peakHoldStep } from "./meter";
73
73
  export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
74
+ export {
75
+ NumberBox,
76
+ Checkbox,
77
+ RadioGroup,
78
+ ParamNumberBox,
79
+ ParamCheckbox,
80
+ ParamRadioGroup,
81
+ snapToStep,
82
+ } from "./fields";
83
+ export type {
84
+ NumberBoxProps,
85
+ CheckboxProps,
86
+ RadioGroupProps,
87
+ ParamNumberBoxProps,
88
+ ParamCheckboxProps,
89
+ ParamRadioGroupProps,
90
+ } from "./fields";
91
+ export { ProgressBar, Spinner } from "./feedback";
92
+ export type { ProgressBarProps, SpinnerProps } from "./feedback";
74
93
  export type { DragEventPayload, LayoutRect, WheelEventPayload } from "./primitives";