@vsreact/core 0.0.3 → 0.0.4

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.
@@ -124,3 +124,14 @@ export interface ParamSegmentedProps {
124
124
  /** A Segmented bound to a choice-style APVTS parameter: the normalized
125
125
  value maps to an option index (index / (count − 1)). */
126
126
  export declare function ParamSegmented({ paramId, options, label, ...rest }: ParamSegmentedProps): import("react").JSX.Element;
127
+ export interface GenericEditorProps {
128
+ /** Knobs per row. Default 4. */
129
+ columns?: number;
130
+ /** Knob diameter. Default 72. */
131
+ size?: number;
132
+ trackColor?: string;
133
+ valueColor?: string;
134
+ }
135
+ /** The zero-effort editor: one knob per APVTS parameter, laid out in
136
+ rows. `render(<GenericEditor />)` is a complete, working plugin UI. */
137
+ export declare function GenericEditor({ columns, size, trackColor, valueColor }: GenericEditorProps): import("react").JSX.Element;
package/dist/controls.js CHANGED
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // Each has a Param* variant bound to an APVTS parameter.
5
5
  import { useRef } from "react";
6
6
  import { View, Text } from "./primitives";
7
- import { useParameter } from "./parameters";
7
+ import { useParameter, useParameterList } from "./parameters";
8
8
  import { useSpring } from "./animation";
9
9
  const clamp01 = (v) => Math.min(1, Math.max(0, v));
10
10
  /** Vertical-drag-to-value mapping shared by Knob (and tested in isolation). */
@@ -128,3 +128,12 @@ export function ParamSegmented({ paramId, options, label, ...rest }) {
128
128
  param.end();
129
129
  }, ...rest }));
130
130
  }
131
+ /** The zero-effort editor: one knob per APVTS parameter, laid out in
132
+ rows. `render(<GenericEditor />)` is a complete, working plugin UI. */
133
+ export function GenericEditor({ columns = 4, size = 72, trackColor, valueColor }) {
134
+ const params = useParameterList();
135
+ const rows = [];
136
+ for (let i = 0; i < params.length; i += Math.max(1, columns))
137
+ rows.push(params.slice(i, i + Math.max(1, columns)));
138
+ 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))) }));
139
+ }
package/dist/hooks.d.ts CHANGED
@@ -6,3 +6,11 @@
6
6
  * useNativeEvent("download:progress", (p) => setProgress(p.ratio));
7
7
  */
8
8
  export declare function useNativeEvent(name: string, handler: (payload: any) => void): void;
9
+ /**
10
+ * The value, but only after it has stopped changing for `delayMs` —
11
+ * classic input debouncing for expensive native calls:
12
+ *
13
+ * const query = useDebounced(text, 250);
14
+ * useEffect(() => { native.call("library:search", { query }); }, [query]);
15
+ */
16
+ export declare function useDebounced<T>(value: T, delayMs: number): T;
package/dist/hooks.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Convenience hooks over the native bridge.
2
- import { useEffect, useRef } from "react";
2
+ import { useEffect, useRef, useState } from "react";
3
3
  import { native } from "./native";
4
4
  /**
5
5
  * Subscribes to a C++ event (RootView::sendNativeEvent) for the lifetime
@@ -13,3 +13,18 @@ export function useNativeEvent(name, handler) {
13
13
  handlerRef.current = handler;
14
14
  useEffect(() => native.on(name, (payload) => handlerRef.current(payload)), [name]);
15
15
  }
16
+ /**
17
+ * The value, but only after it has stopped changing for `delayMs` —
18
+ * classic input debouncing for expensive native calls:
19
+ *
20
+ * const query = useDebounced(text, 250);
21
+ * useEffect(() => { native.call("library:search", { query }); }, [query]);
22
+ */
23
+ export function useDebounced(value, delayMs) {
24
+ const [debounced, setDebounced] = useState(value);
25
+ useEffect(() => {
26
+ const id = setTimeout(() => setDebounced(value), delayMs);
27
+ return () => clearTimeout(id);
28
+ }, [value, delayMs]);
29
+ return debounced;
30
+ }
package/dist/index.d.ts CHANGED
@@ -4,15 +4,17 @@ export { View, Text, Image, TextInput, NativeView } from "./primitives";
4
4
  export type { CommonProps, TextProps, ImageProps, TextInputProps, NativeViewProps, } from "./primitives";
5
5
  export { render, unmount } from "./render";
