@vsreact/core 0.0.3 → 0.0.5

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,49 @@ 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;
138
+ export interface SelectProps {
139
+ options: string[];
140
+ index: number;
141
+ /** Trigger width; the menu matches it. Default 160. */
142
+ width?: number;
143
+ label?: string;
144
+ disabled?: boolean;
145
+ trackColor?: string;
146
+ menuColor?: string;
147
+ activeColor?: string;
148
+ textColor?: string;
149
+ activeTextColor?: string;
150
+ /** Menu scrolls beyond this height. Default 190. */
151
+ maxMenuHeight?: number;
152
+ onChange: (index: number) => void;
153
+ }
154
+ /** A dropdown: the menu renders in the overlay layer, positioned under
155
+ the trigger via onLayout, with a click-away backdrop and a scrolling
156
+ option list. */
157
+ export declare function Select({ options, index, width, label, disabled, trackColor, menuColor, activeColor, textColor, activeTextColor, maxMenuHeight, onChange, }: SelectProps): import("react").JSX.Element;
158
+ export interface ParamSelectProps {
159
+ paramId: string;
160
+ options: string[];
161
+ width?: number;
162
+ label?: string;
163
+ trackColor?: string;
164
+ menuColor?: string;
165
+ activeColor?: string;
166
+ textColor?: string;
167
+ activeTextColor?: string;
168
+ maxMenuHeight?: number;
169
+ }
170
+ /** A Select bound to a choice-style APVTS parameter (same value↔index
171
+ mapping as ParamSegmented). */
172
+ export declare function ParamSelect({ paramId, options, label, ...rest }: ParamSelectProps): import("react").JSX.Element;
package/dist/controls.js CHANGED
@@ -2,10 +2,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // The built-in VST controls — knobs, sliders, toggles, XY pads, segmented
3
3
  // switches — drawn by the VSReacT painter and driven by drag gestures.
4
4
  // Each has a Param* variant bound to an APVTS parameter.
5
- import { useRef } from "react";
5
+ import { useEffect, useRef, useState } 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
+ import { useLayoutRect } from "./hooks";
10
+ import { useOverlay } from "./overlay";
9
11
  const clamp01 = (v) => Math.min(1, Math.max(0, v));
10
12
  /** Vertical-drag-to-value mapping shared by Knob (and tested in isolation). */
11
13
  export function dragToValue(startValue, dy, sensitivity = 0.005) {
@@ -128,3 +130,53 @@ export function ParamSegmented({ paramId, options, label, ...rest }) {
128
130
  param.end();
129
131
  }, ...rest }));
130
132
  }
