@vsreact/core 0.0.6 → 0.0.8

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.
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from "react";
2
+ export interface ButtonProps {
3
+ label?: string;
4
+ children?: ReactNode;
5
+ onClick: () => void;
6
+ disabled?: boolean;
7
+ /** solid (default) — filled; outline — bordered; ghost — text only. */
8
+ variant?: "solid" | "outline" | "ghost";
9
+ size?: "sm" | "md" | "lg";
10
+ accentColor?: string;
11
+ /** Text color for the solid variant (default near-black). */
12
+ textColor?: string;
13
+ }
14
+ /** A pressable with sensible plugin styling — solid/outline/ghost. */
15
+ export declare function Button({ label, children, onClick, disabled, variant, size, accentColor, textColor, }: ButtonProps): import("react").JSX.Element;
package/dist/button.js ADDED
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { View, Text } from "./primitives";
3
+ import { cx } from "./cx";
4
+ const SIZES = {
5
+ sm: { pad: "px-3 py-[5]", text: 11 },
6
+ md: { pad: "px-4 py-[8]", text: 12 },
7
+ lg: { pad: "px-5 py-[11]", text: 13 },
8
+ };
9
+ /** A pressable with sensible plugin styling — solid/outline/ghost. */
10
+ export function Button({ label, children, onClick, disabled, variant = "solid", size = "md", accentColor = "#C6F135", textColor = "#09090b", }) {
11
+ const { pad, text } = SIZES[size];
12
+ const className = cx("flex-row items-center justify-center gap-2 rounded-lg", pad, disabled ? "opacity-40" : "cursor-pointer", !disabled && variant === "solid" && "hover:opacity-90 active:opacity-75", !disabled && variant !== "solid" && "hover:bg-white/10 active:bg-white/15", variant === "outline" && "border");
13
+ const style = variant === "solid"
14
+ ? { backgroundColor: accentColor }
15
+ : variant === "outline"
16
+ ? { borderColor: accentColor }
17
+ : {};
18
+ const color = variant === "solid" ? textColor : accentColor;
19
+ return (_jsxs(View, { className: className, style: style, onClick: disabled ? undefined : onClick, children: [label !== undefined ? (_jsx(Text, { className: "font-bold tracking-wide", style: { fontSize: text, color }, children: label })) : null, children] }));
20
+ }
@@ -0,0 +1,10 @@
1
+ export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, ParamSelect, dragToValue, } from "./controls";
2
+ export type { KnobProps, SliderProps, ToggleProps, XYPadProps, SegmentedProps, SelectProps, GenericEditorProps, ParamKnobProps, ParamSliderProps, ParamToggleProps, ParamXYPadProps, ParamSegmentedProps, ParamSelectProps, } from "./controls";
3
+ export { Meter, usePeakHold, peakHoldStep } from "./meter";
4
+ export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
5
+ export { Bars, Waveform, useRollingBuffer, pushRolling } from "./visualizers";
6
+ export type { BarsProps, WaveformProps } from "./visualizers";
7
+ export { Button } from "./button";
8
+ export type { ButtonProps } from "./button";
9
+ export { Tooltip, Modal } from "./popover";
10
+ export type { TooltipProps, ModalProps } from "./popover";
@@ -0,0 +1,12 @@
1
+ // The component layer, importable on its own:
2
+ //
3
+ // import { Knob, Select, Meter } from "@vsreact/core/components";
4
+ //
5
+ // Identical exports to the root — this subpath exists for readability
6
+ // and as the boundary line if the component kit ever becomes its own
7
+ // package.
8
+ export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, ParamSelect, dragToValue, } from "./controls";
9
+ export { Meter, usePeakHold, peakHoldStep } from "./meter";
10
+ export { Bars, Waveform, useRollingBuffer, pushRolling } from "./visualizers";
11
+ export { Button } from "./button";
12
+ export { Tooltip, Modal } from "./popover";
package/dist/hooks.d.ts CHANGED
@@ -22,3 +22,21 @@ export declare function useNativeEvent(name: string, handler: (payload: any) =>
22
22
  * useEffect(() => { native.call("library:search", { query }); }, [query]);
23
23
  */
24
24
  export declare function useDebounced<T>(value: T, delayMs: number): T;
25
+ /** The value, updated at most once per `intervalMs` (leading + trailing). */
26
+ export declare function useThrottled<T>(value: T, intervalMs: number): T;
27
+ /** The value from the previous render — undefined on the first one. */
28
+ export declare function usePrevious<T>(value: T): T | undefined;
29
+ /** Boolean state with a stable toggle — bypass buttons, panels, A/B. */
30
+ export declare function useToggle(initial?: boolean): [boolean, () => void, (next: boolean) => void];
31
+ /** A declarative interval on the host scheduler; null pauses it. The
32
+ callback stays fresh without restarting the timer. */
33
+ export declare function useInterval(callback: () => void, intervalMs: number | null): void;
34
+ /** Hover state + the props that drive it:
35
+ `const [hovered, hoverProps] = useHover(); <View {...hoverProps}>` */
36
+ export declare function useHover(): [
37
+ boolean,
38
+ {
39
+ onMouseEnter: () => void;
40
+ onMouseLeave: () => void;
41
+ }
42
+ ];
package/dist/hooks.js CHANGED
@@ -38,3 +38,59 @@ export function useDebounced(value, delayMs) {
38
38
  }, [value, delayMs]);