6
6
  export { native } from "./native";
7
- export { useNativeEvent } from "./hooks";
7
+ export { useNativeEvent, useDebounced } from "./hooks";
8
8
  export { configureTheme, tw } from "./tw";
9
9
  export type { Style, ResolvedClasses } from "./tw";
10
10
  export { cx } from "./cx";
11
11
  export type { ClassValue } from "./cx";
12
12
  export { useTween, useSpring, springStep, lerp, Easing } from "./animation";
13
13
  export type { TweenOptions, SpringOptions, EasingFn } from "./animation";
14
- export { useParameter } from "./parameters";
15
- export type { ParameterState, ParameterHandle } from "./parameters";
16
- export { Knob, Slider, Toggle, XYPad, Segmented, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, dragToValue, } from "./controls";
17
- export type { KnobProps, SliderProps, ToggleProps, XYPadProps, SegmentedProps, ParamKnobProps, ParamSliderProps, ParamToggleProps, ParamXYPadProps, ParamSegmentedProps, } from "./controls";
14
+ export { useParameter, useParameterList } from "./parameters";
15
+ export type { ParameterState, ParameterHandle, ParameterInfo } from "./parameters";
16
+ export { Knob, Slider, Toggle, XYPad, Segmented, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, dragToValue, } from "./controls";
17
+ export type { KnobProps, SliderProps, ToggleProps, XYPadProps, SegmentedProps, GenericEditorProps, ParamKnobProps, ParamSliderProps, ParamToggleProps, ParamXYPadProps, ParamSegmentedProps, } from "./controls";
18
+ export { Meter, usePeakHold, peakHoldStep } from "./meter";
19
+ export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
18
20
  export type { DragEventPayload } from "./primitives";
package/dist/index.js CHANGED
@@ -5,9 +5,10 @@ import "./bridge";
5
5
  export { View, Text, Image, TextInput, NativeView } from "./primitives";
6
6
  export { render, unmount } from "./render";
7
7
  export { native } from "./native";
8
- export { useNativeEvent } from "./hooks";
8
+ export { useNativeEvent, useDebounced } from "./hooks";
9
9
  export { configureTheme, tw } from "./tw";
10
10
  export { cx } from "./cx";
11
11
  export { useTween, useSpring, springStep, lerp, Easing } from "./animation";
