@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.
- package/dist/controls.d.ts +11 -0
- package/dist/controls.js +10 -1
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.js +16 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +4 -3
- package/dist/meter.d.ts +37 -0
- package/dist/meter.js +48 -0
- package/dist/parameters.d.ts +14 -0
- package/dist/parameters.js +20 -0
- package/dist/tw.js +9 -1
- package/package.json +1 -1
- package/src/animation.test.tsx +33 -0
- package/src/controls.test.tsx +62 -0
- package/src/controls.tsx +40 -1
- package/src/hooks.ts +19 -1
- package/src/index.ts +7 -3
- package/src/meter.tsx +159 -0
- package/src/parameters.ts +30 -0
- package/src/tw.test.ts +12 -0
- package/src/tw.ts +6 -1
package/dist/controls.d.ts
CHANGED
|
@@ -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";
|
package/dist/meter.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/parameters.d.ts
CHANGED
|
@@ -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;
|
package/dist/parameters.js
CHANGED
|
@@ -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) =>
|
|
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
package/src/animation.test.tsx
CHANGED
|
@@ -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
|
+
});
|
package/src/controls.test.tsx
CHANGED
|
@@ -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 =>
|
|
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;
|