39
39
  return debounced;
40
40
  }
41
+ /** The value, updated at most once per `intervalMs` (leading + trailing). */
42
+ export function useThrottled(value, intervalMs) {
43
+ const [throttled, setThrottled] = useState(value);
44
+ const lastRun = useRef(0);
45
+ useEffect(() => {
46
+ const now = Date.now();
47
+ const elapsed = now - lastRun.current;
48
+ if (elapsed >= intervalMs) {
49
+ lastRun.current = now;
50
+ setThrottled(value);
51
+ return;
52
+ }
53
+ const id = setTimeout(() => {
54
+ lastRun.current = Date.now();
55
+ setThrottled(value);
56
+ }, intervalMs - elapsed);
57
+ return () => clearTimeout(id);
58
+ }, [value, intervalMs]);
59
+ return throttled;
60
+ }
61
+ /** The value from the previous render — undefined on the first one. */
62
+ export function usePrevious(value) {
63
+ const previous = useRef(undefined);
64
+ useEffect(() => {
65
+ previous.current = value;
66
+ }, [value]);
67
+ return previous.current;
68
+ }
69
+ /** Boolean state with a stable toggle — bypass buttons, panels, A/B. */
70
+ export function useToggle(initial = false) {
71
+ const [on, setOn] = useState(initial);
72
+ const toggle = useRef(() => setOn((v) => !v)).current;
73
+ return [on, toggle, setOn];
74
+ }
75
+ /** A declarative interval on the host scheduler; null pauses it. The
76
+ callback stays fresh without restarting the timer. */
77
+ export function useInterval(callback, intervalMs) {
78
+ const callbackRef = useRef(callback);
79
+ callbackRef.current = callback;
80
+ useEffect(() => {
81
+ if (intervalMs === null)
82
+ return;
83
+ const id = setInterval(() => callbackRef.current(), intervalMs);
84
+ return () => clearInterval(id);
85
+ }, [intervalMs]);
86
+ }
87
+ /** Hover state + the props that drive it:
88
+ `const [hovered, hoverProps] = useHover(); <View {...hoverProps}>` */
89
+ export function useHover() {
90
+ const [hovered, setHovered] = useState(false);
91
+ const props = useRef({
92
+ onMouseEnter: () => setHovered(true),
93
+ onMouseLeave: () => setHovered(false),
94
+ }).current;
95
+ return [hovered, props];
96
+ }
package/dist/index.d.ts CHANGED
@@ -4,10 +4,14 @@ 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, useLayoutRect } from "./hooks";
7
+ export { useNativeEvent, 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";
11
+ export { Button } from "./button";
12
+ export type { ButtonProps } from "./button";
13
+ export { Bars, Waveform, useRollingBuffer, pushRolling } from "./visualizers";
14
+ export type { BarsProps, WaveformProps } from "./visualizers";
11
15
  export { configureTheme, tw } from "./tw";
12
16
  export type { Style, ResolvedClasses } from "./tw";
13
17
  export { cx } from "./cx";
package/dist/index.js CHANGED
@@ -5,9 +5,11 @@ import "./bridge";
5
5
  export { View, Text, Image, TextInput, NativeView } from "./primitives";
6
6
  export { render, unmount } from "./render";
7
7
  export { native } from "./native";
8
- export { useNativeEvent, useDebounced, useLayoutRect } from "./hooks";
8
+ export { useNativeEvent, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
9
9
  export { useOverlay, OverlayLayer } from "./overlay";
10
10
  export { Tooltip, Modal } from "./popover";
11
+ export { Button } from "./button";
12
+ export { Bars, Waveform, useRollingBuffer, pushRolling } from "./visualizers";
11
13
  export { configureTheme, tw } from "./tw";
12
14
  export { cx } from "./cx";
13
15
  export { useTween, useSpring, springStep, lerp, Easing } from "./animation";
@@ -0,0 +1,37 @@
1
+ /** Pure rolling-window step (exported for tests): drop the oldest, push
2
+ the newest, pad with zeros to `length`. */
3
+ export declare function pushRolling(previous: number[], value: number, length: number): number[];
4
+ /** A rolling window of the last `length` values — turns a live scalar
5
+ (meter level, envelope) into data for <Waveform>/<Bars> history. */
6
+ export declare function useRollingBuffer(value: number, length?: number): number[];
7
+ export interface BarsProps {
8
+ /** One bar per entry, 0..1. */
9
+ values: number[];
10
+ width?: number;
11
+ height?: number;
12
+ /** Gap between bars. Default 2. */
13
+ gap?: number;
14
+ /** Bars at or above this turn hot. Default 0.85; 1 disables. */
15
+ hotFrom?: number;
16
+ trackColor?: string;
17
+ color?: string;
18
+ hotColor?: string;
19
+ label?: string;
20
+ }
21
+ /** Bottom-anchored bars — spectrum analyzers, band meters. */
22
+ export declare function Bars({ values, width, height, gap, hotFrom, trackColor, color, hotColor, label, }: BarsProps): import("react").JSX.Element;
23
+ export interface WaveformProps {
24
+ /** One bar per entry, −1..1 (or 0..1 — bars mirror around the centre). */
25
+ values: number[];
26
+ width?: number;
27
+ height?: number;
28
+ gap?: number;
29
+ trackColor?: string;
30
+ color?: string;
31
+ /** Draw the centre line. Default true. */
32
+ centerLine?: boolean;
33
+ centerLineColor?: string;
34
+ label?: string;
35
+ }
36
+ /** Centre-mirrored bars — waveform overviews, envelope history. */
37
+ export declare function Waveform({ values, width, height, gap, trackColor, color, centerLine, centerLineColor, label, }: WaveformProps): import("react").JSX.Element;
@@ -0,0 +1,49 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Audio visualizers — bar-based displays fed by arrays of values
3
+ // (typically pushed from C++ via useNativeEvent). For audio-rate scopes,
4
+ // host a juce::Component with <NativeView>; these cover the JS-rate
5
+ // cases: spectrum bars, envelope history, waveform overviews.
6
+ import { useEffect, useState } from "react";
7
+ import { View, Text } from "./primitives";
8
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
9
+ /** Pure rolling-window step (exported for tests): drop the oldest, push
10
+ the newest, pad with zeros to `length`. */
11
+ export function pushRolling(previous, value, length) {
12
+ const next = previous.slice(Math.max(0, previous.length - length + 1));
13
+ next.push(value);
14
+ while (next.length < length)
15
+ next.unshift(0);
16
+ return next;
17
+ }
18
+ /** A rolling window of the last `length` values — turns a live scalar
19
+ (meter level, envelope) into data for <Waveform>/<Bars> history. */
20
+ export function useRollingBuffer(value, length = 64) {
21
+ const [buffer, setBuffer] = useState(() => Array(length).fill(0));
22
+ useEffect(() => {
23
+ setBuffer((previous) => pushRolling(previous, value, length));
24
+ }, [value, length]);
25
+ return buffer;
26
+ }
27
+ /** Bottom-anchored bars — spectrum analyzers, band meters. */
28
+ export function Bars({ values, width = 160, height = 60, gap = 2, hotFrom = 0.85, trackColor = "#141714", color = "#C6F135", hotColor = "#FF4545", label, }) {
29
+ const bars = (_jsx(View, { className: "flex-row items-end rounded overflow-hidden px-[2]", style: { width, height, backgroundColor: trackColor, columnGap: gap }, children: values.map((value, index) => {
30
+ const level = clamp01(value);
31
+ return (_jsx(View, { className: "flex-1 rounded-[1]", style: {
32
+ height: Math.max(1, level * (height - 4)),
33
+ backgroundColor: level >= hotFrom ? hotColor : color,
34
+ } }, index));
35
+ }) }));
36
+ if (label === undefined)
37
+ return bars;
38
+ return (_jsxs(View, { className: "items-center gap-2", children: [bars, _jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })] }));
39
+ }
40
+ /** Centre-mirrored bars — waveform overviews, envelope history. */
41
+ export function Waveform({ values, width = 160, height = 60, gap = 2, trackColor = "#141714", color = "#C6F135", centerLine = true, centerLineColor = "#FFFFFF22", label, }) {
42
+ const wave = (_jsxs(View, { className: "relative flex-row items-center rounded overflow-hidden px-[2]", style: { width, height, backgroundColor: trackColor, columnGap: gap }, children: [centerLine ? (_jsx(View, { className: "absolute left-0 right-0 h-[1]", style: { top: height / 2, backgroundColor: centerLineColor } })) : null, values.map((value, index) => (_jsx(View, { className: "flex-1 rounded-[1]", style: {
43
+ height: Math.max(1, Math.min(1, Math.abs(value)) * (height - 4)),
44
+ backgroundColor: color,
45
+ } }, index)))] }));
46
+ if (label === undefined)
47
+ return wave;
48
+ return (_jsxs(View, { className: "items-center gap-2", children: [wave, _jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })] }));
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vsreact/core",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Write React. Ship native VST. A React renderer for JUCE audio plugins — QuickJS + Yoga + juce::Graphics, no webview.",
5
5
  "keywords": [
6
6
  "react",
@@ -31,6 +31,12 @@
31
31
  "bun": "./src/index.ts",
32
32
  "import": "./dist/index.js",
33
33
  "default": "./dist/index.js"
34
+ },
35
+ "./components": {
36
+ "types": "./dist/components.d.ts",
37
+ "bun": "./src/components.ts",
38
+ "import": "./dist/components.js",
39
+ "default": "./dist/components.js"
34
40
  }