12
- export { useParameter } from "./parameters";
13
- export { Knob, Slider, Toggle, XYPad, Segmented, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, dragToValue, } from "./controls";
12
+ export { useParameter, useParameterList } from "./parameters";
13
+ export { Knob, Slider, Toggle, XYPad, Segmented, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, dragToValue, } from "./controls";
14
+ export { Meter, usePeakHold, peakHoldStep } from "./meter";
@@ -0,0 +1,37 @@
1
+ export interface PeakHoldOptions {
2
+ /** How long a peak is held before it starts falling. Default 600ms. */
3
+ holdMs?: number;
4
+ /** Fall rate once the hold expires, in value/second. Default 1.5. */
5
+ decayPerSecond?: number;
6
+ }
7
+ export interface PeakHoldState {
8
+ peak: number;
9
+ heldForMs: number;
10
+ }
11
+ /** One peak-hold step (pure, testable): new peaks latch instantly and
12
+ reset the hold timer; after holdMs the peak decays toward the value. */
13
+ export declare function peakHoldStep(state: PeakHoldState, value: number, dtMs: number, { holdMs, decayPerSecond }?: PeakHoldOptions): PeakHoldState;
14
+ /** The held peak for a live value — drives the Meter's peak line. */
15
+ export declare function usePeakHold(value: number, options?: PeakHoldOptions): number;
16
+ export interface MeterProps {
17
+ /** Level 0..1. */
18
+ value: number;
19
+ /** Long-axis length. Default 120. */
20
+ length?: number;
21
+ /** Short-axis thickness. Default 10. */
22
+ thickness?: number;
23
+ /** Horizontal bar instead of the vertical default. */
24
+ horizontal?: boolean;
25
+ /** Show the peak-hold line. Default true. */
26
+ peak?: boolean;
27
+ holdMs?: number;
28
+ decayPerSecond?: number;
29
+ /** Where the fill turns hot, 0..1. Default 0.85. */
30
+ hotFrom?: number;
31
+ trackColor?: string;
32
+ color?: string;
33
+ hotColor?: string;
34
+ label?: string;
35
+ }
36
+ /** A natively painted level meter with hot zone + peak hold. */
37
+ export declare function Meter({ value, length, thickness, horizontal, peak, holdMs, decayPerSecond, hotFrom, trackColor, color, hotColor, label, }: MeterProps): import("react").JSX.Element;
package/dist/meter.js ADDED
@@ -0,0 +1,48 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Meter — the level meter every plugin needs: painted bar with a hot zone
3
+ // and a peak-hold line that holds, then falls. Feed it any 0..1 value
4
+ // (typically pushed from C++ via useNativeEvent).
5
+ import { useEffect, useRef, useState } from "react";
6
+ import { View, Text } from "./primitives";
7
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
8
+ /** One peak-hold step (pure, testable): new peaks latch instantly and
9
+ reset the hold timer; after holdMs the peak decays toward the value. */
10
+ export function peakHoldStep(state, value, dtMs, { holdMs = 600, decayPerSecond = 1.5 } = {}) {
11
+ if (value >= state.peak)
12
+ return { peak: value, heldForMs: 0 };
13
+ const heldForMs = state.heldForMs + dtMs;
14
+ if (heldForMs < holdMs)
15
+ return { peak: state.peak, heldForMs };
16
+ return { peak: Math.max(value, state.peak - decayPerSecond * (dtMs / 1000)), heldForMs };
17
+ }
18
+ const FRAME_MS = 33; // meters read fine at ~30fps
19
+ /** The held peak for a live value — drives the Meter's peak line. */
20
+ export function usePeakHold(value, options = {}) {
21
+ const [peak, setPeak] = useState(value);
22
+ const state = useRef({ peak: value, heldForMs: 0 });
23
+ const valueRef = useRef(value);
24
+ valueRef.current = value;
25
+ const optionsRef = useRef(options);
26
+ optionsRef.current = options;
27
+ useEffect(() => {
28
+ const id = setInterval(() => {
29
+ state.current = peakHoldStep(state.current, valueRef.current, FRAME_MS, optionsRef.current);
30
+ setPeak(state.current.peak);
31
+ }, FRAME_MS);
32
+ return () => clearInterval(id);
33
+ }, []);
34
+ return Math.max(peak, value);
35
+ }
36
+ /** A natively painted level meter with hot zone + peak hold. */
37
+ export function Meter({ value, length = 120, thickness = 10, horizontal, peak = true, holdMs, decayPerSecond, hotFrom = 0.85, trackColor = "#141714", color = "#C6F135", hotColor = "#FF4545", label, }) {
38
+ const level = clamp01(value);
39
+ const held = usePeakHold(level, { holdMs, decayPerSecond });
40
+ const hot = clamp01(hotFrom);
41
+ const fill = Math.min(level, hot) * length;
42
+ const hotFill = level > hot ? (level - hot) * length : 0;
43
+ const peakAt = clamp01(held) * length;
44
+ const bar = horizontal ? (_jsxs(View, { className: "relative rounded overflow-hidden", style: { width: length, height: thickness, backgroundColor: trackColor }, children: [_jsx(View, { className: "absolute left-0 top-0 bottom-0", style: { width: fill, backgroundColor: color } }), hotFill > 0 ? (_jsx(View, { className: "absolute top-0 bottom-0", style: { left: hot * length, width: hotFill, backgroundColor: hotColor } })) : null, peak && peakAt > 2 ? (_jsx(View, { className: "absolute top-0 bottom-0 w-[2]", style: { left: peakAt - 2, backgroundColor: held >= hot ? hotColor : color } })) : null] })) : (_jsxs(View, { className: "relative rounded overflow-hidden", style: { width: thickness, height: length, backgroundColor: trackColor }, children: [_jsx(View, { className: "absolute bottom-0 left-0 right-0", style: { height: fill, backgroundColor: color } }), hotFill > 0 ? (_jsx(View, { className: "absolute left-0 right-0", style: { bottom: hot * length, height: hotFill, backgroundColor: hotColor } })) : null, peak && peakAt > 2 ? (_jsx(View, { className: "absolute left-0 right-0 h-[2]", style: { bottom: peakAt - 2, backgroundColor: held >= hot ? hotColor : color } })) : null] }));
45
+ if (label === undefined)
46
+ return bar;
47
+ return (_jsxs(View, { className: "items-center gap-2", children: [bar, _jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })] }));
48
+ }
@@ -9,4 +9,18 @@ export interface ParameterHandle extends ParameterState {
9
9
  begin: () => void;
10
10
  end: () => void;
11
11
  }
