@vsreact/core 0.0.13 → 0.0.15
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 +6 -2
- package/dist/components.js +3 -1
- package/dist/eq.d.ts +40 -0
- package/dist/eq.js +150 -0
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.js +12 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -2
- package/dist/layout.d.ts +37 -0
- package/dist/layout.js +33 -0
- package/dist/meter.d.ts +22 -1
- package/dist/meter.js +39 -2
- package/package.json +1 -1
- package/src/components.ts +6 -2
- package/src/eq.test.tsx +127 -0
- package/src/eq.tsx +231 -0
- package/src/hooks.ts +13 -0
- package/src/index.ts +7 -2
- package/src/layout.test.tsx +127 -0
- package/src/layout.tsx +136 -0
- package/src/meter.tsx +109 -8
package/src/eq.tsx
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// The EQ curve — a real biquad response display with draggable band
|
|
2
|
+
// nodes. Views only: the response is a center-anchored column fill
|
|
3
|
+
// (boost up, cut down), one node per band; drag moves freq/gain, the
|
|
4
|
+
// wheel adjusts Q. RBJ cookbook math, exported pure for reuse.
|
|
5
|
+
|
|
6
|
+
import { useRef } from "react";
|
|
7
|
+
import { View, Text } from "./primitives";
|
|
8
|
+
|
|
9
|
+
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
10
|
+
|
|
11
|
+
export type FilterType =
|
|
12
|
+
| "peak"
|
|
13
|
+
| "lowshelf"
|
|
14
|
+
| "highshelf"
|
|
15
|
+
| "lowpass"
|
|
16
|
+
| "highpass"
|
|
17
|
+
| "bandpass"
|
|
18
|
+
| "notch";
|
|
19
|
+
|
|
20
|
+
export interface EQBand {
|
|
21
|
+
type: FilterType;
|
|
22
|
+
/** Center/corner frequency in Hz (20..20000). */
|
|
23
|
+
freq: number;
|
|
24
|
+
/** Boost/cut in dB (peak & shelves). Default 0. */
|
|
25
|
+
gainDb?: number;
|
|
26
|
+
/** Resonance. Default 0.71. */
|
|
27
|
+
q?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** RBJ cookbook biquad magnitude at `atHz`, in dB. */
|
|
31
|
+
export function biquadMagnitudeDb(band: EQBand, atHz: number, sampleRate = 48000): number {
|
|
32
|
+
const { type, freq } = band;
|
|
33
|
+
const gainDb = band.gainDb ?? 0;
|
|
34
|
+
const q = Math.max(0.05, band.q ?? 0.71);
|
|
35
|
+
|
|
36
|
+
const A = Math.pow(10, gainDb / 40);
|
|
37
|
+
const w0 = (2 * Math.PI * clamp(freq, 1, sampleRate / 2 - 1)) / sampleRate;
|
|
38
|
+
const cos = Math.cos(w0);
|
|
39
|
+
const sin = Math.sin(w0);
|
|
40
|
+
const alpha = sin / (2 * q);
|
|
41
|
+
const rootA2Alpha = 2 * Math.sqrt(A) * alpha;
|
|
42
|
+
|
|
43
|
+
let b0: number, b1: number, b2: number, a0: number, a1: number, a2: number;
|
|
44
|
+
switch (type) {
|
|
45
|
+
case "peak":
|
|
46
|
+
b0 = 1 + alpha * A; b1 = -2 * cos; b2 = 1 - alpha * A;
|
|
47
|
+
a0 = 1 + alpha / A; a1 = -2 * cos; a2 = 1 - alpha / A;
|
|
48
|
+
break;
|
|
49
|
+
case "lowpass":
|
|
50
|
+
b0 = (1 - cos) / 2; b1 = 1 - cos; b2 = (1 - cos) / 2;
|
|
51
|
+
a0 = 1 + alpha; a1 = -2 * cos; a2 = 1 - alpha;
|
|
52
|
+
break;
|
|
53
|
+
case "highpass":
|
|
54
|
+
b0 = (1 + cos) / 2; b1 = -(1 + cos); b2 = (1 + cos) / 2;
|
|
55
|
+
a0 = 1 + alpha; a1 = -2 * cos; a2 = 1 - alpha;
|
|
56
|
+
break;
|
|
57
|
+
case "bandpass":
|
|
58
|
+
b0 = alpha; b1 = 0; b2 = -alpha;
|
|
59
|
+
a0 = 1 + alpha; a1 = -2 * cos; a2 = 1 - alpha;
|
|
60
|
+
break;
|
|
61
|
+
case "notch":
|
|
62
|
+
b0 = 1; b1 = -2 * cos; b2 = 1;
|
|
63
|
+
a0 = 1 + alpha; a1 = -2 * cos; a2 = 1 - alpha;
|
|
64
|
+
break;
|
|
65
|
+
case "lowshelf":
|
|
66
|
+
b0 = A * (A + 1 - (A - 1) * cos + rootA2Alpha);
|
|
67
|
+
b1 = 2 * A * (A - 1 - (A + 1) * cos);
|
|
68
|
+
b2 = A * (A + 1 - (A - 1) * cos - rootA2Alpha);
|
|
69
|
+
a0 = A + 1 + (A - 1) * cos + rootA2Alpha;
|
|
70
|
+
a1 = -2 * (A - 1 + (A + 1) * cos);
|
|
71
|
+
a2 = A + 1 + (A - 1) * cos - rootA2Alpha;
|
|
72
|
+
break;
|
|
73
|
+
case "highshelf":
|
|
74
|
+
b0 = A * (A + 1 + (A - 1) * cos + rootA2Alpha);
|
|
75
|
+
b1 = -2 * A * (A - 1 + (A + 1) * cos);
|
|
76
|
+
b2 = A * (A + 1 + (A - 1) * cos - rootA2Alpha);
|
|
77
|
+
a0 = A + 1 - (A - 1) * cos + rootA2Alpha;
|
|
78
|
+
a1 = 2 * (A - 1 - (A + 1) * cos);
|
|
79
|
+
a2 = A + 1 - (A - 1) * cos - rootA2Alpha;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const w = (2 * Math.PI * clamp(atHz, 1, sampleRate / 2 - 1)) / sampleRate;
|
|
84
|
+
const cw = Math.cos(w);
|
|
85
|
+
const c2w = Math.cos(2 * w);
|
|
86
|
+
const num = b0 * b0 + b1 * b1 + b2 * b2 + 2 * (b0 * b1 + b1 * b2) * cw + 2 * b0 * b2 * c2w;
|
|
87
|
+
const den = a0 * a0 + a1 * a1 + a2 * a2 + 2 * (a0 * a1 + a1 * a2) * cw + 2 * a0 * a2 * c2w;
|
|
88
|
+
return 10 * Math.log10(Math.max(1e-12, num / Math.max(1e-12, den)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The summed response of all bands at `atHz`, in dB. */
|
|
92
|
+
export function eqResponseDb(bands: EQBand[], atHz: number, sampleRate = 48000): number {
|
|
93
|
+
let db = 0;
|
|
94
|
+
for (const band of bands) db += biquadMagnitudeDb(band, atHz, sampleRate);
|
|
95
|
+
return db;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const FREQ_MIN = 20;
|
|
99
|
+
const FREQ_MAX = 20000;
|
|
100
|
+
const logSpan = Math.log(FREQ_MAX / FREQ_MIN);
|
|
101
|
+
|
|
102
|
+
/** 0..1 position ↔ 20..20k Hz on a log scale. */
|
|
103
|
+
export const eqXToHz = (x01: number) => FREQ_MIN * Math.exp(clamp(x01, 0, 1) * logSpan);
|
|
104
|
+
export const eqHzToX = (hz: number) => Math.log(clamp(hz, FREQ_MIN, FREQ_MAX) / FREQ_MIN) / logSpan;
|
|
105
|
+
|
|
106
|
+
export interface EQCurveProps {
|
|
107
|
+
bands: EQBand[];
|
|
108
|
+
width?: number;
|
|
109
|
+
height?: number;
|
|
110
|
+
/** Vertical range: ±dbRange dB. Default 18. */
|
|
111
|
+
dbRange?: number;
|
|
112
|
+
/** Fill resolution. Default 48 columns. */
|
|
113
|
+
columns?: number;
|
|
114
|
+
disabled?: boolean;
|
|
115
|
+
trackColor?: string;
|
|
116
|
+
/** The response fill. */
|
|
117
|
+
color?: string;
|
|
118
|
+
handleColor?: string;
|
|
119
|
+
label?: string;
|
|
120
|
+
/** A node was dragged (freq/gain) or wheeled (q). */
|
|
121
|
+
onChange?: (index: number, band: EQBand) => void;
|
|
122
|
+
onBegin?: (index: number) => void;
|
|
123
|
+
onEnd?: (index: number) => void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The EQ display every plugin wants: the real summed biquad response,
|
|
127
|
+
with one draggable node per band (x = freq on a log scale, y = gain)
|
|
128
|
+
and the wheel adjusting Q. */
|
|
129
|
+
export function EQCurve({
|
|
130
|
+
bands,
|
|
131
|
+
width = 220,
|
|
132
|
+
height = 96,
|
|
133
|
+
dbRange = 18,
|
|
134
|
+
columns = 48,
|
|
135
|
+
disabled,
|
|
136
|
+
trackColor = "#141714",
|
|
137
|
+
color = "#C6F13560",
|
|
138
|
+
handleColor = "#ECF2E8",
|
|
139
|
+
label,
|
|
140
|
+
onChange,
|
|
141
|
+
onBegin,
|
|
142
|
+
onEnd,
|
|
143
|
+
}: EQCurveProps) {
|
|
144
|
+
const start = useRef<{ freq: number; gainDb: number }>({ freq: 1000, gainDb: 0 });
|
|
145
|
+
const mid = height / 2;
|
|
146
|
+
const HANDLE = 18;
|
|
147
|
+
|
|
148
|
+
const yOfDb = (db: number) => mid - (clamp(db, -dbRange, dbRange) / dbRange) * (mid - 4);
|
|
149
|
+
|
|
150
|
+
const node = (band: EQBand, index: number) => {
|
|
151
|
+
const x = eqHzToX(band.freq) * width;
|
|
152
|
+
const y = yOfDb(band.gainDb ?? 0);
|
|
153
|
+
const handlers = disabled
|
|
154
|
+
? {}
|
|
155
|
+
: {
|
|
156
|
+
onDragStart: () => {
|
|
157
|
+
start.current = { freq: band.freq, gainDb: band.gainDb ?? 0 };
|
|
158
|
+
onBegin?.(index);
|
|
159
|
+
},
|
|
160
|
+
onDrag: (e: { dx: number; dy: number }) => {
|
|
161
|
+
const x01 = clamp(eqHzToX(start.current.freq) + e.dx / width, 0, 1);
|
|
162
|
+
onChange?.(index, {
|
|
163
|
+
...band,
|
|
164
|
+
freq: Math.round(eqXToHz(x01)),
|
|
165
|
+
gainDb: clamp(start.current.gainDb - (e.dy / (mid - 4)) * dbRange, -dbRange, dbRange),
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
onDragEnd: () => onEnd?.(index),
|
|
169
|
+
onWheel: (e: { dy: number }) => {
|
|
170
|
+
const q = Math.max(0.05, band.q ?? 0.71);
|
|
171
|
+
onBegin?.(index);
|
|
172
|
+
onChange?.(index, { ...band, q: clamp(q * Math.exp(e.dy * 1.4), 0.1, 18) });
|
|
173
|
+
onEnd?.(index);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<View
|
|
179
|
+
key={index}
|
|
180
|
+
className={`absolute items-center justify-center ${disabled ? "" : "cursor-pointer"}`}
|
|
181
|
+
style={{ left: x - HANDLE / 2, top: y - HANDLE / 2, width: HANDLE, height: HANDLE }}
|
|
182
|
+
{...handlers}
|
|
183
|
+
>
|
|
184
|
+
<View
|
|
185
|
+
className="rounded-full border"
|
|
186
|
+
style={{ width: 10, height: 10, backgroundColor: handleColor, borderColor: "#00000088" }}
|
|
187
|
+
/>
|
|
188
|
+
</View>
|
|
189
|
+
);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const body = (
|
|
193
|
+
<View
|
|
194
|
+
className={`relative rounded overflow-hidden ${disabled ? "opacity-40" : ""}`}
|
|
195
|
+
style={{ width, height, backgroundColor: trackColor }}
|
|
196
|
+
>
|
|
197
|
+
<View
|
|
198
|
+
className="absolute left-0 right-0 h-[1]"
|
|
199
|
+
style={{ top: mid, backgroundColor: "#FFFFFF24" }}
|
|
200
|
+
/>
|
|
201
|
+
{Array.from({ length: columns }, (_, i) => {
|
|
202
|
+
const hz = eqXToHz((i + 0.5) / columns);
|
|
203
|
+
const db = eqResponseDb(bands, hz);
|
|
204
|
+
const columnHeight = Math.abs(yOfDb(db) - mid);
|
|
205
|
+
return (
|
|
206
|
+
<View
|
|
207
|
+
key={i}
|
|
208
|
+
className="absolute"
|
|
209
|
+
style={{
|
|
210
|
+
left: (i / columns) * width + 0.5,
|
|
211
|
+
width: width / columns - 1,
|
|
212
|
+
top: db >= 0 ? mid - columnHeight : mid,
|
|
213
|
+
height: Math.max(1, columnHeight),
|
|
214
|
+
backgroundColor: color,
|
|
215
|
+
}}
|
|
216
|
+
/>
|
|
217
|
+
);
|
|
218
|
+
})}
|
|
219
|
+
{bands.map(node)}
|
|
220
|
+
</View>
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
if (label === undefined) return body;
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<View className="items-center gap-2">
|
|
227
|
+
{body}
|
|
228
|
+
<Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
|
|
229
|
+
</View>
|
|
230
|
+
);
|
|
231
|
+
}
|
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,
|
|
@@ -69,8 +70,10 @@ export type {
|
|
|
69
70
|
ParamSegmentedProps,
|
|
70
71
|
ParamSelectProps,
|
|
71
72
|
} from "./controls";
|
|
72
|
-
export { Meter, usePeakHold, peakHoldStep } from "./meter";
|
|
73
|
-
export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
|
|
73
|
+
export { Meter, RingMeter, usePeakHold, peakHoldStep } from "./meter";
|
|
74
|
+
export type { MeterProps, RingMeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
|
|
75
|
+
export { EQCurve, biquadMagnitudeDb, eqResponseDb, eqXToHz, eqHzToX } from "./eq";
|
|
76
|
+
export type { EQCurveProps, EQBand, FilterType } from "./eq";
|
|
74
77
|
export {
|
|
75
78
|
NumberBox,
|
|
76
79
|
Checkbox,
|
|
@@ -94,6 +97,8 @@ export { PianoKeyboard } from "./keyboard";
|
|
|
94
97
|
export type { PianoKeyboardProps } from "./keyboard";
|
|
95
98
|
export { StepSequencer } from "./sequencer";
|
|
96
99
|
export type { StepSequencerProps } from "./sequencer";
|
|
100
|
+
export { Tabs, Disclosure } from "./layout";
|
|
101
|
+
export type { TabsProps, DisclosureProps } from "./layout";
|
|
97
102
|
export {
|
|
98
103
|
ADSREnvelope,
|
|
99
104
|
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
|
+
}
|