35
41
  },
36
42
  "files": [
package/src/button.tsx ADDED
@@ -0,0 +1,68 @@
1
+ // Button — the pressable with hover/active states baked in.
2
+
3
+ import type { ReactNode } from "react";
4
+ import { View, Text } from "./primitives";
5
+ import { cx } from "./cx";
6
+ import type { Style } from "./tw";
7
+
8
+ export interface ButtonProps {
9
+ label?: string;
10
+ children?: ReactNode;
11
+ onClick: () => void;
12
+ disabled?: boolean;
13
+ /** solid (default) — filled; outline — bordered; ghost — text only. */
14
+ variant?: "solid" | "outline" | "ghost";
15
+ size?: "sm" | "md" | "lg";
16
+ accentColor?: string;
17
+ /** Text color for the solid variant (default near-black). */
18
+ textColor?: string;
19
+ }
20
+
21
+ const SIZES = {
22
+ sm: { pad: "px-3 py-[5]", text: 11 },
23
+ md: { pad: "px-4 py-[8]", text: 12 },
24
+ lg: { pad: "px-5 py-[11]", text: 13 },
25
+ } as const;
26
+
27
+ /** A pressable with sensible plugin styling — solid/outline/ghost. */
28
+ export function Button({
29
+ label,
30
+ children,
31
+ onClick,
32
+ disabled,
33
+ variant = "solid",
34
+ size = "md",
35
+ accentColor = "#C6F135",
36
+ textColor = "#09090b",
37
+ }: ButtonProps) {
38
+ const { pad, text } = SIZES[size];
39
+
40
+ const className = cx(
41
+ "flex-row items-center justify-center gap-2 rounded-lg",
42
+ pad,
43
+ disabled ? "opacity-40" : "cursor-pointer",
44
+ !disabled && variant === "solid" && "hover:opacity-90 active:opacity-75",
45
+ !disabled && variant !== "solid" && "hover:bg-white/10 active:bg-white/15",
46
+ variant === "outline" && "border",
47
+ );
48
+
49
+ const style: Style =
50
+ variant === "solid"
51
+ ? { backgroundColor: accentColor }
52
+ : variant === "outline"
53
+ ? { borderColor: accentColor }
54
+ : {};
55
+
56
+ const color = variant === "solid" ? textColor : accentColor;
57
+
58
+ return (
59
+ <View className={className} style={style} onClick={disabled ? undefined : onClick}>
60
+ {label !== undefined ? (
61
+ <Text className="font-bold tracking-wide" style={{ fontSize: text, color }}>
62
+ {label}
63
+ </Text>
64
+ ) : null}
65
+ {children}
66
+ </View>
67
+ );
68
+ }
@@ -0,0 +1,47 @@
1
+ // The component layer, importable on its own:
2
+ //
3
+ // import { Knob, Select, Meter } from "@vsreact/core/components";
4
+ //
5
+ // Identical exports to the root — this subpath exists for readability
6
+ // and as the boundary line if the component kit ever becomes its own
7
+ // package.
8
+
9
+ export {
10
+ Knob,
11
+ Slider,
12
+ Toggle,
13
+ XYPad,
14
+ Segmented,
15
+ Select,
16
+ GenericEditor,
17
+ ParamKnob,
18
+ ParamSlider,
19
+ ParamToggle,
20
+ ParamXYPad,
21
+ ParamSegmented,
22
+ ParamSelect,
23
+ dragToValue,
24
+ } from "./controls";
25
+ export type {
26
+ KnobProps,
27
+ SliderProps,
28
+ ToggleProps,
29
+ XYPadProps,
30
+ SegmentedProps,
31
+ SelectProps,
32
+ GenericEditorProps,
33
+ ParamKnobProps,
34
+ ParamSliderProps,
35
+ ParamToggleProps,
36
+ ParamXYPadProps,
37
+ ParamSegmentedProps,
38
+ ParamSelectProps,
39
+ } from "./controls";
40
+ export { Meter, usePeakHold, peakHoldStep } from "./meter";
41
+ export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
42
+ export { Bars, Waveform, useRollingBuffer, pushRolling } from "./visualizers";
43
+ export type { BarsProps, WaveformProps } from "./visualizers";
44
+ export { Button } from "./button";
45
+ export type { ButtonProps } from "./button";
46
+ export { Tooltip, Modal } from "./popover";
47
+ export type { TooltipProps, ModalProps } from "./popover";
@@ -433,3 +433,112 @@ describe("Tooltip + Modal", () => {
433
433
  expect(closed).toEqual([1]);
434
434
  });
435
435
  });