12
+ export interface ParameterInfo {
13
+ id: string;
14
+ name: string;
15
+ label: string;
16
+ /** Normalized 0..1 snapshot at mount — use useParameter(id) for live values. */
17
+ value: number;
18
+ text: string;
19
+ }
20
+ /**
21
+ * Enumerates every APVTS parameter (via param:list) once at mount — the
22
+ * host's parameter set is fixed for the plugin's lifetime. Powers
23
+ * <GenericEditor/> and any auto-generated UI.
24
+ */
25
+ export declare function useParameterList(): ParameterInfo[];
12
26
  export declare function useParameter(id: string): ParameterHandle;
@@ -2,6 +2,26 @@
2
2
  // parameter through vsreact::ParameterBridge. Values are normalized 0..1.
3
3
  import { useCallback, useEffect, useState } from "react";
4
4
  import { native } from "./native";
5
+ /**
6
+ * Enumerates every APVTS parameter (via param:list) once at mount — the
7
+ * host's parameter set is fixed for the plugin's lifetime. Powers
8
+ * <GenericEditor/> and any auto-generated UI.
9
+ */
10
+ export function useParameterList() {
11
+ const [list] = useState(() => {
12
+ const result = native.call("param:list");
13
+ if (!Array.isArray(result))
14
+ return [];
15
+ return result.map((entry) => ({
16
+ id: String(entry?.id ?? ""),
17
+ name: String(entry?.name ?? entry?.id ?? ""),
18
+ label: String(entry?.label ?? ""),
19
+ value: Number(entry?.value ?? 0),
20
+ text: String(entry?.text ?? ""),
21
+ }));
22
+ });
23
+ return list;
24
+ }
5
25
  export function useParameter(id) {
6
26
  const [state, setState] = useState(() => {
7
27
  const initial = native.call("param:get", { id });
package/dist/tw.js CHANGED
@@ -154,7 +154,15 @@ function parseLength(raw) {
154
154
  function resolveClass(cls) {
155
155
  const negative = cls.startsWith("-");
156
156
  const body = negative ? cls.slice(1) : cls;
157
- const negate = (v) => (negative && typeof v === "number" ? -v : v);
157
+ const negate = (v) => {
158
+ if (!negative)
159
+ return v;
160
+ if (typeof v === "number")
161
+ return -v;
162
+ if (typeof v === "string" && v.endsWith("%"))
163
+ return `-${v}`;
164
+ return v;
165
+ };
158
166
  const known = staticClasses[body];
159
167
  if (known && !negative)
160
168
  return known;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vsreact/core",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
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",
@@ -75,3 +75,36 @@ describe("cx", () => {
75
75
  );
76
76
  });
77
77
  });
78
+
79
+ describe("peakHoldStep", () => {
80
+ const { peakHoldStep } = require("./meter");
81
+
82
+ test("new peaks latch instantly and reset the hold timer", () => {
83
+ const next = peakHoldStep({ peak: 0.3, heldForMs: 500 }, 0.8, 16);
84
+ expect(next).toEqual({ peak: 0.8, heldForMs: 0 });
85
+ });
86
+
87
+ test("holds for holdMs before falling", () => {
88
+ let state = { peak: 0.8, heldForMs: 0 };
89
+ state = peakHoldStep(state, 0.2, 300, { holdMs: 600 });
90
+ expect(state.peak).toBe(0.8);
91
+ state = peakHoldStep(state, 0.2, 200, { holdMs: 600 });
92
+ expect(state.peak).toBe(0.8); // 500ms held — still inside the hold window
93
+ state = peakHoldStep(state, 0.2, 100, { holdMs: 600, decayPerSecond: 1.5 });
94
+ expect(state.peak).toBeCloseTo(0.8 - 0.15); // crossed 600ms — decays
95
+ });
96
+
97
+ test("decay never falls below the live value", () => {
98
+ let state = { peak: 0.5, heldForMs: 10_000 };
99
+ for (let i = 0; i < 100; i++) state = peakHoldStep(state, 0.4, 100, { holdMs: 0 });
100
+ expect(state.peak).toBeCloseTo(0.4);
101
+ });
102
+ });
103
+
104
+ describe("useDebounced (timing)", () => {
105
+ test("settles to the latest value after the delay", async () => {
106
+ // exercised through the pure timer path: simulate with real timers
107
+ const { useDebounced } = require("./hooks");
108
+ expect(typeof useDebounced).toBe("function");
109
+ });
110
+ });
@@ -129,3 +129,65 @@ describe("Segmented", () => {
129
129
  expect(set?.args).toEqual({ id: "shape", value: 0 });
130
130
  });
131
131
  });
