@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.
@@ -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
- (globalThis as Record<string, any>).__vsreact_nativeCall = (name: string, argsJson: string) => {
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
@@ -31,6 +31,7 @@ const eventPropNames: Record<string, string> = {
31
31
  onDragStart: "dragstart",
32
32
  onDrag: "drag",
33
33
  onDragEnd: "dragend",
34
+ onLayout: "layout",
34
35
  onChange: "change",
35
36
  onSubmit: "submit",
36
37
  onFocus: "focus",
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 type { DragEventPayload } from "./primitives";
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
+ }