436
+
437
+ describe("0.0.7 — Button, visualizers, hooks", () => {
438
+ test("Button: click fires; disabled renders no listener; variants style", () => {
439
+ const { Button } = require("./index");
440
+ const seen: number[] = [];
441
+ render(<Button label="APPLY" onClick={() => seen.push(1)} accentColor="#FF2E2E" />);
442
+
443
+ dispatch({ kind: "event", nodeId: nodeWithListener("click"), type: "click" });
444
+ expect(seen).toEqual([1]);
445
+
446
+ // solid: filled with the accent
447
+ const solid: any = opsNamed("setProps").find(
448
+ (op: any) => op[2]?.style?.backgroundColor === "#FF2E2E",
449
+ );
450
+ expect(solid).toBeDefined();
451
+ expect(solid[2].hoverStyle).toBeDefined();
452
+
453
+ unmount();
454
+ batches.length = 0;
455
+ render(<Button label="X" disabled onClick={() => seen.push(2)} />);
456
+ expect(opsNamed("setProps").some((op: any) => op[2]?.listeners?.includes("click"))).toBe(false);
457
+
458
+ unmount();
459
+ batches.length = 0;
460
+ render(<Button label="Y" variant="outline" onClick={() => {}} accentColor="#FF2E2E" />);
461
+ const outline: any = opsNamed("setProps").find(
462
+ (op: any) => op[2]?.style?.borderColor === "#FF2E2E",
463
+ );
464
+ expect(outline).toBeDefined();
465
+ expect(outline[2].style.borderWidth).toBe(1);
466
+ });
467
+
468
+ test("Bars: one bar per value, hot bars switch color, heights scale", () => {
469
+ const { Bars } = require("./index");
470
+ render(<Bars values={[0.5, 0.9]} height={60} hotFrom={0.85} color="#AAA111" hotColor="#FF0000" />);
471
+
472
+ const bars = opsNamed("setProps").filter(
473
+ (op: any) => op[2]?.style?.backgroundColor === "#AAA111" || op[2]?.style?.backgroundColor === "#FF0000",
474
+ );
475
+ expect(bars.length).toBe(2);
476
+ const cold: any = bars.find((op: any) => op[2].style.backgroundColor === "#AAA111");
477
+ const hot: any = bars.find((op: any) => op[2].style.backgroundColor === "#FF0000");
478
+ expect(cold[2].style.height).toBeCloseTo(0.5 * 56);
479
+ expect(hot[2].style.height).toBeCloseTo(0.9 * 56);
480
+ });
481
+
482
+ test("Waveform: bars mirror around centre with |value| heights", () => {
483
+ const { Waveform } = require("./index");
484
+ render(<Waveform values={[-1, 0.5]} height={60} color="#AAA111" centerLine={false} />);
485
+
486
+ const bars = opsNamed("setProps")
487
+ .filter((op: any) => op[2]?.style?.backgroundColor === "#AAA111")
488
+ .map((op: any) => op[2].style.height);
489
+ expect(bars).toHaveLength(2);
490
+ expect(Math.max(...bars)).toBeCloseTo(56);
491
+ expect(Math.min(...bars)).toBeCloseTo(28);
492
+ });
493
+
494
+ test("pushRolling keeps a fixed window, newest last", () => {
495
+ const { pushRolling } = require("./index");
496
+ expect(pushRolling([1, 2, 3], 4, 3)).toEqual([2, 3, 4]);
497
+ expect(pushRolling([], 7, 3)).toEqual([0, 0, 7]);
498
+ expect(pushRolling([1, 2, 3, 4, 5], 6, 3)).toEqual([4, 5, 6]);
499
+ });
500
+
501
+ test("useToggle + useHover drive a probe component", () => {
502
+ const { useToggle, useHover, View: V, Text: T } = require("./index");
503
+
504
+ function Probe() {
505
+ const [on, toggle] = useToggle(false);
506
+ const [hovered, hoverProps] = useHover();
507
+ return (
508
+ <V onClick={toggle} {...hoverProps}>
509
+ <T>{`${on ? "ON" : "OFF"}:${hovered ? "HOV" : "OUT"}`}</T>
510
+ </V>
511
+ );
512
+ }
513
+
514
+ render(<Probe />);
515
+ const id = nodeWithListener("click");
516
+
517
+ dispatch({ kind: "event", nodeId: id, type: "click" });
518
+ dispatch({ kind: "event", nodeId: id, type: "mouseenter" });
519
+ expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "ON:HOV")).toBe(true);
520
+
521
+ dispatch({ kind: "event", nodeId: id, type: "mouseleave" });
522
+ expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "ON:OUT")).toBe(true);
523
+ });
524
+
525
+ test("usePrevious reports the last-render value", () => {
526
+ const { usePrevious, View: V, Text: T } = require("./index");
527
+ const values: Array<string> = [];
528
+
529
+ function Probe({ n }: { n: number }) {
530
+ const prev = usePrevious(n);
531
+ values.push(`${prev}->${n}`);
532
+ return (
533
+ <V>
534
+ <T>{n}</T>
535
+ </V>
536
+ );
537
+ }
538
+
539
+ render(<Probe n={1} />);
540
+ render(<Probe n={2} />);
541
+ expect(values[0]).toBe("undefined->1");
542
+ expect(values.at(-1)).toBe("1->2");
543
+ });
544
+ });
package/src/hooks.ts CHANGED
@@ -46,3 +46,74 @@ export function useDebounced<T>(value: T, delayMs: number): T {
46
46
 
47
47
  return debounced;
48
48
  }