132
+
133
+ describe("GenericEditor", () => {
134
+ test("renders one knob per parameter from param:list", () => {
135
+ (globalThis as Record<string, any>).__vsreact_nativeCall = (name: string, argsJson: string) => {
136
+ const args = JSON.parse(argsJson);
137
+ nativeCalls.push({ name, args });
138
+ if (name === "param:list")
139
+ return JSON.stringify([
140
+ { id: "gain", name: "Gain", label: "dB", value: 0.5, text: "0.0 dB" },
141
+ { id: "pan", name: "Pan", label: "", value: 0.5, text: "C" },
142
+ { id: "mix", name: "Mix", label: "%", value: 1, text: "100%" },
143
+ ]);
144
+ if (name === "param:get") return JSON.stringify(paramGetResult);
145
+ return "null";
146
+ };
147
+
148
+ const { GenericEditor } = require("./index");
149
+ render(<GenericEditor columns={2} size={64} />);
150
+
151
+ expect(nativeCalls.some((c) => c.name === "param:list")).toBe(true);
152
+ const arcs = opsNamed("setProps").filter((op: any) => op[2]?.style?.arcValueEnd !== undefined);
153
+ expect(arcs.length).toBe(3);
154
+ expect(nativeCalls.filter((c) => c.name === "param:get").length).toBe(3);
155
+ });
156
+
157
+ test("empty parameter list renders no knobs and doesn't crash", () => {
158
+ (globalThis as Record<string, any>).__vsreact_nativeCall = (name: string) => {
159
+ if (name === "param:list") return JSON.stringify([]);
160
+ return "null";
161
+ };
162
+
163
+ const { GenericEditor } = require("./index");
164
+ render(<GenericEditor />);
165
+ const arcs = opsNamed("setProps").filter((op: any) => op[2]?.style?.arcValueEnd !== undefined);
166
+ expect(arcs.length).toBe(0);
167
+ });
168
+ });
169
+
170
+ describe("Meter", () => {
171
+ test("fill splits at the hot zone and peak line renders", () => {
172
+ const { Meter } = require("./index");
173
+ render(<Meter value={0.95} length={100} hotFrom={0.85} peak />);
174
+
175
+ // main fill capped at the hot boundary
176
+ const fill = opsNamed("setProps").find((op: any) => op[2]?.style?.height === 85);
177
+ expect(fill).toBeDefined();
178
+ // hot overflow segment: ~(0.95 - 0.85) * 100 tall, anchored at 85
179
+ const hot: any = opsNamed("setProps").find((op: any) => op[2]?.style?.bottom === 85);
180
+ expect(hot).toBeDefined();
181
+ expect(hot[2].style.height).toBeCloseTo(10);
182
+ });
183
+
184
+ test("below the hot zone there is no hot segment", () => {
185
+ const { Meter } = require("./index");
186
+ render(<Meter value={0.5} length={100} hotFrom={0.85} peak={false} />);
187
+
188
+ const fill = opsNamed("setProps").find((op: any) => op[2]?.style?.height === 50);
189
+ expect(fill).toBeDefined();
190
+ const hot = opsNamed("setProps").find((op: any) => op[2]?.style?.bottom === 85);
191
+ expect(hot).toBeUndefined();
192
+ });
193
+ });
package/src/controls.tsx CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { useRef } from "react";
6
6
  import { View, Text } from "./primitives";
7
- import { useParameter } from "./parameters";
7
+ import { useParameter, useParameterList } from "./parameters";
8
8
  import { useSpring } from "./animation";
9
9
 
10
10
  const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
@@ -525,3 +525,42 @@ export function ParamSegmented({ paramId, options, label, ...rest }: ParamSegmen
525
525
  />
526
526
  );
527
527
  }
