@vsreact/core 0.0.13 → 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components.d.ts +2 -0
- package/dist/components.js +1 -0
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.js +12 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/layout.d.ts +37 -0
- package/dist/layout.js +33 -0
- package/dist/meter.d.ts +4 -1
- package/dist/meter.js +10 -2
- package/package.json +1 -1
- package/src/components.ts +2 -0
- package/src/hooks.ts +13 -0
- package/src/index.ts +3 -0
- package/src/layout.test.tsx +127 -0
- package/src/layout.tsx +136 -0
- package/src/meter.tsx +28 -8
package/dist/components.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export { PianoKeyboard } from "./keyboard";
|
|
|
16
16
|
export type { PianoKeyboardProps } from "./keyboard";
|
|
17
17
|
export { StepSequencer } from "./sequencer";
|
|
18
18
|
export type { StepSequencerProps } from "./sequencer";
|
|
19
|
+
export { Tabs, Disclosure } from "./layout";
|
|
20
|
+
export type { TabsProps, DisclosureProps } from "./layout";
|
|
19
21
|
export { ADSREnvelope, ParamADSREnvelope, PitchBend, ModWheel, ParamModWheel, ParamPitchBend, } from "./synth";
|
|
20
22
|
export type { ADSRKey, ADSREnvelopeProps, ParamADSREnvelopeProps, PitchBendProps, ModWheelProps, ParamModWheelProps, ParamPitchBendProps, } from "./synth";
|
|
21
23
|
export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
|
package/dist/components.js
CHANGED
|
@@ -14,5 +14,6 @@ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRa
|
|
|
14
14
|
export { ProgressBar, Spinner } from "./feedback";
|
|
15
15
|
export { PianoKeyboard } from "./keyboard";
|
|
16
16
|
export { StepSequencer } from "./sequencer";
|
|
17
|
+
export { Tabs, Disclosure } from "./layout";
|
|
17
18
|
export { ADSREnvelope, ParamADSREnvelope, PitchBend, ModWheel, ParamModWheel, ParamPitchBend, } from "./synth";
|
|
18
19
|
export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
|
package/dist/hooks.d.ts
CHANGED
|
@@ -14,6 +14,14 @@ export declare function useLayoutRect(): [LayoutRect | null, (rect: LayoutRect)
|
|
|
14
14
|
* useNativeEvent("download:progress", (p) => setProgress(p.ratio));
|
|
15
15
|
*/
|
|
16
16
|
export declare function useNativeEvent(name: string, handler: (payload: any) => void): void;
|
|
17
|
+
/**
|
|
18
|
+
* The latest payload of a C++ event, held as state — the one-liner for
|
|
19
|
+
* native → UI data feeds:
|
|
20
|
+
*
|
|
21
|
+
* const meter = useNativeValue("meter", { level: 0 });
|
|
22
|
+
* <Meter value={meter.level} />
|
|
23
|
+
*/
|
|
24
|
+
export declare function useNativeValue<T = any>(name: string, initial: T): T;
|
|
17
25
|
/**
|
|
18
26
|
* The value, but only after it has stopped changing for `delayMs` —
|
|
19
27
|
* classic input debouncing for expensive native calls:
|
package/dist/hooks.js
CHANGED
|
@@ -23,6 +23,18 @@ export function useNativeEvent(name, handler) {
|
|
|
23
23
|
handlerRef.current = handler;
|
|
24
24
|
useEffect(() => native.on(name, (payload) => handlerRef.current(payload)), [name]);
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* The latest payload of a C++ event, held as state — the one-liner for
|
|
28
|
+
* native → UI data feeds:
|
|
29
|
+
*
|
|
30
|
+
* const meter = useNativeValue("meter", { level: 0 });
|
|
31
|
+
* <Meter value={meter.level} />
|
|
32
|
+
*/
|
|
33
|
+
export function useNativeValue(name, initial) {
|
|
34
|
+
const [value, setValue] = useState(initial);
|
|
35
|
+
useNativeEvent(name, setValue);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
26
38
|
/**
|
|
27
39
|
* The value, but only after it has stopped changing for `delayMs` —
|
|
28
40
|
* classic input debouncing for expensive native calls:
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ 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, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
|
|
7
|
+
export { useNativeEvent, useNativeValue, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
|
|
8
8
|
export { useOverlay, OverlayLayer } from "./overlay";
|
|
9
9
|
export { Tooltip, Modal } from "./popover";
|
|
10
10
|
export type { TooltipProps, ModalProps } from "./popover";
|
|
@@ -32,6 +32,8 @@ export { PianoKeyboard } from "./keyboard";
|
|
|
32
32
|
export type { PianoKeyboardProps } from "./keyboard";
|
|
33
33
|
export { StepSequencer } from "./sequencer";
|
|
34
34
|
export type { StepSequencerProps } from "./sequencer";
|
|
35
|
+
export { Tabs, Disclosure } from "./layout";
|
|
36
|
+
export type { TabsProps, DisclosureProps } from "./layout";
|
|
35
37
|
export { ADSREnvelope, ParamADSREnvelope, PitchBend, ModWheel, ParamModWheel, ParamPitchBend, adsrLevelAt, } from "./synth";
|
|
36
38
|
export type { ADSRKey, ADSREnvelopeProps, ParamADSREnvelopeProps, PitchBendProps, ModWheelProps, ParamModWheelProps, ParamPitchBendProps, } from "./synth";
|
|
37
39
|
export { mapRange, formatDb, formatHz, formatMs, formatPercent, formatSemitones, midiNoteName, midiNoteToHz, hzToMidiNote, } from "./format";
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ 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, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
|
|
8
|
+
export { useNativeEvent, useNativeValue, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
|
|
9
9
|
export { useOverlay, OverlayLayer } from "./overlay";
|
|
10
10
|
export { Tooltip, Modal } from "./popover";
|
|
11
11
|
export { Button } from "./button";
|
|
@@ -20,6 +20,7 @@ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRa
|
|
|
20
20
|
export { ProgressBar, Spinner } from "./feedback";
|
|
21
21
|
export { PianoKeyboard } from "./keyboard";
|
|
22
22
|
export { StepSequencer } from "./sequencer";
|
|
23
|
+
export { Tabs, Disclosure } from "./layout";
|
|
23
24
|
export { ADSREnvelope, ParamADSREnvelope, PitchBend, ModWheel, ParamModWheel, ParamPitchBend, adsrLevelAt, } from "./synth";
|
|
24
25
|
export { mapRange, formatDb, formatHz, formatMs, formatPercent, formatSemitones, midiNoteName, midiNoteToHz, hzToMidiNote, } from "./format";
|
|
25
26
|
export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
|
package/dist/layout.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export interface TabsProps {
|
|
3
|
+
labels: string[];
|
|
4
|
+
/** Controlled active tab; omit to let Tabs manage it. */
|
|
5
|
+
index?: number;
|
|
6
|
+
/** Starting tab when uncontrolled. Default 0. */
|
|
7
|
+
defaultIndex?: number;
|
|
8
|
+
onChange?: (index: number) => void;
|
|
9
|
+
/** Fixed width; defaults to content width. */
|
|
10
|
+
width?: number;
|
|
11
|
+
/** Space between the bar and the panel. Default 12. */
|
|
12
|
+
gap?: number;
|
|
13
|
+
accentColor?: string;
|
|
14
|
+
textColor?: string;
|
|
15
|
+
activeTextColor?: string;
|
|
16
|
+
trackColor?: string;
|
|
17
|
+
/** One panel per label (a lone child works for label-driven UIs). */
|
|
18
|
+
children?: ReactNode | ReactNode[];
|
|
19
|
+
}
|
|
20
|
+
/** The page switcher — MAIN / FX / SETTINGS. A themed tab bar with an
|
|
21
|
+
underline indicator; renders the active panel below it. */
|
|
22
|
+
export declare function Tabs({ labels, index, defaultIndex, onChange, width, gap, accentColor, textColor, activeTextColor, trackColor, children, }: TabsProps): import("react").JSX.Element;
|
|
23
|
+
export interface DisclosureProps {
|
|
24
|
+
title: string;
|
|
25
|
+
/** Controlled open state; omit to let Disclosure manage it. */
|
|
26
|
+
open?: boolean;
|
|
27
|
+
/** Starting state when uncontrolled. Default false. */
|
|
28
|
+
defaultOpen?: boolean;
|
|
29
|
+
onChange?: (open: boolean) => void;
|
|
30
|
+
width?: number;
|
|
31
|
+
textColor?: string;
|
|
32
|
+
accentColor?: string;
|
|
33
|
+
children?: ReactNode;
|
|
34
|
+
}
|
|
35
|
+
/** A collapsible section row — "ADVANCED", "MODULATION". Click the
|
|
36
|
+
header to fold the content in and out. */
|
|
37
|
+
export declare function Disclosure({ title, open, defaultOpen, onChange, width, textColor, accentColor, children, }: DisclosureProps): import("react").JSX.Element;
|
package/dist/layout.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Workspace structure — tabs for multi-page plugin UIs and the
|
|
3
|
+
// disclosure row for collapsible settings sections.
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { View, Text } from "./primitives";
|
|
6
|
+
const clampIndex = (i, count) => Math.min(Math.max(0, count - 1), Math.max(0, i));
|
|
7
|
+
/** The page switcher — MAIN / FX / SETTINGS. A themed tab bar with an
|
|
8
|
+
underline indicator; renders the active panel below it. */
|
|
9
|
+
export function Tabs({ labels, index, defaultIndex = 0, onChange, width, gap = 12, accentColor = "#C6F135", textColor = "#7c8087", activeTextColor = "#ECF2E8", trackColor = "#FFFFFF14", children, }) {
|
|
10
|
+
const [internal, setInternal] = useState(defaultIndex);
|
|
11
|
+
const current = clampIndex(index ?? internal, labels.length);
|
|
12
|
+
const select = (i) => {
|
|
13
|
+
setInternal(i);
|
|
14
|
+
onChange?.(i);
|
|
15
|
+
};
|
|
16
|
+
const panels = Array.isArray(children) ? children : children !== undefined ? [children] : [];
|
|
17
|
+
return (_jsxs(View, { style: width !== undefined ? { width } : undefined, children: [_jsxs(View, { className: "flex-row relative", style: { columnGap: 4 }, children: [_jsx(View, { className: "absolute left-0 right-0 h-[1]", style: { bottom: 0, backgroundColor: trackColor } }), labels.map((label, i) => (_jsxs(View, { className: "items-center cursor-pointer px-3 pt-1", onClick: () => select(i), children: [_jsx(Text, { className: "text-[11] font-bold tracking-widest", style: { color: i === current ? activeTextColor : textColor }, children: label }), _jsx(View, { className: "rounded-full self-stretch", style: {
|
|
18
|
+
height: 2,
|
|
19
|
+
marginTop: 6,
|
|
20
|
+
backgroundColor: i === current ? accentColor : "#00000000",
|
|
21
|
+
} })] }, `${label}-${i}`)))] }), panels.length > 0 ? (_jsx(View, { style: { marginTop: gap }, children: panels[Math.min(current, panels.length - 1)] })) : null] }));
|
|
22
|
+
}
|
|
23
|
+
/** A collapsible section row — "ADVANCED", "MODULATION". Click the
|
|
24
|
+
header to fold the content in and out. */
|
|
25
|
+
export function Disclosure({ title, open, defaultOpen = false, onChange, width, textColor = "#a1a1aa", accentColor = "#C6F135", children, }) {
|
|
26
|
+
const [internal, setInternal] = useState(defaultOpen);
|
|
27
|
+
const isOpen = open ?? internal;
|
|
28
|
+
const toggle = () => {
|
|
29
|
+
setInternal(!isOpen);
|
|
30
|
+
onChange?.(!isOpen);
|
|
31
|
+
};
|
|
32
|
+
return (_jsxs(View, { style: width !== undefined ? { width } : undefined, children: [_jsxs(View, { className: "flex-row items-center gap-2 cursor-pointer py-1", onClick: toggle, children: [_jsx(Text, { className: "text-[10] font-bold", style: { color: accentColor, width: 10 }, children: isOpen ? "▾" : "▸" }), _jsx(Text, { className: "text-[11] font-bold tracking-widest", style: { color: textColor }, children: title })] }), isOpen ? _jsx(View, { className: "pt-2 pl-5", children: children }) : null] }));
|
|
33
|
+
}
|
package/dist/meter.d.ts
CHANGED
|
@@ -22,6 +22,9 @@ export interface MeterProps {
|
|
|
22
22
|
thickness?: number;
|
|
23
23
|
/** Horizontal bar instead of the vertical default. */
|
|
24
24
|
horizontal?: boolean;
|
|
25
|
+
/** Fill from the top (vertical) / right (horizontal) — gain-reduction
|
|
26
|
+
meters. Default false. */
|
|
27
|
+
reverse?: boolean;
|
|
25
28
|
/** Show the peak-hold line. Default true. */
|
|
26
29
|
peak?: boolean;
|
|
27
30
|
holdMs?: number;
|
|
@@ -34,4 +37,4 @@ export interface MeterProps {
|
|
|
34
37
|
label?: string;
|
|
35
38
|
}
|
|
36
39
|
/** 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;
|
|
40
|
+
export declare function Meter({ value, length, thickness, horizontal, reverse, peak, holdMs, decayPerSecond, hotFrom, trackColor, color, hotColor, label, }: MeterProps): import("react").JSX.Element;
|
package/dist/meter.js
CHANGED
|
@@ -34,14 +34,22 @@ export function usePeakHold(value, options = {}) {
|
|
|
34
34
|
return Math.max(peak, value);
|
|
35
35
|
}
|
|
36
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, }) {
|
|
37
|
+
export function Meter({ value, length = 120, thickness = 10, horizontal, reverse = false, peak = true, holdMs, decayPerSecond, hotFrom = 0.85, trackColor = "#141714", color = "#C6F135", hotColor = "#FF4545", label, }) {
|
|
38
38
|
const level = clamp01(value);
|
|
39
39
|
const held = usePeakHold(level, { holdMs, decayPerSecond });
|
|
40
40
|
const hot = clamp01(hotFrom);
|
|
41
41
|
const fill = Math.min(level, hot) * length;
|
|
42
42
|
const hotFill = level > hot ? (level - hot) * length : 0;
|
|
43
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
|
|
44
|
+
const bar = horizontal ? (_jsxs(View, { className: "relative rounded overflow-hidden", style: { width: length, height: thickness, backgroundColor: trackColor }, children: [_jsx(View, { className: "absolute top-0 bottom-0", style: reverse ? { right: 0, width: fill, backgroundColor: color } : { left: 0, width: fill, backgroundColor: color } }), hotFill > 0 ? (_jsx(View, { className: "absolute top-0 bottom-0", style: reverse
|
|
45
|
+
? { right: hot * length, width: hotFill, backgroundColor: hotColor }
|
|
46
|
+
: { left: hot * length, width: hotFill, backgroundColor: hotColor } })) : null, peak && peakAt > 2 ? (_jsx(View, { className: "absolute top-0 bottom-0 w-[2]", style: reverse
|
|
47
|
+
? { right: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
|
|
48
|
+
: { 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 left-0 right-0", style: reverse ? { top: 0, height: fill, backgroundColor: color } : { bottom: 0, height: fill, backgroundColor: color } }), hotFill > 0 ? (_jsx(View, { className: "absolute left-0 right-0", style: reverse
|
|
49
|
+
? { top: hot * length, height: hotFill, backgroundColor: hotColor }
|
|
50
|
+
: { bottom: hot * length, height: hotFill, backgroundColor: hotColor } })) : null, peak && peakAt > 2 ? (_jsx(View, { className: "absolute left-0 right-0 h-[2]", style: reverse
|
|
51
|
+
? { top: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
|
|
52
|
+
: { bottom: peakAt - 2, backgroundColor: held >= hot ? hotColor : color } })) : null] }));
|
|
45
53
|
if (label === undefined)
|
|
46
54
|
return bar;
|
|
47
55
|
return (_jsxs(View, { className: "items-center gap-2", children: [bar, _jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })] }));
|
package/package.json
CHANGED
package/src/components.ts
CHANGED
|
@@ -68,6 +68,8 @@ export { PianoKeyboard } from "./keyboard";
|
|
|
68
68
|
export type { PianoKeyboardProps } from "./keyboard";
|
|
69
69
|
export { StepSequencer } from "./sequencer";
|
|
70
70
|
export type { StepSequencerProps } from "./sequencer";
|
|
71
|
+
export { Tabs, Disclosure } from "./layout";
|
|
72
|
+
export type { TabsProps, DisclosureProps } from "./layout";
|
|
71
73
|
export {
|
|
72
74
|
ADSREnvelope,
|
|
73
75
|
ParamADSREnvelope,
|
package/src/hooks.ts
CHANGED
|
@@ -29,6 +29,19 @@ export function useNativeEvent(name: string, handler: (payload: any) => void): v
|
|
|
29
29
|
useEffect(() => native.on(name, (payload) => handlerRef.current(payload)), [name]);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The latest payload of a C++ event, held as state — the one-liner for
|
|
34
|
+
* native → UI data feeds:
|
|
35
|
+
*
|
|
36
|
+
* const meter = useNativeValue("meter", { level: 0 });
|
|
37
|
+
* <Meter value={meter.level} />
|
|
38
|
+
*/
|
|
39
|
+
export function useNativeValue<T = any>(name: string, initial: T): T {
|
|
40
|
+
const [value, setValue] = useState<T>(initial);
|
|
41
|
+
useNativeEvent(name, setValue);
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
/**
|
|
33
46
|
* The value, but only after it has stopped changing for `delayMs` —
|
|
34
47
|
* classic input debouncing for expensive native calls:
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export { render, unmount } from "./render";
|
|
|
15
15
|
export { native } from "./native";
|
|
16
16
|
export {
|
|
17
17
|
useNativeEvent,
|
|
18
|
+
useNativeValue,
|
|
18
19
|
useDebounced,
|
|
19
20
|
useThrottled,
|
|
20
21
|
usePrevious,
|
|
@@ -94,6 +95,8 @@ export { PianoKeyboard } from "./keyboard";
|
|
|
94
95
|
export type { PianoKeyboardProps } from "./keyboard";
|
|
95
96
|
export { StepSequencer } from "./sequencer";
|
|
96
97
|
export type { StepSequencerProps } from "./sequencer";
|
|
98
|
+
export { Tabs, Disclosure } from "./layout";
|
|
99
|
+
export type { TabsProps, DisclosureProps } from "./layout";
|
|
97
100
|
export {
|
|
98
101
|
ADSREnvelope,
|
|
99
102
|
ParamADSREnvelope,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
const batches: unknown[][][] = [];
|
|
4
|
+
|
|
5
|
+
(globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
|
|
6
|
+
batches.push(JSON.parse(json));
|
|
7
|
+
};
|
|
8
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = () => "null";
|
|
9
|
+
|
|
10
|
+
import { render, unmount, View, Text, Tabs, Disclosure, Meter, useNativeValue } from "./index";
|
|
11
|
+
|
|
12
|
+
const allOps = () => batches.flat();
|
|
13
|
+
const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
|
|
14
|
+
const dispatch = (msg: unknown) =>
|
|
15
|
+
(globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
|
|
16
|
+
|
|
17
|
+
const nodesWithListener = (type: string): number[] => {
|
|
18
|
+
const seen = new Set<number>();
|
|
19
|
+
for (const op of opsNamed("setProps") as any[]) {
|
|
20
|
+
if (op[2]?.listeners?.includes(type)) seen.add(op[1]);
|
|
21
|
+
}
|
|
22
|
+
return [...seen];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const textsSet = (): string[] =>
|
|
26
|
+
(opsNamed("setText") as any[]).map((op) => op[2]).filter((t) => typeof t === "string");
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
unmount();
|
|
30
|
+
batches.length = 0;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("Tabs", () => {
|
|
34
|
+
test("uncontrolled: clicking a tab switches the panel", async () => {
|
|
35
|
+
render(
|
|
36
|
+
<Tabs labels={["MAIN", "FX"]}>
|
|
37
|
+
<Text>main-panel</Text>
|
|
38
|
+
<Text>fx-panel</Text>
|
|
39
|
+
</Tabs>,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(textsSet()).toContain("main-panel");
|
|
43
|
+
expect(textsSet()).not.toContain("fx-panel");
|
|
44
|
+
|
|
45
|
+
const tabs = nodesWithListener("click");
|
|
46
|
+
expect(tabs).toHaveLength(2);
|
|
47
|
+
dispatch({ kind: "event", nodeId: tabs[1], type: "click" });
|
|
48
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
49
|
+
|
|
50
|
+
expect(textsSet()).toContain("fx-panel");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("controlled: reports the click, renders the given index", () => {
|
|
54
|
+
const seen: number[] = [];
|
|
55
|
+
render(
|
|
56
|
+
<Tabs labels={["A", "B", "C"]} index={2} onChange={(i) => seen.push(i)}>
|
|
57
|
+
<Text>a</Text>
|
|
58
|
+
<Text>b</Text>
|
|
59
|
+
<Text>c</Text>
|
|
60
|
+
</Tabs>,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(textsSet()).toContain("c");
|
|
64
|
+
const tabs = nodesWithListener("click");
|
|
65
|
+
dispatch({ kind: "event", nodeId: tabs[0], type: "click" });
|
|
66
|
+
expect(seen).toEqual([0]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("Disclosure", () => {
|
|
71
|
+
test("closed by default; clicking the header reveals the content", async () => {
|
|
72
|
+
render(
|
|
73
|
+
<Disclosure title="ADVANCED">
|
|
74
|
+
<Text>secret-settings</Text>
|
|
75
|
+
</Disclosure>,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(textsSet()).not.toContain("secret-settings");
|
|
79
|
+
|
|
80
|
+
const header = nodesWithListener("click")[0];
|
|
81
|
+
dispatch({ kind: "event", nodeId: header, type: "click" });
|
|
82
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
83
|
+
|
|
84
|
+
expect(textsSet()).toContain("secret-settings");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("Meter reverse", () => {
|
|
89
|
+
test("reverse anchors the vertical fill to the top", () => {
|
|
90
|
+
render(<Meter value={0.5} length={100} peak={false} reverse />);
|
|
91
|
+
|
|
92
|
+
const fill: any = (opsNamed("setProps") as any[]).find(
|
|
93
|
+
(op) => op[2]?.style?.height === 42.5 || op[2]?.style?.height === 50,
|
|
94
|
+
);
|
|
95
|
+
expect(fill).toBeDefined();
|
|
96
|
+
expect(fill[2].style.top).toBe(0);
|
|
97
|
+
expect(fill[2].style.bottom).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("default still fills from the bottom", () => {
|
|
101
|
+
render(<Meter value={0.5} length={100} peak={false} />);
|
|
102
|
+
|
|
103
|
+
const fill: any = (opsNamed("setProps") as any[]).find(
|
|
104
|
+
(op) => (op[2]?.style?.height === 42.5 || op[2]?.style?.height === 50) && op[2]?.style?.bottom === 0,
|
|
105
|
+
);
|
|
106
|
+
expect(fill).toBeDefined();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("useNativeValue", () => {
|
|
111
|
+
test("holds the latest payload of a native event", async () => {
|
|
112
|
+
const seen: number[] = [];
|
|
113
|
+
function App() {
|
|
114
|
+
const meter = useNativeValue("meter", { level: 0 });
|
|
115
|
+
seen.push(meter.level);
|
|
116
|
+
return <View />;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
render(<App />);
|
|
120
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
121
|
+
|
|
122
|
+
dispatch({ kind: "native", name: "meter", payload: { level: 0.8 } });
|
|
123
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
124
|
+
|
|
125
|
+
expect(seen.at(-1)).toBe(0.8);
|
|
126
|
+
});
|
|
127
|
+
});
|
package/src/layout.tsx
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Workspace structure — tabs for multi-page plugin UIs and the
|
|
2
|
+
// disclosure row for collapsible settings sections.
|
|
3
|
+
|
|
4
|
+
import { useState, type ReactNode } from "react";
|
|
5
|
+
import { View, Text } from "./primitives";
|
|
6
|
+
|
|
7
|
+
const clampIndex = (i: number, count: number) => Math.min(Math.max(0, count - 1), Math.max(0, i));
|
|
8
|
+
|
|
9
|
+
export interface TabsProps {
|
|
10
|
+
labels: string[];
|
|
11
|
+
/** Controlled active tab; omit to let Tabs manage it. */
|
|
12
|
+
index?: number;
|
|
13
|
+
/** Starting tab when uncontrolled. Default 0. */
|
|
14
|
+
defaultIndex?: number;
|
|
15
|
+
onChange?: (index: number) => void;
|
|
16
|
+
/** Fixed width; defaults to content width. */
|
|
17
|
+
width?: number;
|
|
18
|
+
/** Space between the bar and the panel. Default 12. */
|
|
19
|
+
gap?: number;
|
|
20
|
+
accentColor?: string;
|
|
21
|
+
textColor?: string;
|
|
22
|
+
activeTextColor?: string;
|
|
23
|
+
trackColor?: string;
|
|
24
|
+
/** One panel per label (a lone child works for label-driven UIs). */
|
|
25
|
+
children?: ReactNode | ReactNode[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The page switcher — MAIN / FX / SETTINGS. A themed tab bar with an
|
|
29
|
+
underline indicator; renders the active panel below it. */
|
|
30
|
+
export function Tabs({
|
|
31
|
+
labels,
|
|
32
|
+
index,
|
|
33
|
+
defaultIndex = 0,
|
|
34
|
+
onChange,
|
|
35
|
+
width,
|
|
36
|
+
gap = 12,
|
|
37
|
+
accentColor = "#C6F135",
|
|
38
|
+
textColor = "#7c8087",
|
|
39
|
+
activeTextColor = "#ECF2E8",
|
|
40
|
+
trackColor = "#FFFFFF14",
|
|
41
|
+
children,
|
|
42
|
+
}: TabsProps) {
|
|
43
|
+
const [internal, setInternal] = useState(defaultIndex);
|
|
44
|
+
const current = clampIndex(index ?? internal, labels.length);
|
|
45
|
+
|
|
46
|
+
const select = (i: number) => {
|
|
47
|
+
setInternal(i);
|
|
48
|
+
onChange?.(i);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const panels: ReactNode[] = Array.isArray(children) ? children : children !== undefined ? [children] : [];
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<View style={width !== undefined ? { width } : undefined}>
|
|
55
|
+
<View className="flex-row relative" style={{ columnGap: 4 }}>
|
|
56
|
+
<View
|
|
57
|
+
className="absolute left-0 right-0 h-[1]"
|
|
58
|
+
style={{ bottom: 0, backgroundColor: trackColor }}
|
|
59
|
+
/>
|
|
60
|
+
{labels.map((label, i) => (
|
|
61
|
+
<View
|
|
62
|
+
key={`${label}-${i}`}
|
|
63
|
+
className="items-center cursor-pointer px-3 pt-1"
|
|
64
|
+
onClick={() => select(i)}
|
|
65
|
+
>
|
|
66
|
+
<Text
|
|
67
|
+
className="text-[11] font-bold tracking-widest"
|
|
68
|
+
style={{ color: i === current ? activeTextColor : textColor }}
|
|
69
|
+
>
|
|
70
|
+
{label}
|
|
71
|
+
</Text>
|
|
72
|
+
<View
|
|
73
|
+
className="rounded-full self-stretch"
|
|
74
|
+
style={{
|
|
75
|
+
height: 2,
|
|
76
|
+
marginTop: 6,
|
|
77
|
+
backgroundColor: i === current ? accentColor : "#00000000",
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
</View>
|
|
81
|
+
))}
|
|
82
|
+
</View>
|
|
83
|
+
{panels.length > 0 ? (
|
|
84
|
+
<View style={{ marginTop: gap }}>{panels[Math.min(current, panels.length - 1)]}</View>
|
|
85
|
+
) : null}
|
|
86
|
+
</View>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface DisclosureProps {
|
|
91
|
+
title: string;
|
|
92
|
+
/** Controlled open state; omit to let Disclosure manage it. */
|
|
93
|
+
open?: boolean;
|
|
94
|
+
/** Starting state when uncontrolled. Default false. */
|
|
95
|
+
defaultOpen?: boolean;
|
|
96
|
+
onChange?: (open: boolean) => void;
|
|
97
|
+
width?: number;
|
|
98
|
+
textColor?: string;
|
|
99
|
+
accentColor?: string;
|
|
100
|
+
children?: ReactNode;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A collapsible section row — "ADVANCED", "MODULATION". Click the
|
|
104
|
+
header to fold the content in and out. */
|
|
105
|
+
export function Disclosure({
|
|
106
|
+
title,
|
|
107
|
+
open,
|
|
108
|
+
defaultOpen = false,
|
|
109
|
+
onChange,
|
|
110
|
+
width,
|
|
111
|
+
textColor = "#a1a1aa",
|
|
112
|
+
accentColor = "#C6F135",
|
|
113
|
+
children,
|
|
114
|
+
}: DisclosureProps) {
|
|
115
|
+
const [internal, setInternal] = useState(defaultOpen);
|
|
116
|
+
const isOpen = open ?? internal;
|
|
117
|
+
|
|
118
|
+
const toggle = () => {
|
|
119
|
+
setInternal(!isOpen);
|
|
120
|
+
onChange?.(!isOpen);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<View style={width !== undefined ? { width } : undefined}>
|
|
125
|
+
<View className="flex-row items-center gap-2 cursor-pointer py-1" onClick={toggle}>
|
|
126
|
+
<Text className="text-[10] font-bold" style={{ color: accentColor, width: 10 }}>
|
|
127
|
+
{isOpen ? "▾" : "▸"}
|
|
128
|
+
</Text>
|
|
129
|
+
<Text className="text-[11] font-bold tracking-widest" style={{ color: textColor }}>
|
|
130
|
+
{title}
|
|
131
|
+
</Text>
|
|
132
|
+
</View>
|
|
133
|
+
{isOpen ? <View className="pt-2 pl-5">{children}</View> : null}
|
|
134
|
+
</View>
|
|
135
|
+
);
|
|
136
|
+
}
|
package/src/meter.tsx
CHANGED
|
@@ -67,6 +67,9 @@ export interface MeterProps {
|
|
|
67
67
|
thickness?: number;
|
|
68
68
|
/** Horizontal bar instead of the vertical default. */
|
|
69
69
|
horizontal?: boolean;
|
|
70
|
+
/** Fill from the top (vertical) / right (horizontal) — gain-reduction
|
|
71
|
+
meters. Default false. */
|
|
72
|
+
reverse?: boolean;
|
|
70
73
|
/** Show the peak-hold line. Default true. */
|
|
71
74
|
peak?: boolean;
|
|
72
75
|
holdMs?: number;
|
|
@@ -85,6 +88,7 @@ export function Meter({
|
|
|
85
88
|
length = 120,
|
|
86
89
|
thickness = 10,
|
|
87
90
|
horizontal,
|
|
91
|
+
reverse = false,
|
|
88
92
|
peak = true,
|
|
89
93
|
holdMs,
|
|
90
94
|
decayPerSecond,
|
|
@@ -108,19 +112,27 @@ export function Meter({
|
|
|
108
112
|
style={{ width: length, height: thickness, backgroundColor: trackColor }}
|
|
109
113
|
>
|
|
110
114
|
<View
|
|
111
|
-
className="absolute
|
|
112
|
-
style={{ width: fill, backgroundColor: color }}
|
|
115
|
+
className="absolute top-0 bottom-0"
|
|
116
|
+
style={reverse ? { right: 0, width: fill, backgroundColor: color } : { left: 0, width: fill, backgroundColor: color }}
|
|
113
117
|
/>
|
|
114
118
|
{hotFill > 0 ? (
|
|
115
119
|
<View
|
|
116
120
|
className="absolute top-0 bottom-0"
|
|
117
|
-
style={
|
|
121
|
+
style={
|
|
122
|
+
reverse
|
|
123
|
+
? { right: hot * length, width: hotFill, backgroundColor: hotColor }
|
|
124
|
+
: { left: hot * length, width: hotFill, backgroundColor: hotColor }
|
|
125
|
+
}
|
|
118
126
|
/>
|
|
119
127
|
) : null}
|
|
120
128
|
{peak && peakAt > 2 ? (
|
|
121
129
|
<View
|
|
122
130
|
className="absolute top-0 bottom-0 w-[2]"
|
|
123
|
-
style={
|
|
131
|
+
style={
|
|
132
|
+
reverse
|
|
133
|
+
? { right: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
|
|
134
|
+
: { left: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
|
|
135
|
+
}
|
|
124
136
|
/>
|
|
125
137
|
) : null}
|
|
126
138
|
</View>
|
|
@@ -130,19 +142,27 @@ export function Meter({
|
|
|
130
142
|
style={{ width: thickness, height: length, backgroundColor: trackColor }}
|
|
131
143
|
>
|
|
132
144
|
<View
|
|
133
|
-
className="absolute
|
|
134
|
-
style={{ height: fill, backgroundColor: color }}
|
|
145
|
+
className="absolute left-0 right-0"
|
|
146
|
+
style={reverse ? { top: 0, height: fill, backgroundColor: color } : { bottom: 0, height: fill, backgroundColor: color }}
|
|
135
147
|
/>
|
|
136
148
|
{hotFill > 0 ? (
|
|
137
149
|
<View
|
|
138
150
|
className="absolute left-0 right-0"
|
|
139
|
-
style={
|
|
151
|
+
style={
|
|
152
|
+
reverse
|
|
153
|
+
? { top: hot * length, height: hotFill, backgroundColor: hotColor }
|
|
154
|
+
: { bottom: hot * length, height: hotFill, backgroundColor: hotColor }
|
|
155
|
+
}
|
|
140
156
|
/>
|
|
141
157
|
) : null}
|
|
142
158
|
{peak && peakAt > 2 ? (
|
|
143
159
|
<View
|
|
144
160
|
className="absolute left-0 right-0 h-[2]"
|
|
145
|
-
style={
|
|
161
|
+
style={
|
|
162
|
+
reverse
|
|
163
|
+
? { top: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
|
|
164
|
+
: { bottom: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
|
|
165
|
+
}
|
|
146
166
|
/>
|
|
147
167
|
) : null}
|
|
148
168
|
</View>
|