49
+
50
+ /** The value, updated at most once per `intervalMs` (leading + trailing). */
51
+ export function useThrottled<T>(value: T, intervalMs: number): T {
52
+ const [throttled, setThrottled] = useState(value);
53
+ const lastRun = useRef(0);
54
+
55
+ useEffect(() => {
56
+ const now = Date.now();
57
+ const elapsed = now - lastRun.current;
58
+
59
+ if (elapsed >= intervalMs) {
60
+ lastRun.current = now;
61
+ setThrottled(value);
62
+ return;
63
+ }
64
+
65
+ const id = setTimeout(() => {
66
+ lastRun.current = Date.now();
67
+ setThrottled(value);
68
+ }, intervalMs - elapsed);
69
+ return () => clearTimeout(id);
70
+ }, [value, intervalMs]);
71
+
72
+ return throttled;
73
+ }
74
+
75
+ /** The value from the previous render — undefined on the first one. */
76
+ export function usePrevious<T>(value: T): T | undefined {
77
+ const previous = useRef<T | undefined>(undefined);
78
+
79
+ useEffect(() => {
80
+ previous.current = value;
81
+ }, [value]);
82
+
83
+ return previous.current;
84
+ }
85
+
86
+ /** Boolean state with a stable toggle — bypass buttons, panels, A/B. */
87
+ export function useToggle(initial = false): [boolean, () => void, (next: boolean) => void] {
88
+ const [on, setOn] = useState(initial);
89
+ const toggle = useRef(() => setOn((v) => !v)).current;
90
+ return [on, toggle, setOn];
91
+ }
92
+
93
+ /** A declarative interval on the host scheduler; null pauses it. The
94
+ callback stays fresh without restarting the timer. */
95
+ export function useInterval(callback: () => void, intervalMs: number | null): void {
96
+ const callbackRef = useRef(callback);
97
+ callbackRef.current = callback;
98
+
99
+ useEffect(() => {
100
+ if (intervalMs === null) return;
101
+ const id = setInterval(() => callbackRef.current(), intervalMs);
102
+ return () => clearInterval(id);
103
+ }, [intervalMs]);
104
+ }
105
+
106
+ /** Hover state + the props that drive it:
107
+ `const [hovered, hoverProps] = useHover(); <View {...hoverProps}>` */
108
+ export function useHover(): [
109
+ boolean,
110
+ { onMouseEnter: () => void; onMouseLeave: () => void },
111
+ ] {
112
+ const [hovered, setHovered] = useState(false);
113
+ const props = useRef({
114
+ onMouseEnter: () => setHovered(true),
115
+ onMouseLeave: () => setHovered(false),
116
+ }).current;
117
+
118
+ return [hovered, props];
119
+ }
package/src/index.ts CHANGED
@@ -13,10 +13,23 @@ export type {
13
13
  } from "./primitives";