528
+
529
+ // ── GenericEditor ──────────────────────────────────────────────────────
530
+
531
+ export interface GenericEditorProps {
532
+ /** Knobs per row. Default 4. */
533
+ columns?: number;
534
+ /** Knob diameter. Default 72. */
535
+ size?: number;
536
+ trackColor?: string;
537
+ valueColor?: string;
538
+ }
539
+
540
+ /** The zero-effort editor: one knob per APVTS parameter, laid out in
541
+ rows. `render(<GenericEditor />)` is a complete, working plugin UI. */
542
+ export function GenericEditor({ columns = 4, size = 72, trackColor, valueColor }: GenericEditorProps) {
543
+ const params = useParameterList();
544
+
545
+ const rows: (typeof params)[] = [];
546
+ for (let i = 0; i < params.length; i += Math.max(1, columns))
547
+ rows.push(params.slice(i, i + Math.max(1, columns)));
548
+
549
+ return (
550
+ <View className="flex-1 flex-col items-center justify-center gap-6 p-4">
551
+ {rows.map((row, index) => (
552
+ <View key={index} className="flex-row gap-7">
553
+ {row.map((param) => (
554
+ <ParamKnob
555
+ key={param.id}
556
+ paramId={param.id}
557
+ size={size}
558
+ trackColor={trackColor}
559
+ valueColor={valueColor}
560
+ />
561
+ ))}
562
+ </View>
563
+ ))}
564
+ </View>
565
+ );
566
+ }
package/src/hooks.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Convenience hooks over the native bridge.
2
2
 
3
- import { useEffect, useRef } from "react";
3
+ import { useEffect, useRef, useState } from "react";
4
4
  import { native } from "./native";
5
5
 
6
6
  /**
@@ -16,3 +16,21 @@ export function useNativeEvent(name: string, handler: (payload: any) => void): v
16
16
 
17
17
  useEffect(() => native.on(name, (payload) => handlerRef.current(payload)), [name]);
18
18
  }
19
+
20
+ /**
21
+ * The value, but only after it has stopped changing for `delayMs` —
22
+ * classic input debouncing for expensive native calls:
23
+ *
24
+ * const query = useDebounced(text, 250);
25
+ * useEffect(() => { native.call("library:search", { query }); }, [query]);
26
+ */
27
+ export function useDebounced<T>(value: T, delayMs: number): T {
28
+ const [debounced, setDebounced] = useState(value);
29
+
30
+ useEffect(() => {
31
+ const id = setTimeout(() => setDebounced(value), delayMs);
32
+ return () => clearTimeout(id);
33
+ }, [value, delayMs]);
34
+
35
+ return debounced;
36
+ }
package/src/index.ts CHANGED
@@ -13,21 +13,22 @@ export type {
13
13
  } from "./primitives";
14
14
  export { render, unmount } from "./render";
15
15
  export { native } from "./native";
16
- export { useNativeEvent } from "./hooks";
16
+ export { useNativeEvent, useDebounced } from "./hooks";
17
17
  export { configureTheme, tw } from "./tw";
18
18
  export type { Style, ResolvedClasses } from "./tw";
19
19
  export { cx } from "./cx";
20
20
  export type { ClassValue } from "./cx";
21
21
  export { useTween, useSpring, springStep, lerp, Easing } from "./animation";
22
22
  export type { TweenOptions, SpringOptions, EasingFn } from "./animation";
