@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.
- package/dist/controls.d.ts +46 -0
- package/dist/controls.js +54 -2
- package/dist/hooks.d.ts +16 -0
- package/dist/hooks.js +26 -1
- package/dist/hostConfig.js +1 -0
- package/dist/index.d.ts +9 -6
- package/dist/index.js +5 -3
- package/dist/meter.d.ts +37 -0
- package/dist/meter.js +48 -0
- package/dist/overlay.d.ts +9 -0
- package/dist/overlay.js +47 -0
- package/dist/parameters.d.ts +14 -0
- package/dist/parameters.js +20 -0
- package/dist/primitives.d.ts +11 -0
- package/dist/render.d.ts +2 -1
- package/dist/render.js +5 -2
- package/dist/tw.js +9 -1
- package/package.json +2 -2
- package/src/animation.test.tsx +33 -0
- package/src/controls.test.tsx +174 -1
- package/src/controls.tsx +191 -2
- package/src/hooks.ts +31 -1
- package/src/hostConfig.ts +1 -0
- package/src/index.ts +13 -4
- package/src/meter.tsx +159 -0
- package/src/overlay.tsx +70 -0
- package/src/parameters.ts +30 -0
- package/src/primitives.tsx +11 -0
- package/src/render.tsx +12 -2
- package/src/tw.test.ts +12 -0
- package/src/tw.ts +6 -1
package/src/controls.test.tsx
CHANGED
|
@@ -7,12 +7,13 @@ let paramGetResult: any = { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB
|
|
|
7
7
|
(globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
|
|
8
8
|
batches.push(JSON.parse(json));
|
|
9
9
|
};
|
|
10
|
-
|
|
10
|
+
const defaultNativeCall = (name: string, argsJson: string) => {
|
|
11
11
|
const args = JSON.parse(argsJson);
|
|
12
12
|
nativeCalls.push({ name, args });
|
|
13
13
|
if (name === "param:get") return JSON.stringify(paramGetResult);
|
|
14
14
|
return "null";
|
|
15
15
|
};
|
|
16
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = defaultNativeCall;
|
|
16
17
|
|
|
17
18
|
import { render, unmount, Slider, Toggle, XYPad, Segmented, ParamSegmented, ParamToggle } from "./index";
|
|
18
19
|
|
|
@@ -33,6 +34,7 @@ beforeEach(() => {
|
|
|
33
34
|
batches.length = 0;
|
|
34
35
|
nativeCalls.length = 0;
|
|
35
36
|
paramGetResult = { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB" };
|
|
37
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = defaultNativeCall;
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
describe("vertical Slider", () => {
|
|
@@ -129,3 +131,174 @@ describe("Segmented", () => {
|
|
|
129
131
|
expect(set?.args).toEqual({ id: "shape", value: 0 });
|
|
130
132
|
});
|
|
131
133
|
});
|
|
134
|
+
|
|
135
|
+
describe("GenericEditor", () => {
|
|
136
|
+
test("renders one knob per parameter from param:list", () => {
|
|
137
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = (name: string, argsJson: string) => {
|
|
138
|
+
const args = JSON.parse(argsJson);
|
|
139
|
+
nativeCalls.push({ name, args });
|
|
140
|
+
if (name === "param:list")
|
|
141
|
+
return JSON.stringify([
|
|
142
|
+
{ id: "gain", name: "Gain", label: "dB", value: 0.5, text: "0.0 dB" },
|
|
143
|
+
{ id: "pan", name: "Pan", label: "", value: 0.5, text: "C" },
|
|
144
|
+
{ id: "mix", name: "Mix", label: "%", value: 1, text: "100%" },
|
|
145
|
+
]);
|
|
146
|
+
if (name === "param:get") return JSON.stringify(paramGetResult);
|
|
147
|
+
return "null";
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const { GenericEditor } = require("./index");
|
|
151
|
+
render(<GenericEditor columns={2} size={64} />);
|
|
152
|
+
|
|
153
|
+
expect(nativeCalls.some((c) => c.name === "param:list")).toBe(true);
|
|
154
|
+
const arcs = opsNamed("setProps").filter((op: any) => op[2]?.style?.arcValueEnd !== undefined);
|
|
155
|
+
expect(arcs.length).toBe(3);
|
|
156
|
+
expect(nativeCalls.filter((c) => c.name === "param:get").length).toBe(3);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("empty parameter list renders no knobs and doesn't crash", () => {
|
|
160
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = (name: string) => {
|
|
161
|
+
if (name === "param:list") return JSON.stringify([]);
|
|
162
|
+
return "null";
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const { GenericEditor } = require("./index");
|
|
166
|
+
render(<GenericEditor />);
|
|
167
|
+
const arcs = opsNamed("setProps").filter((op: any) => op[2]?.style?.arcValueEnd !== undefined);
|
|
168
|
+
expect(arcs.length).toBe(0);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("Meter", () => {
|
|
173
|
+
test("fill splits at the hot zone and peak line renders", () => {
|
|
174
|
+
const { Meter } = require("./index");
|
|
175
|
+
render(<Meter value={0.95} length={100} hotFrom={0.85} peak />);
|
|
176
|
+
|
|
177
|
+
// main fill capped at the hot boundary
|
|
178
|
+
const fill = opsNamed("setProps").find((op: any) => op[2]?.style?.height === 85);
|
|
179
|
+
expect(fill).toBeDefined();
|
|
180
|
+
// hot overflow segment: ~(0.95 - 0.85) * 100 tall, anchored at 85
|
|
181
|
+
const hot: any = opsNamed("setProps").find((op: any) => op[2]?.style?.bottom === 85);
|
|
182
|
+
expect(hot).toBeDefined();
|
|
183
|
+
expect(hot[2].style.height).toBeCloseTo(10);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("below the hot zone there is no hot segment", () => {
|
|
187
|
+
const { Meter } = require("./index");
|
|
188
|
+
render(<Meter value={0.5} length={100} hotFrom={0.85} peak={false} />);
|
|
189
|
+
|
|
190
|
+
const fill = opsNamed("setProps").find((op: any) => op[2]?.style?.height === 50);
|
|
191
|
+
expect(fill).toBeDefined();
|
|
192
|
+
const hot = opsNamed("setProps").find((op: any) => op[2]?.style?.bottom === 85);
|
|
193
|
+
expect(hot).toBeUndefined();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("onLayout + Select", () => {
|
|
198
|
+
/** Map child → parent from appendChild ops, to find a row by its text. */
|
|
199
|
+
const parentMap = () => {
|
|
200
|
+
const map = new Map<number, number>();
|
|
201
|
+
for (const op of allOps() as any[])
|
|
202
|
+
if (op[0] === "appendChild" || op[0] === "insertBefore") map.set(op[2], op[1]);
|
|
203
|
+
return map;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
test("onLayout registers a layout listener and receives the rect", () => {
|
|
207
|
+
const seen: any[] = [];
|
|
208
|
+
render(<Slider value={0.5} onChange={() => {}} />); // warm-up unrelated tree
|
|
209
|
+
unmount();
|
|
210
|
+
batches.length = 0;
|
|
211
|
+
|
|
212
|
+
const { View: V } = require("./index");
|
|
213
|
+
render(<V onLayout={(r: any) => seen.push(r)} className="w-4" />);
|
|
214
|
+
|
|
215
|
+
const props: any = opsNamed("setProps").find((op: any) =>
|
|
216
|
+
op[2]?.listeners?.includes("layout"),
|
|
217
|
+
);
|
|
218
|
+
expect(props).toBeDefined();
|
|
219
|
+
|
|
220
|
+
dispatch({
|
|
221
|
+
kind: "event",
|
|
222
|
+
nodeId: props[1],
|
|
223
|
+
type: "layout",
|
|
224
|
+
payload: { x: 5, y: 10, width: 160, height: 32 },
|
|
225
|
+
});
|
|
226
|
+
expect(seen).toEqual([{ x: 5, y: 10, width: 160, height: 32 }]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("Select opens a positioned overlay menu and selects on click", async () => {
|
|
230
|
+
const { Select } = require("./index");
|
|
231
|
+
const seen: number[] = [];
|
|
232
|
+
render(
|
|
233
|
+
<Select options={["SINE", "SAW", "SQR"]} index={0} width={160} onChange={(i: number) => seen.push(i)} />,
|
|
234
|
+
);
|
|
235
|
+
await new Promise((r) => setTimeout(r, 0)); // let effects flush
|
|
236
|
+
|
|
237
|
+
// trigger has both layout + click listeners
|
|
238
|
+
const trigger: any = opsNamed("setProps").find(
|
|
239
|
+
(op: any) => op[2]?.listeners?.includes("layout") && op[2]?.listeners?.includes("click"),
|
|
240
|
+
);
|
|
241
|
+
expect(trigger).toBeDefined();
|
|
242
|
+
|
|
243
|
+
// layout arrives, then the click opens the menu
|
|
244
|
+
dispatch({
|
|
245
|
+
kind: "event",
|
|
246
|
+
nodeId: trigger[1],
|
|
247
|
+
type: "layout",
|
|
248
|
+
payload: { x: 20, y: 40, width: 160, height: 33 },
|
|
249
|
+
});
|
|
250
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
251
|
+
dispatch({ kind: "event", nodeId: trigger[1], type: "click" });
|
|
252
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
253
|
+
|
|
254
|
+
// menu panel positioned under the trigger, same width
|
|
255
|
+
const panel: any = opsNamed("setProps").find(
|
|
256
|
+
(op: any) => op[2]?.style?.top === 40 + 33 + 4 && op[2]?.style?.left === 20,
|
|
257
|
+
);
|
|
258
|
+
expect(panel).toBeDefined();
|
|
259
|
+
expect(panel[2].style.width).toBe(160);
|
|
260
|
+
|
|
261
|
+
// find the "SAW" row: rawtext -> Text -> row
|
|
262
|
+
const sawText: any = allOps().find((op: any) => op[0] === "setText" && op[2] === "SAW");
|
|
263
|
+
expect(sawText).toBeDefined();
|
|
264
|
+
const parents = parentMap();
|
|
265
|
+
const rowId = parents.get(parents.get(sawText[1])!)!;
|
|
266
|
+
|
|
267
|
+
dispatch({ kind: "event", nodeId: rowId, type: "click" });
|
|
268
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
269
|
+
|
|
270
|
+
expect(seen).toEqual([1]);
|
|
271
|
+
// menu removed after selection
|
|
272
|
+
expect(allOps().some((op: any) => op[0] === "removeChild")).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("ParamSelect writes a begin/set/end gesture with the index mapping", async () => {
|
|
276
|
+
paramGetResult = { value: 0, text: "Sine", name: "Shape", label: "" };
|
|
277
|
+
const { ParamSelect } = require("./index");
|
|
278
|
+
render(<ParamSelect paramId="shape" options={["SINE", "SAW", "SQR"]} />);
|
|
279
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
280
|
+
|
|
281
|
+
const trigger: any = opsNamed("setProps").find(
|
|
282
|
+
(op: any) => op[2]?.listeners?.includes("layout") && op[2]?.listeners?.includes("click"),
|
|
283
|
+
);
|
|
284
|
+
dispatch({
|
|
285
|
+
kind: "event",
|
|
286
|
+
nodeId: trigger[1],
|
|
287
|
+
type: "layout",
|
|
288
|
+
payload: { x: 0, y: 0, width: 160, height: 33 },
|
|
289
|
+
});
|
|
290
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
291
|
+
dispatch({ kind: "event", nodeId: trigger[1], type: "click" });
|
|
292
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
293
|
+
|
|
294
|
+
const sqrText: any = allOps().find((op: any) => op[0] === "setText" && op[2] === "SQR");
|
|
295
|
+
const parents = parentMap();
|
|
296
|
+
const rowId = parents.get(parents.get(sqrText[1])!)!;
|
|
297
|
+
dispatch({ kind: "event", nodeId: rowId, type: "click" });
|
|
298
|
+
|
|
299
|
+
const set = nativeCalls.find((c) => c.name === "param:set");
|
|
300
|
+
expect(set?.args).toEqual({ id: "shape", value: 1 });
|
|
301
|
+
expect(nativeCalls.some((c) => c.name === "param:begin")).toBe(true);
|
|
302
|
+
expect(nativeCalls.some((c) => c.name === "param:end")).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
});
|
package/src/controls.tsx
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// switches — drawn by the VSReacT painter and driven by drag gestures.
|
|
3
3
|
// Each has a Param* variant bound to an APVTS parameter.
|
|
4
4
|
|
|
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
|
|
|
10
12
|
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
|
11
13
|
|
|
@@ -525,3 +527,190 @@ export function ParamSegmented({ paramId, options, label, ...rest }: ParamSegmen
|
|
|
525
527
|
/>
|
|
526
528
|
);
|
|
527
529
|
}
|
|
530
|
+
|
|
531
|
+
// ── GenericEditor ──────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
export interface GenericEditorProps {
|
|
534
|
+
/** Knobs per row. Default 4. */
|
|
535
|
+
columns?: number;
|
|
536
|
+
/** Knob diameter. Default 72. */
|
|
537
|
+
size?: number;
|
|
538
|
+
trackColor?: string;
|
|
539
|
+
valueColor?: string;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** The zero-effort editor: one knob per APVTS parameter, laid out in
|
|
543
|
+
rows. `render(<GenericEditor />)` is a complete, working plugin UI. */
|
|
544
|
+
export function GenericEditor({ columns = 4, size = 72, trackColor, valueColor }: GenericEditorProps) {
|
|
545
|
+
const params = useParameterList();
|
|
546
|
+
|
|
547
|
+
const rows: (typeof params)[] = [];
|
|
548
|
+
for (let i = 0; i < params.length; i += Math.max(1, columns))
|
|
549
|
+
rows.push(params.slice(i, i + Math.max(1, columns)));
|
|
550
|
+
|
|
551
|
+
return (
|
|
552
|
+
<View className="flex-1 flex-col items-center justify-center gap-6 p-4">
|
|
553
|
+
{rows.map((row, index) => (
|
|
554
|
+
<View key={index} className="flex-row gap-7">
|
|
555
|
+
{row.map((param) => (
|
|
556
|
+
<ParamKnob
|
|
557
|
+
key={param.id}
|
|
558
|
+
paramId={param.id}
|
|
559
|
+
size={size}
|
|
560
|
+
trackColor={trackColor}
|
|
561
|
+
valueColor={valueColor}
|
|
562
|
+
/>
|
|
563
|
+
))}
|
|
564
|
+
</View>
|
|
565
|
+
))}
|
|
566
|
+
</View>
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── Select ─────────────────────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
export interface SelectProps {
|
|
573
|
+
options: string[];
|
|
574
|
+
index: number;
|
|
575
|
+
/** Trigger width; the menu matches it. Default 160. */
|
|
576
|
+
width?: number;
|
|
577
|
+
label?: string;
|
|
578
|
+
disabled?: boolean;
|
|
579
|
+
trackColor?: string;
|
|
580
|
+
menuColor?: string;
|
|
581
|
+
activeColor?: string;
|
|
582
|
+
textColor?: string;
|
|
583
|
+
activeTextColor?: string;
|
|
584
|
+
/** Menu scrolls beyond this height. Default 190. */
|
|
585
|
+
maxMenuHeight?: number;
|
|
586
|
+
onChange: (index: number) => void;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const SELECT_ROW_HEIGHT = 30;
|
|
590
|
+
|
|
591
|
+
/** A dropdown: the menu renders in the overlay layer, positioned under
|
|
592
|
+
the trigger via onLayout, with a click-away backdrop and a scrolling
|
|
593
|
+
option list. */
|
|
594
|
+
export function Select({
|
|
595
|
+
options,
|
|
596
|
+
index,
|
|
597
|
+
width = 160,
|
|
598
|
+
label,
|
|
599
|
+
disabled,
|
|
600
|
+
trackColor = "#2A2F27",
|
|
601
|
+
menuColor = "#20241F",
|
|
602
|
+
activeColor = "#C6F135",
|
|
603
|
+
textColor = "#d4d4d8",
|
|
604
|
+
activeTextColor = "#09090b",
|
|
605
|
+
maxMenuHeight = 190,
|
|
606
|
+
onChange,
|
|
607
|
+
}: SelectProps) {
|
|
608
|
+
const [open, setOpen] = useState(false);
|
|
609
|
+
const [rect, onLayout] = useLayoutRect();
|
|
610
|
+
const overlay = useOverlay();
|
|
611
|
+
const current = Math.min(options.length - 1, Math.max(0, index));
|
|
612
|
+
|
|
613
|
+
useEffect(() => {
|
|
614
|
+
if (!open || rect === null) {
|
|
615
|
+
overlay.hide();
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const menuHeight = Math.min(options.length * SELECT_ROW_HEIGHT + 8, maxMenuHeight);
|
|
620
|
+
|
|
621
|
+
overlay.show(
|
|
622
|
+
<View className="absolute inset-0" onClick={() => setOpen(false)}>
|
|
623
|
+
<View
|
|
624
|
+
className="absolute rounded-lg border shadow-lg overflow-hidden"
|
|
625
|
+
style={{
|
|
626
|
+
left: rect.x,
|
|
627
|
+
top: rect.y + rect.height + 4,
|
|
628
|
+
width: rect.width,
|
|
629
|
+
backgroundColor: menuColor,
|
|
630
|
+
borderColor: "#00000066",
|
|
631
|
+
}}
|
|
632
|
+
>
|
|
633
|
+
<View className="overflow-y-scroll p-[4] gap-[2]" style={{ height: menuHeight }}>
|
|
634
|
+
{options.map((option, i) => (
|
|
635
|
+
<View
|
|
636
|
+
key={`${option}-${i}`}
|
|
637
|
+
className="px-3 py-[6] rounded cursor-pointer hover:bg-white/10"
|
|
638
|
+
style={{ backgroundColor: i === current ? activeColor : "#00000000" }}
|
|
639
|
+
onClick={() => {
|
|
640
|
+
onChange(i);
|
|
641
|
+
setOpen(false);
|
|
642
|
+
}}
|
|
643
|
+
>
|
|
644
|
+
<Text
|
|
645
|
+
className="text-[12] font-medium"
|
|
646
|
+
style={{ color: i === current ? activeTextColor : textColor }}
|
|
647
|
+
>
|
|
648
|
+
{option}
|
|
649
|
+
</Text>
|
|
650
|
+
</View>
|
|
651
|
+
))}
|
|
652
|
+
</View>
|
|
653
|
+
</View>
|
|
654
|
+
</View>,
|
|
655
|
+
);
|
|
656
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
657
|
+
}, [open, rect, options, current, menuColor, activeColor, textColor, activeTextColor, maxMenuHeight]);
|
|
658
|
+
|
|
659
|
+
return (
|
|
660
|
+
<View className="items-center gap-2">
|
|
661
|
+
<View
|
|
662
|
+
className={`flex-row items-center justify-between rounded-lg border px-3 py-[8] ${
|
|
663
|
+
disabled ? "opacity-40" : "cursor-pointer"
|
|
664
|
+
}`}
|
|
665
|
+
style={{ width, backgroundColor: trackColor, borderColor: "#00000055" }}
|
|
666
|
+
onLayout={onLayout}
|
|
667
|
+
onClick={disabled ? undefined : () => setOpen((v) => !v)}
|
|
668
|
+
>
|
|
669
|
+
<Text className="text-[12] font-medium" style={{ color: textColor }}>
|
|
670
|
+
{options[current] ?? ""}
|
|
671
|
+
</Text>
|
|
672
|
+
<Text className="text-[9]" style={{ color: textColor, opacity: 0.7 }}>
|
|
673
|
+
{open ? "▲" : "▼"}
|
|
674
|
+
</Text>
|
|
675
|
+
</View>
|
|
676
|
+
{label !== undefined ? (
|
|
677
|
+
<Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
|
|
678
|
+
) : null}
|
|
679
|
+
</View>
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export interface ParamSelectProps {
|
|
684
|
+
paramId: string;
|
|
685
|
+
options: string[];
|
|
686
|
+
width?: number;
|
|
687
|
+
label?: string;
|
|
688
|
+
trackColor?: string;
|
|
689
|
+
menuColor?: string;
|
|
690
|
+
activeColor?: string;
|
|
691
|
+
textColor?: string;
|
|
692
|
+
activeTextColor?: string;
|
|
693
|
+
maxMenuHeight?: number;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** A Select bound to a choice-style APVTS parameter (same value↔index
|
|
697
|
+
mapping as ParamSegmented). */
|
|
698
|
+
export function ParamSelect({ paramId, options, label, ...rest }: ParamSelectProps) {
|
|
699
|
+
const param = useParameter(paramId);
|
|
700
|
+
const count = Math.max(1, options.length);
|
|
701
|
+
const index = Math.round(param.value * (count - 1));
|
|
702
|
+
|
|
703
|
+
return (
|
|
704
|
+
<Select
|
|
705
|
+
options={options}
|
|
706
|
+
index={index}
|
|
707
|
+
label={label ?? param.name.toUpperCase()}
|
|
708
|
+
onChange={(i) => {
|
|
709
|
+
param.begin();
|
|
710
|
+
param.set(count <= 1 ? 0 : i / (count - 1));
|
|
711
|
+
param.end();
|
|
712
|
+
}}
|
|
713
|
+
{...rest}
|
|
714
|
+
/>
|
|
715
|
+
);
|
|
716
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
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
|
+
import type { LayoutRect } from "./primitives";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Captures a node's root-space rect from its onLayout prop:
|
|
9
|
+
*
|
|
10
|
+
* const [rect, onLayout] = useLayoutRect();
|
|
11
|
+
* <View onLayout={onLayout} /> // rect updates whenever layout moves it
|
|
12
|
+
*/
|
|
13
|
+
export function useLayoutRect(): [LayoutRect | null, (rect: LayoutRect) => void] {
|
|
14
|
+
const [rect, setRect] = useState<LayoutRect | null>(null);
|
|
15
|
+
return [rect, setRect];
|
|
16
|
+
}
|
|
5
17
|
|
|
6
18
|
/**
|
|
7
19
|
* Subscribes to a C++ event (RootView::sendNativeEvent) for the lifetime
|
|
@@ -16,3 +28,21 @@ export function useNativeEvent(name: string, handler: (payload: any) => void): v
|
|
|
16
28
|
|
|
17
29
|
useEffect(() => native.on(name, (payload) => handlerRef.current(payload)), [name]);
|
|
18
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The value, but only after it has stopped changing for `delayMs` —
|
|
34
|
+
* classic input debouncing for expensive native calls:
|
|
35
|
+
*
|
|
36
|
+
* const query = useDebounced(text, 250);
|
|
37
|
+
* useEffect(() => { native.call("library:search", { query }); }, [query]);
|
|
38
|
+
*/
|
|
39
|
+
export function useDebounced<T>(value: T, delayMs: number): T {
|
|
40
|
+
const [debounced, setDebounced] = useState(value);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const id = setTimeout(() => setDebounced(value), delayMs);
|
|
44
|
+
return () => clearTimeout(id);
|
|
45
|
+
}, [value, delayMs]);
|
|
46
|
+
|
|
47
|
+
return debounced;
|
|
48
|
+
}
|
package/src/hostConfig.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -13,26 +13,30 @@ 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, useLayoutRect } from "./hooks";
|
|
17
|
+
export { useOverlay, OverlayLayer } from "./overlay";
|
|
17
18
|
export { configureTheme, tw } from "./tw";
|
|
18
19
|
export type { Style, ResolvedClasses } from "./tw";
|
|
19
20
|
export { cx } from "./cx";
|
|
20
21
|
export type { ClassValue } from "./cx";
|
|
21
22
|
export { useTween, useSpring, springStep, lerp, Easing } from "./animation";
|
|
22
23
|
export type { TweenOptions, SpringOptions, EasingFn } from "./animation";
|
|
23
|
-
export { useParameter } from "./parameters";
|
|
24
|
-
export type { ParameterState, ParameterHandle } from "./parameters";
|
|
24
|
+
export { useParameter, useParameterList } from "./parameters";
|
|
25
|
+
export type { ParameterState, ParameterHandle, ParameterInfo } from "./parameters";
|
|
25
26
|
export {
|
|
26
27
|
Knob,
|
|
27
28
|
Slider,
|
|
28
29
|
Toggle,
|
|
29
30
|
XYPad,
|
|
30
31
|
Segmented,
|
|
32
|
+
Select,
|
|
33
|
+
GenericEditor,
|
|
31
34
|
ParamKnob,
|
|
32
35
|
ParamSlider,
|
|
33
36
|
ParamToggle,
|
|
34
37
|
ParamXYPad,
|
|
35
38
|
ParamSegmented,
|
|
39
|
+
ParamSelect,
|
|
36
40
|
dragToValue,
|
|
37
41
|
} from "./controls";
|
|
38
42
|
export type {
|
|
@@ -41,10 +45,15 @@ export type {
|
|
|
41
45
|
ToggleProps,
|
|
42
46
|
XYPadProps,
|
|
43
47
|
SegmentedProps,
|
|
48
|
+
SelectProps,
|
|
49
|
+
GenericEditorProps,
|
|
44
50
|
ParamKnobProps,
|
|
45
51
|
ParamSliderProps,
|
|
46
52
|
ParamToggleProps,
|
|
47
53
|
ParamXYPadProps,
|
|
48
54
|
ParamSegmentedProps,
|
|
55
|
+
ParamSelectProps,
|
|
49
56
|
} from "./controls";
|
|
50
|
-
export
|
|
57
|
+
export { Meter, usePeakHold, peakHoldStep } from "./meter";
|
|
58
|
+
export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
|
|
59
|
+
export type { DragEventPayload, LayoutRect } 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
|
+
}
|