133
+ /** The zero-effort editor: one knob per APVTS parameter, laid out in
134
+ rows. `render(<GenericEditor />)` is a complete, working plugin UI. */
135
+ export function GenericEditor({ columns = 4, size = 72, trackColor, valueColor }) {
136
+ const params = useParameterList();
137
+ const rows = [];
138
+ for (let i = 0; i < params.length; i += Math.max(1, columns))
139
+ rows.push(params.slice(i, i + Math.max(1, columns)));
140
+ 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))) }));
141
+ }
142
+ const SELECT_ROW_HEIGHT = 30;
143
+ /** A dropdown: the menu renders in the overlay layer, positioned under
144
+ the trigger via onLayout, with a click-away backdrop and a scrolling
145
+ option list. */
146
+ export function Select({ options, index, width = 160, label, disabled, trackColor = "#2A2F27", menuColor = "#20241F", activeColor = "#C6F135", textColor = "#d4d4d8", activeTextColor = "#09090b", maxMenuHeight = 190, onChange, }) {
147
+ const [open, setOpen] = useState(false);
148
+ const [rect, onLayout] = useLayoutRect();
149
+ const overlay = useOverlay();
150
+ const current = Math.min(options.length - 1, Math.max(0, index));
151
+ useEffect(() => {
152
+ if (!open || rect === null) {
153
+ overlay.hide();
154
+ return;
155
+ }
156
+ const menuHeight = Math.min(options.length * SELECT_ROW_HEIGHT + 8, maxMenuHeight);
157
+ overlay.show(_jsx(View, { className: "absolute inset-0", onClick: () => setOpen(false), children: _jsx(View, { className: "absolute rounded-lg border shadow-lg overflow-hidden", style: {
158
+ left: rect.x,
159
+ top: rect.y + rect.height + 4,
160
+ width: rect.width,
161
+ backgroundColor: menuColor,
162
+ borderColor: "#00000066",
163
+ }, children: _jsx(View, { className: "overflow-y-scroll p-[4] gap-[2]", style: { height: menuHeight }, children: options.map((option, i) => (_jsx(View, { className: "px-3 py-[6] rounded cursor-pointer hover:bg-white/10", style: { backgroundColor: i === current ? activeColor : "#00000000" }, onClick: () => {
164
+ onChange(i);
165
+ setOpen(false);
166
+ }, children: _jsx(Text, { className: "text-[12] font-medium", style: { color: i === current ? activeTextColor : textColor }, children: option }) }, `${option}-${i}`))) }) }) }));
167
+ // eslint-disable-next-line react-hooks/exhaustive-deps
168
+ }, [open, rect, options, current, menuColor, activeColor, textColor, activeTextColor, maxMenuHeight]);
169
+ return (_jsxs(View, { className: "items-center gap-2", children: [_jsxs(View, { className: `flex-row items-center justify-between rounded-lg border px-3 py-[8] ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width, backgroundColor: trackColor, borderColor: "#00000055" }, onLayout: onLayout, onClick: disabled ? undefined : () => setOpen((v) => !v), children: [_jsx(Text, { className: "text-[12] font-medium", style: { color: textColor }, children: options[current] ?? "" }), _jsx(Text, { className: "text-[9]", style: { color: textColor, opacity: 0.7 }, children: open ? "▲" : "▼" })] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
170
+ }
171
+ /** A Select bound to a choice-style APVTS parameter (same value↔index
172
+ mapping as ParamSegmented). */
173
+ export function ParamSelect({ paramId, options, label, ...rest }) {
174
+ const param = useParameter(paramId);
175
+ const count = Math.max(1, options.length);
176
+ const index = Math.round(param.value * (count - 1));
177
+ return (_jsx(Select, { options: options, index: index, label: label ?? param.name.toUpperCase(), onChange: (i) => {
178
+ param.begin();
179
+ param.set(count <= 1 ? 0 : i / (count - 1));
180
+ param.end();
181
+ }, ...rest }));
182
+ }
package/dist/hooks.d.ts CHANGED
@@ -1,3 +1,11 @@
1
+ import type { LayoutRect } from "./primitives";
2
+ /**
3
+ * Captures a node's root-space rect from its onLayout prop:
4
+ *
5
+ * const [rect, onLayout] = useLayoutRect();
6
+ * <View onLayout={onLayout} /> // rect updates whenever layout moves it
7
+ */
8
+ export declare function useLayoutRect(): [LayoutRect | null, (rect: LayoutRect) => void];
1
9
  /**
2
10
  * Subscribes to a C++ event (RootView::sendNativeEvent) for the lifetime
3
11
  * of the component. The handler can close over fresh state — it is kept
@@ -6,3 +14,11 @@
6
14
  * useNativeEvent("download:progress", (p) => setProgress(p.ratio));
7
15
  */
8
16
  export declare function useNativeEvent(name: string, handler: (payload: any) => void): void;
17
+ /**
18
+ * The value, but only after it has stopped changing for `delayMs` —
19
+ * classic input debouncing for expensive native calls:
20
+ *
21
+ * const query = useDebounced(text, 250);
22
+ * useEffect(() => { native.call("library:search", { query }); }, [query]);
23
+ */
24
+ export declare function useDebounced<T>(value: T, delayMs: number): T;
package/dist/hooks.js CHANGED
@@ -1,6 +1,16 @@
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
+ /**
5
+ * Captures a node's root-space rect from its onLayout prop:
6
+ *
7
+ * const [rect, onLayout] = useLayoutRect();
8
+ * <View onLayout={onLayout} /> // rect updates whenever layout moves it
9
+ */
10
+ export function useLayoutRect() {
11
+ const [rect, setRect] = useState(null);
12
+ return [rect, setRect];
13
+ }
4
14
  /**
5
15
  * Subscribes to a C++ event (RootView::sendNativeEvent) for the lifetime
6
16
  * of the component. The handler can close over fresh state — it is kept
@@ -13,3 +23,18 @@ export function useNativeEvent(name, handler) {
13
23
  handlerRef.current = handler;
14
24
  useEffect(() => native.on(name, (payload) => handlerRef.current(payload)), [name]);
15
25
  }
26
+ /**
27
+ * The value, but only after it has stopped changing for `delayMs` —
28
+ * classic input debouncing for expensive native calls:
29
+ *
30
+ * const query = useDebounced(text, 250);
31
+ * useEffect(() => { native.call("library:search", { query }); }, [query]);
32
+ */
33
+ export function useDebounced(value, delayMs) {
34
+ const [debounced, setDebounced] = useState(value);
35
+ useEffect(() => {
36
+ const id = setTimeout(() => setDebounced(value), delayMs);
37
+ return () => clearTimeout(id);
38
+ }, [value, delayMs]);
39
+ return debounced;
40
+ }
@@ -20,6 +20,7 @@ const eventPropNames = {
20
20
  onDragStart: "dragstart",
21
21
  onDrag: "drag",
22
22
  onDragEnd: "dragend",
23
+ onLayout: "layout",
23
24
  onChange: "change",
24
25
  onSubmit: "submit",
25
26
  onFocus: "focus",
package/dist/index.d.ts CHANGED
@@ -4,15 +4,18 @@ 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, useLayoutRect } from "./hooks";
8
+ export { useOverlay, OverlayLayer } from "./overlay";
8
9
  export { configureTheme, tw } from "./tw";
9
10
  export type { Style, ResolvedClasses } from "./tw";
10
11
  export { cx } from "./cx";
11
12
  export type { ClassValue } from "./cx";
12
13
  export { useTween, useSpring, springStep, lerp, Easing } from "./animation";
13
14
  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";
18
- export type { DragEventPayload } from "./primitives";
15
+ export { useParameter, useParameterList } from "./parameters";
16
+ export type { ParameterState, ParameterHandle, ParameterInfo } from "./parameters";
17
+ export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, ParamSelect, dragToValue, } from "./controls";
18
+ export type { KnobProps, SliderProps, ToggleProps, XYPadProps, SegmentedProps, SelectProps, GenericEditorProps, ParamKnobProps, ParamSliderProps, ParamToggleProps, ParamXYPadProps, ParamSegmentedProps, ParamSelectProps, } from "./controls";
19
+ export { Meter, usePeakHold, peakHoldStep } from "./meter";
20
+ export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
21
+ export type { DragEventPayload, LayoutRect } from "./primitives";
package/dist/index.js CHANGED
@@ -5,9 +5,11 @@ 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, useLayoutRect } from "./hooks";
9
+ export { useOverlay, OverlayLayer } from "./overlay";
9
10
  export { configureTheme, tw } from "./tw";
10
11
  export { cx } from "./cx";
11
12
  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";
13
+ export { useParameter, useParameterList } from "./parameters";
14
+ export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, ParamSelect, dragToValue, } from "./controls";
15
+ 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
+ }
@@ -0,0 +1,9 @@
1
+ import { type ReactNode } from "react";
2
+ /** A per-component slot in the overlay layer. show() replaces the slot's
3
+ content; hide() removes it; unmounting cleans up automatically. */
4
+ export declare function useOverlay(): {
5
+ show: (node: ReactNode) => void;
6
+ hide: () => void;
7
+ };
8
+ /** Mounted automatically by render() as the last sibling of your app. */
9
+ export declare function OverlayLayer(): import("react").JSX.Element;
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // The overlay layer — content that paints above everything else (menus,
3
+ // tooltips, modals). render() mounts <OverlayLayer/> after your app
4
+ // automatically, so painting order puts overlays on top and hit-testing
5
+ // reaches them first. Position entries absolutely using rects from
6
+ // onLayout / useLayoutRect.
7
+ import { Fragment, useCallback, useEffect, useRef, useSyncExternalStore } from "react";
8
+ let entries = [];
9
+ const subscribers = new Set();
10
+ let nextOverlayKey = 1;
11
+ function emit() {
12
+ for (const subscriber of subscribers)
13
+ subscriber();
14
+ }
15
+ function setEntry(key, node) {
16
+ entries = [...entries.filter((e) => e.key !== key), { key, node }];
17
+ emit();
18
+ }
19
+ function clearEntry(key) {
20
+ if (!entries.some((e) => e.key === key))
21
+ return;
22
+ entries = entries.filter((e) => e.key !== key);
23
+ emit();
24
+ }
25
+ /** A per-component slot in the overlay layer. show() replaces the slot's
26
+ content; hide() removes it; unmounting cleans up automatically. */
27
+ export function useOverlay() {
28
+ const key = useRef(0);
29
+ if (key.current === 0)
30
+ key.current = nextOverlayKey++;
31
+ useEffect(() => {
32
+ const k = key.current;
33
+ return () => clearEntry(k);
34
+ }, []);
35
+ return {
36
+ show: useCallback((node) => setEntry(key.current, node), []),
37
+ hide: useCallback(() => clearEntry(key.current), []),
38
+ };
39
+ }
40
+ /** Mounted automatically by render() as the last sibling of your app. */
41
+ export function OverlayLayer() {
42
+ const list = useSyncExternalStore((onStoreChange) => {
43
+ subscribers.add(onStoreChange);
44
+ return () => subscribers.delete(onStoreChange);
45
+ }, () => entries);
46
+ return (_jsx(_Fragment, { children: list.map((entry) => (_jsx(Fragment, { children: entry.node }, entry.key))) }));
47
+ }
@@ -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 });
@@ -8,6 +8,13 @@ export interface DragEventPayload {
8
8
  x: number;
9
9
  y: number;
10
10
  }
11
+ /** A node's laid-out rect in root coordinates (scroll-adjusted). */
12
+ export interface LayoutRect {
13
+ x: number;
14
+ y: number;
15
+ width: number;
16
+ height: number;
17
+ }
11
18
  export interface CommonProps {
12
19
  className?: string;
13
20
  style?: Style;
@@ -22,6 +29,9 @@ export interface CommonProps {
22
29
  onDragStart?: (e: DragEventPayload) => void;
23
30
  onDrag?: (e: DragEventPayload) => void;
24
31
  onDragEnd?: (e: DragEventPayload) => void;
32
+ /** Fires after layout whenever this node's root-space rect changes —
33
+ the foundation for popovers, menus, and tooltips. */
34
+ onLayout?: (rect: LayoutRect) => void;
25
35
  }
26
36
  export declare function View(props: CommonProps): import("react").ReactElement<CommonProps, string | import("react").JSXElementConstructor<any>>;
27
37
  export interface TextProps extends Omit<CommonProps, "children"> {
@@ -62,6 +72,7 @@ export declare function TextInput({ onChange, onSubmit, ...rest }: TextInputProp
62
72
  onDragStart?: ((e: DragEventPayload) => void) | undefined;
63
73
  onDrag?: ((e: DragEventPayload) => void) | undefined;
64
74
  onDragEnd?: ((e: DragEventPayload) => void) | undefined;
75
+ onLayout?: ((rect: LayoutRect) => void) | undefined;
65
76
  }, string | import("react").JSXElementConstructor<any>>;
66
77
  export interface NativeViewProps extends CommonProps {
67
78
  /** Id of a component factory registered in the C++ NativeRegistry. */
package/dist/render.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
- /** Mounts (or re-renders) the app into the plugin window. */
2
+ /** Mounts (or re-renders) the app into the plugin window. The overlay
3
+ layer (menus, tooltips) mounts after the app so it paints on top. */
3
4
  export declare function render(element: ReactNode): void;
4
5
  /** Unmounts the current app (used by hot reload teardown). */
5
6
  export declare function unmount(): void;
package/dist/render.js CHANGED
@@ -1,15 +1,18 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
2
  import Reconciler from "react-reconciler";
2
3
  import { LegacyRoot } from "react-reconciler/constants";
3
4
  import { hostConfig } from "./hostConfig";
4
5
  import { flushOps } from "./bridge";
6
+ import { OverlayLayer } from "./overlay";
5
7
  const reconciler = Reconciler(hostConfig);
6
8
  let container = null;
7
- /** Mounts (or re-renders) the app into the plugin window. */
9
+ /** Mounts (or re-renders) the app into the plugin window. The overlay
10
+ layer (menus, tooltips) mounts after the app so it paints on top. */
8
11
  export function render(element) {
9
12
  if (!container) {
10
13
  container = reconciler.createContainer({}, LegacyRoot, null, false, null, "vsreact", (error) => console.error("[vsreact] recoverable error:", error), null);
11
14
  }
12
- reconciler.updateContainer(element, container, null, null);
15
+ reconciler.updateContainer(_jsxs(_Fragment, { children: [element, _jsx(OverlayLayer, {})] }), container, null, null);
13
16
  flushOps();
14
17
  }
15
18
  /** Unmounts the current app (used by hot reload teardown). */
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.5",
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",
@@ -20,7 +20,7 @@
20
20
  "bugs": "https://github.com/N9RecordsTechnologiesIL/VSReacT/issues",
21
21
  "repository": {
22
22
  "type": "git",
23
- "url": "https://github.com/N9RecordsTechnologiesIL/VSReacT.git",
23
+ "url": "git+https://github.com/N9RecordsTechnologiesIL/VSReacT.git",
24
24
  "directory": "vsreact/js"
25
25
  },
26
26
  "main": "dist/index.js",
@@ -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
+ });