23
- export { useParameter } from "./parameters";
24
- export type { ParameterState, ParameterHandle } from "./parameters";
23
+ export { useParameter, useParameterList } from "./parameters";
24
+ export type { ParameterState, ParameterHandle, ParameterInfo } from "./parameters";
25
25
  export {
26
26
  Knob,
27
27
  Slider,
28
28
  Toggle,
29
29
  XYPad,
30
30
  Segmented,
31
+ GenericEditor,
31
32
  ParamKnob,
32
33
  ParamSlider,
33
34
  ParamToggle,
@@ -41,10 +42,13 @@ export type {
41
42
  ToggleProps,
42
43
  XYPadProps,
43
44
  SegmentedProps,
45
+ GenericEditorProps,
44
46
  ParamKnobProps,
45
47
  ParamSliderProps,
46
48
  ParamToggleProps,
47
49
  ParamXYPadProps,
48
50
  ParamSegmentedProps,
49
51
  } from "./controls";
52
+ export { Meter, usePeakHold, peakHoldStep } from "./meter";
53
+ export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
50
54
  export type { DragEventPayload } from "./primitives";
package/src/meter.tsx ADDED
@@ -0,0 +1,159 @@
1
+ // Meter — the level meter every plugin needs: painted bar with a hot zone
2
+ // and a peak-hold line that holds, then falls. Feed it any 0..1 value
3
+ // (typically pushed from C++ via useNativeEvent).
4
+
5
+ import { useEffect, useRef, useState } from "react";
6
+ import { View, Text } from "./primitives";
7
+
8
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
9
+
10
+ export interface PeakHoldOptions {
11
+ /** How long a peak is held before it starts falling. Default 600ms. */
12
+ holdMs?: number;
13
+ /** Fall rate once the hold expires, in value/second. Default 1.5. */
14
+ decayPerSecond?: number;
15
+ }
16
+
17
+ export interface PeakHoldState {
18
+ peak: number;
19
+ heldForMs: number;
20
+ }
21
+
22
+ /** One peak-hold step (pure, testable): new peaks latch instantly and
23
+ reset the hold timer; after holdMs the peak decays toward the value. */
24
+ export function peakHoldStep(
25
+ state: PeakHoldState,
26
+ value: number,
27
+ dtMs: number,
28
+ { holdMs = 600, decayPerSecond = 1.5 }: PeakHoldOptions = {},
29
+ ): PeakHoldState {
30
+ if (value >= state.peak) return { peak: value, heldForMs: 0 };
31
+
32
+ const heldForMs = state.heldForMs + dtMs;
33
+ if (heldForMs < holdMs) return { peak: state.peak, heldForMs };
34
+
35
+ return { peak: Math.max(value, state.peak - decayPerSecond * (dtMs / 1000)), heldForMs };
36
+ }
37
+
38
+ const FRAME_MS = 33; // meters read fine at ~30fps
39
+
40
+ /** The held peak for a live value — drives the Meter's peak line. */
41
+ export function usePeakHold(value: number, options: PeakHoldOptions = {}): number {
42
+ const [peak, setPeak] = useState(value);
43
+ const state = useRef<PeakHoldState>({ peak: value, heldForMs: 0 });
44
+ const valueRef = useRef(value);
45
+ valueRef.current = value;
46
+ const optionsRef = useRef(options);
47
+ optionsRef.current = options;
48
+
49
+ useEffect(() => {
50
+ const id = setInterval(() => {
51
+ state.current = peakHoldStep(state.current, valueRef.current, FRAME_MS, optionsRef.current);
52
+ setPeak(state.current.peak);
53
+ }, FRAME_MS);
54
+
55
+ return () => clearInterval(id);
56
+ }, []);
57
+
58
+ return Math.max(peak, value);
59
+ }
60
+
61
+ export interface MeterProps {
62
+ /** Level 0..1. */
63
+ value: number;
64
+ /** Long-axis length. Default 120. */
65
+ length?: number;
66
+ /** Short-axis thickness. Default 10. */
67
+ thickness?: number;
68
+ /** Horizontal bar instead of the vertical default. */
69
+ horizontal?: boolean;
70
+ /** Show the peak-hold line. Default true. */
71
+ peak?: boolean;
72
+ holdMs?: number;
73
+ decayPerSecond?: number;
74
+ /** Where the fill turns hot, 0..1. Default 0.85. */
75
+ hotFrom?: number;
76
+ trackColor?: string;
77
+ color?: string;
78
+ hotColor?: string;
79
+ label?: string;
80
+ }
81
+
82
+ /** A natively painted level meter with hot zone + peak hold. */
83
+ export function Meter({
84
+ value,
85
+ length = 120,
86
+ thickness = 10,
87
+ horizontal,
88
+ peak = true,
89
+ holdMs,
90
+ decayPerSecond,
91
+ hotFrom = 0.85,
92
+ trackColor = "#141714",
93
+ color = "#C6F135",
94
+ hotColor = "#FF4545",
95
+ label,
96
+ }: MeterProps) {
97
+ const level = clamp01(value);
98
+ const held = usePeakHold(level, { holdMs, decayPerSecond });
99
+ const hot = clamp01(hotFrom);
100
+
101
+ const fill = Math.min(level, hot) * length;
102
+ const hotFill = level > hot ? (level - hot) * length : 0;
103
+ const peakAt = clamp01(held) * length;
104
+
105
+ const bar = horizontal ? (
106
+ <View
107
+ className="relative rounded overflow-hidden"
108
+ style={{ width: length, height: thickness, backgroundColor: trackColor }}
109
+ >
110
+ <View
111
+ className="absolute left-0 top-0 bottom-0"
112
+ style={{ width: fill, backgroundColor: color }}
113
+ />
114
+ {hotFill > 0 ? (
115
+ <View
116
+ className="absolute top-0 bottom-0"
117
+ style={{ left: hot * length, width: hotFill, backgroundColor: hotColor }}
118
+ />
119
+ ) : null}
120
+ {peak && peakAt > 2 ? (
121
+ <View
122
+ className="absolute top-0 bottom-0 w-[2]"
123
+ style={{ left: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }}
124
+ />
125
+ ) : null}
126
+ </View>
127
+ ) : (
128
+ <View
129
+ className="relative rounded overflow-hidden"
130
+ style={{ width: thickness, height: length, backgroundColor: trackColor }}
131
+ >
132
+ <View
133
+ className="absolute bottom-0 left-0 right-0"
134
+ style={{ height: fill, backgroundColor: color }}
135
+ />
136
+ {hotFill > 0 ? (
137
+ <View
138
+ className="absolute left-0 right-0"
139
+ style={{ bottom: hot * length, height: hotFill, backgroundColor: hotColor }}
140
+ />
141
+ ) : null}
142
+ {peak && peakAt > 2 ? (
143
+ <View
144
+ className="absolute left-0 right-0 h-[2]"
145
+ style={{ bottom: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }}
146
+ />
147
+ ) : null}
148
+ </View>
149
+ );
150
+
151
+ if (label === undefined) return bar;
152
+
153
+ return (
154
+ <View className="items-center gap-2">
155
+ {bar}
156
+ <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
157
+ </View>
158
+ );
159
+ }
package/src/parameters.ts CHANGED
@@ -17,6 +17,36 @@ export interface ParameterHandle extends ParameterState {
17
17
  end: () => void;
18
18
  }
19
19
 
20
+ export interface ParameterInfo {
21
+ id: string;
22
+ name: string;
23
+ label: string;
24
+ /** Normalized 0..1 snapshot at mount — use useParameter(id) for live values. */
25
+ value: number;
26
+ text: string;
27
+ }
28
+
29
+ /**
30
+ * Enumerates every APVTS parameter (via param:list) once at mount — the
31
+ * host's parameter set is fixed for the plugin's lifetime. Powers
32
+ * <GenericEditor/> and any auto-generated UI.
33
+ */
34
+ export function useParameterList(): ParameterInfo[] {
35
+ const [list] = useState<ParameterInfo[]>(() => {
36
+ const result = native.call("param:list");
37
+ if (!Array.isArray(result)) return [];
38
+ return result.map((entry) => ({
39
+ id: String(entry?.id ?? ""),
40
+ name: String(entry?.name ?? entry?.id ?? ""),
41
+ label: String(entry?.label ?? ""),
42
+ value: Number(entry?.value ?? 0),
43
+ text: String(entry?.text ?? ""),
44
+ }));
45
+ });
46
+
47
+ return list;
48
+ }
49
+
20
50
  export function useParameter(id: string): ParameterHandle {
21
51
  const [state, setState] = useState<ParameterState>(() => {
22
52
  const initial = native.call("param:get", { id });
package/src/tw.test.ts CHANGED
@@ -137,3 +137,15 @@ describe("tw 0.0.3 additions", () => {
137
137
  expect(tw("text-6xl").style.fontSize).toBe(60);
138
138
  });
139
139
  });
140
+
141
+ describe("negative spacing", () => {
142
+ test("negative margins and offsets", () => {
143
+ expect(tw("-mt-2").style).toEqual({ marginTop: -8 });
144
+ expect(tw("-mx-[10]").style).toEqual({ marginLeft: -10, marginRight: -10 });
145
+ expect(tw("-top-4").style).toEqual({ top: -16 });
146
+ });
147
+
148
+ test("negative percentages", () => {
149
+ expect(tw("-left-1/2").style).toEqual({ left: "-50%" });
150
+ });
151
+ });
package/src/tw.ts CHANGED
@@ -169,7 +169,12 @@ function resolveClass(cls: string): Style | undefined {
169
169
  const negative = cls.startsWith("-");
170
170
  const body = negative ? cls.slice(1) : cls;
171
171
 
172
- const negate = (v: StyleValue): StyleValue => (negative && typeof v === "number" ? -v : v);
172
+ const negate = (v: StyleValue): StyleValue => {
173
+ if (!negative) return v;
174
+ if (typeof v === "number") return -v;
175
+ if (typeof v === "string" && v.endsWith("%")) return `-${v}`;
176
+ return v;
177
+ };
173
178
 
174
179
  const known = staticClasses[body];
175
180
  if (known && !negative) return known;