14
14
  export { render, unmount } from "./render";
15
15
  export { native } from "./native";
16
- export { useNativeEvent, useDebounced, useLayoutRect } from "./hooks";
16
+ export {
17
+ useNativeEvent,
18
+ useDebounced,
19
+ useThrottled,
20
+ usePrevious,
21
+ useToggle,
22
+ useInterval,
23
+ useHover,
24
+ useLayoutRect,
25
+ } from "./hooks";
17
26
  export { useOverlay, OverlayLayer } from "./overlay";
18
27
  export { Tooltip, Modal } from "./popover";
19
28
  export type { TooltipProps, ModalProps } from "./popover";
29
+ export { Button } from "./button";
30
+ export type { ButtonProps } from "./button";
31
+ export { Bars, Waveform, useRollingBuffer, pushRolling } from "./visualizers";
32
+ export type { BarsProps, WaveformProps } from "./visualizers";
20
33
  export { configureTheme, tw } from "./tw";
21
34
  export type { Style, ResolvedClasses } from "./tw";
22
35
  export { cx } from "./cx";
@@ -0,0 +1,148 @@
1
+ // Audio visualizers — bar-based displays fed by arrays of values
2
+ // (typically pushed from C++ via useNativeEvent). For audio-rate scopes,
3
+ // host a juce::Component with <NativeView>; these cover the JS-rate
4
+ // cases: spectrum bars, envelope history, waveform overviews.
5
+
6
+ import { useEffect, useState } from "react";
7
+ import { View, Text } from "./primitives";
8
+
9
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
10
+
11
+ /** Pure rolling-window step (exported for tests): drop the oldest, push
12
+ the newest, pad with zeros to `length`. */
13
+ export function pushRolling(previous: number[], value: number, length: number): number[] {
14
+ const next = previous.slice(Math.max(0, previous.length - length + 1));
15
+ next.push(value);
16
+ while (next.length < length) next.unshift(0);
17
+ return next;
18
+ }
19
+
20
+ /** A rolling window of the last `length` values — turns a live scalar
21
+ (meter level, envelope) into data for <Waveform>/<Bars> history. */
22
+ export function useRollingBuffer(value: number, length = 64): number[] {
23
+ const [buffer, setBuffer] = useState<number[]>(() => Array(length).fill(0));
24
+
25
+ useEffect(() => {
26
+ setBuffer((previous) => pushRolling(previous, value, length));
27
+ }, [value, length]);
28
+
29
+ return buffer;
30
+ }
31
+
32
+ export interface BarsProps {
33
+ /** One bar per entry, 0..1. */
34
+ values: number[];
35
+ width?: number;
36
+ height?: number;
37
+ /** Gap between bars. Default 2. */
38
+ gap?: number;
39
+ /** Bars at or above this turn hot. Default 0.85; 1 disables. */
40
+ hotFrom?: number;
41
+ trackColor?: string;
42
+ color?: string;
43
+ hotColor?: string;
44
+ label?: string;
45
+ }
46
+
47
+ /** Bottom-anchored bars — spectrum analyzers, band meters. */
48
+ export function Bars({
49
+ values,
50
+ width = 160,
51
+ height = 60,
52
+ gap = 2,
53
+ hotFrom = 0.85,
54
+ trackColor = "#141714",
55
+ color = "#C6F135",
56
+ hotColor = "#FF4545",
57
+ label,
58
+ }: BarsProps) {
59
+ const bars = (
60
+ <View
61
+ className="flex-row items-end rounded overflow-hidden px-[2]"
62
+ style={{ width, height, backgroundColor: trackColor, columnGap: gap }}
63
+ >
64
+ {values.map((value, index) => {
65
+ const level = clamp01(value);
66
+ return (
67
+ <View
68
+ key={index}
69
+ className="flex-1 rounded-[1]"
70
+ style={{
71
+ height: Math.max(1, level * (height - 4)),
72
+ backgroundColor: level >= hotFrom ? hotColor : color,
73
+ }}
74
+ />
75
+ );
76
+ })}
77
+ </View>
78
+ );
79
+
80
+ if (label === undefined) return bars;
81
+
82
+ return (
83
+ <View className="items-center gap-2">
84
+ {bars}
85
+ <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
86
+ </View>
87
+ );
88
+ }
89
+
90
+ export interface WaveformProps {
91
+ /** One bar per entry, −1..1 (or 0..1 — bars mirror around the centre). */
92
+ values: number[];
93
+ width?: number;
94
+ height?: number;
95
+ gap?: number;
96
+ trackColor?: string;
97
+ color?: string;
98
+ /** Draw the centre line. Default true. */
99
+ centerLine?: boolean;
100
+ centerLineColor?: string;
101
+ label?: string;
102
+ }
103
+
104
+ /** Centre-mirrored bars — waveform overviews, envelope history. */
105
+ export function Waveform({
106
+ values,
107
+ width = 160,
108
+ height = 60,
109
+ gap = 2,
110
+ trackColor = "#141714",
111
+ color = "#C6F135",
112
+ centerLine = true,
113
+ centerLineColor = "#FFFFFF22",
114
+ label,
115
+ }: WaveformProps) {
116
+ const wave = (
117
+ <View
118
+ className="relative flex-row items-center rounded overflow-hidden px-[2]"
119
+ style={{ width, height, backgroundColor: trackColor, columnGap: gap }}
120
+ >
121
+ {centerLine ? (
122
+ <View
123
+ className="absolute left-0 right-0 h-[1]"
124
+ style={{ top: height / 2, backgroundColor: centerLineColor }}
125
+ />
126
+ ) : null}
127
+ {values.map((value, index) => (
128
+ <View
129
+ key={index}
130
+ className="flex-1 rounded-[1]"
131
+ style={{
132
+ height: Math.max(1, Math.min(1, Math.abs(value)) * (height - 4)),
133
+ backgroundColor: color,
134
+ }}
135
+ />
136
+ ))}
137
+ </View>
138
+ );
139
+
140
+ if (label === undefined) return wave;
141
+
142
+ return (
143
+ <View className="items-center gap-2">
144
+ {wave}
145
+ <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
146
+ </View>
147
+ );
148
+ }