@vsreact/core 0.0.11 → 0.0.12

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/src/feedback.tsx CHANGED
@@ -14,6 +14,9 @@ export interface ProgressBarProps {
14
14
  height?: number;
15
15
  /** Renders "42%" alongside the bar. Default false. */
16
16
  showPercent?: boolean;
17
+ /** Unknown duration — a segment sweeps the track and `value` is
18
+ ignored. Default false. */
19
+ indeterminate?: boolean;
17
20
  trackColor?: string;
18
21
  color?: string;
19
22
  label?: string;
@@ -25,24 +28,43 @@ export function ProgressBar({
25
28
  width = 200,
26
29
  height = 8,
27
30
  showPercent = false,
31
+ indeterminate = false,
28
32
  trackColor = "#2A2F27",
29
33
  color = "#C6F135",
30
34
  label,
31
35
  }: ProgressBarProps) {
32
36
  const level = clamp01(value);
33
37
 
38
+ // The indeterminate sweep: a 30%-width segment marching left → right.
39
+ const [sweep, setSweep] = useState(0);
40
+ useInterval(() => setSweep((t) => (t + 0.018) % 1), indeterminate ? 16 : null);
41
+ const segment = width * 0.3;
42
+
34
43
  const bar = (
35
44
  <View className="flex-row items-center gap-3">
36
45
  <View
37
- className="rounded-full overflow-hidden"
46
+ className="rounded-full overflow-hidden relative"
38
47
  style={{ width, height, backgroundColor: trackColor }}
39
48
  >
40
- <View
41
- className="rounded-full"
42
- style={{ width: level * width, height, backgroundColor: color }}
43
- />
49
+ {indeterminate ? (
50
+ <View
51
+ className="rounded-full absolute"
52
+ style={{
53
+ left: sweep * (width + segment) - segment,
54
+ top: 0,
55
+ width: segment,
56
+ height,
57
+ backgroundColor: color,
58
+ }}
59
+ />
60
+ ) : (
61
+ <View
62
+ className="rounded-full"
63
+ style={{ width: level * width, height, backgroundColor: color }}
64
+ />
65
+ )}
44
66
  </View>
45
- {showPercent ? (
67
+ {showPercent && !indeterminate ? (
46
68
  <Text className="text-faint text-[11] font-bold" style={{ width: 34 }}>
47
69
  {`${Math.round(level * 100)}%`}
48
70
  </Text>
package/src/format.ts ADDED
@@ -0,0 +1,62 @@
1
+ // Value formatting for parameter readouts — the strings DAW users
2
+ // expect next to a knob. Pure functions; pair them with NumberBox's
3
+ // `format` prop or any Text.
4
+
5
+ const trim = (value: number, decimals: number) => String(parseFloat(value.toFixed(decimals)));
6
+
7
+ /** Linear interpolation of `value` from [inMin, inMax] to [outMin, outMax].
8
+ `clampOut` pins the result inside the output range. */
9
+ export function mapRange(
10
+ value: number,
11
+ inMin: number,
12
+ inMax: number,
13
+ outMin: number,
14
+ outMax: number,
15
+ clampOut = false,
16
+ ): number {
17
+ const t = inMax === inMin ? 0 : (value - inMin) / (inMax - inMin);
18
+ const out = outMin + t * (outMax - outMin);
19
+ if (!clampOut) return out;
20
+ const lo = Math.min(outMin, outMax);
21
+ const hi = Math.max(outMin, outMax);
22
+ return Math.min(hi, Math.max(lo, out));
23
+ }
24
+
25
+ /** Decibels with an explicit sign: "+6.0 dB", "0.0 dB", "-12.5 dB".
26
+ -Infinity (a gain fader on the floor) reads "-inf dB". */
27
+ export function formatDb(db: number, decimals = 1): string {
28
+ if (db === Number.NEGATIVE_INFINITY) return "-inf dB";
29
+ const text = db.toFixed(decimals);
30
+ return `${db > 0 && !text.startsWith("+") ? "+" : ""}${text} dB`;
31
+ }
32
+
33
+ /** Frequency with automatic kHz: "440 Hz", "1.2 kHz". */
34
+ export function formatHz(hz: number, decimals = 1): string {
35
+ if (Math.abs(hz) >= 1000) return `${trim(hz / 1000, Math.max(decimals, 1))} kHz`;
36
+ return `${trim(hz, decimals)} Hz`;
37
+ }
38
+
39
+ /** Time with automatic seconds: "350 ms", "1.25 s". */
40
+ export function formatMs(ms: number, decimals = 0): string {
41
+ if (Math.abs(ms) >= 1000) return `${trim(ms / 1000, Math.max(decimals, 2))} s`;
42
+ return `${trim(ms, decimals)} ms`;
43
+ }
44
+
45
+ /** A 0..1 value as "42%". */
46
+ export function formatPercent(value01: number, decimals = 0): string {
47
+ return `${trim(value01 * 100, decimals)}%`;
48
+ }
49
+
50
+ /** Semitones with an explicit sign: "+7 st", "0 st", "-12 st". */
51
+ export function formatSemitones(semitones: number, decimals = 0): string {
52
+ const text = trim(semitones, decimals);
53
+ return `${semitones > 0 && !text.startsWith("+") ? "+" : ""}${text} st`;
54
+ }
55
+
56
+ const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
57
+
58
+ /** A MIDI note number as scientific pitch: 60 → "C4", 69 → "A4". */
59
+ export function midiNoteName(note: number): string {
60
+ const n = Math.round(note);
61
+ return `${NOTE_NAMES[((n % 12) + 12) % 12]}${Math.floor(n / 12) - 1}`;
62
+ }
@@ -1,120 +1,120 @@
1
- import { beforeEach, describe, expect, test } from "bun:test";
2
- import { useState } from "react";
3
-
4
- const batches: unknown[][][] = [];
5
- (globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
6
- batches.push(JSON.parse(json));
7
- };
8
-
9
- import { render, unmount, View, Text, TextInput } from "./index";
10
-
11
- const allOps = () => batches.flat();
12
- const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
13
- const dispatch = (msg: unknown) =>
14
- (globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
15
-
16
- beforeEach(() => {
17
- unmount();
18
- batches.length = 0;
19
- });
20
-
21
- describe("reconciler host config", () => {
22
- test("initial mount emits create/setProps/append ops ending at the root", () => {
23
- render(
24
- <View className="p-4">
25
- <Text>hi</Text>
26
- </View>,
27
- );
28
-
29
- const ops = allOps();
30
- const creates = opsNamed("create");
31
-
32
- // view + text + rawtext
33
- expect(creates.length).toBe(3);
34
- const viewId = (creates.find((op: any) => op[2] === "view") as any)[1];
35
- const textId = (creates.find((op: any) => op[2] === "text") as any)[1];
36
- const rawId = (creates.find((op: any) => op[2] === "rawtext") as any)[1];
37
-
38
- const viewProps: any = opsNamed("setProps").find((op: any) => op[1] === viewId);
39
- expect(viewProps[2].style.padding).toBe(16);
40
-
41
- expect(opsNamed("setText")).toContainEqual(["setText", rawId, "hi"]);
42
- expect(opsNamed("appendChild")).toContainEqual(["appendChild", textId, rawId]);
43
- expect(opsNamed("appendChild")).toContainEqual(["appendChild", viewId, textId]);
44
- expect(opsNamed("appendChild")).toContainEqual(["appendChild", 0, viewId]);
45
-
46
- // root attach comes after the subtree is assembled
47
- const opNames = ops.map((op: any) => op.join(":"));
48
- expect(opNames.indexOf(`appendChild:0:${viewId}`)).toBeGreaterThan(
49
- opNames.indexOf(`appendChild:${viewId}:${textId}`),
50
- );
51
- });
52
-
53
- test("click handlers register as listeners and receive dispatches", () => {
54
- let clicks = 0;
55
-
56
- render(<View onClick={() => clicks++} className="w-4" />);
57
-
58
- const create: any = opsNamed("create")[0];
59
- const props: any = opsNamed("setProps").find((op: any) => op[1] === create[1]);
60
- expect(props[2].listeners).toEqual(["click"]);
61
-
62
- dispatch({ kind: "event", nodeId: create[1], type: "click" });
63
- expect(clicks).toBe(1);
64
- });
65
-
66
- test("state updates emit new setProps and setText", () => {
67
- function Counter() {
68
- const [count, setCount] = useState(0);
69
- return (
70
- <View onClick={() => setCount((c) => c + 1)}>
71
- <Text>{`count:${count}`}</Text>
72
- </View>
73
- );
74
- }
75
-
76
- render(<Counter />);
77
-
78
- const viewId = (opsNamed("create").find((op: any) => op[2] === "view") as any)[1];
79
- batches.length = 0;
80
-
81
- dispatch({ kind: "event", nodeId: viewId, type: "click" });
82
-
83
- expect(opsNamed("setText").some((op: any) => op[2] === "count:1")).toBe(true);
84
- });
85
-
86
- test("TextInput onChange receives the plain string value", () => {
87
- const seen: string[] = [];
88
-
89
- render(<TextInput onChange={(v) => seen.push(v)} />);
90
-
91
- const inputId = (opsNamed("create").find((op: any) => op[2] === "textinput") as any)[1];
92
- dispatch({ kind: "event", nodeId: inputId, type: "change", payload: { value: "abc" } });
93
-
94
- expect(seen).toEqual(["abc"]);
95
- });
96
-
97
- test("conditional removal emits removeChild", () => {
98
- function Toggle({ on }: { on: boolean }) {
99
- return <View>{on ? <View className="w-2" /> : null}</View>;
100
- }
101
-
102
- render(<Toggle on={true} />);
103
- // React creates instances bottom-up: the inner view is created first.
104
- const childId = (opsNamed("create")[0] as any)[1];
105
- batches.length = 0;
106
-
107
- render(<Toggle on={false} />);
108
- expect(opsNamed("removeChild").some((op: any) => op[2] === childId)).toBe(true);
109
- });
110
-
111
- test("unmount clears the container", () => {
112
- render(<View />);
113
- batches.length = 0;
114
- unmount();
115
- const ops = allOps();
116
- expect(ops.some((op: any) => op[0] === "removeChild" || op[0] === "clearContainer")).toBe(
117
- true,
118
- );
119
- });
120
- });
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import { useState } from "react";
3
+
4
+ const batches: unknown[][][] = [];
5
+ (globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
6
+ batches.push(JSON.parse(json));
7
+ };
8
+
9
+ import { render, unmount, View, Text, TextInput } from "./index";
10
+
11
+ const allOps = () => batches.flat();
12
+ const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
13
+ const dispatch = (msg: unknown) =>
14
+ (globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
15
+
16
+ beforeEach(() => {
17
+ unmount();
18
+ batches.length = 0;
19
+ });
20
+
21
+ describe("reconciler host config", () => {
22
+ test("initial mount emits create/setProps/append ops ending at the root", () => {
23
+ render(
24
+ <View className="p-4">
25
+ <Text>hi</Text>
26
+ </View>,
27
+ );
28
+
29
+ const ops = allOps();
30
+ const creates = opsNamed("create");
31
+
32
+ // view + text + rawtext
33
+ expect(creates.length).toBe(3);
34
+ const viewId = (creates.find((op: any) => op[2] === "view") as any)[1];
35
+ const textId = (creates.find((op: any) => op[2] === "text") as any)[1];
36
+ const rawId = (creates.find((op: any) => op[2] === "rawtext") as any)[1];
37
+
38
+ const viewProps: any = opsNamed("setProps").find((op: any) => op[1] === viewId);
39
+ expect(viewProps[2].style.padding).toBe(16);
40
+
41
+ expect(opsNamed("setText")).toContainEqual(["setText", rawId, "hi"]);
42
+ expect(opsNamed("appendChild")).toContainEqual(["appendChild", textId, rawId]);
43
+ expect(opsNamed("appendChild")).toContainEqual(["appendChild", viewId, textId]);
44
+ expect(opsNamed("appendChild")).toContainEqual(["appendChild", 0, viewId]);
45
+
46
+ // root attach comes after the subtree is assembled
47
+ const opNames = ops.map((op: any) => op.join(":"));
48
+ expect(opNames.indexOf(`appendChild:0:${viewId}`)).toBeGreaterThan(
49
+ opNames.indexOf(`appendChild:${viewId}:${textId}`),
50
+ );
51
+ });
52
+
53
+ test("click handlers register as listeners and receive dispatches", () => {
54
+ let clicks = 0;
55
+
56
+ render(<View onClick={() => clicks++} className="w-4" />);
57
+
58
+ const create: any = opsNamed("create")[0];
59
+ const props: any = opsNamed("setProps").find((op: any) => op[1] === create[1]);
60
+ expect(props[2].listeners).toEqual(["click"]);
61
+
62
+ dispatch({ kind: "event", nodeId: create[1], type: "click" });
63
+ expect(clicks).toBe(1);
64
+ });
65
+
66
+ test("state updates emit new setProps and setText", () => {
67
+ function Counter() {
68
+ const [count, setCount] = useState(0);
69
+ return (
70
+ <View onClick={() => setCount((c) => c + 1)}>
71
+ <Text>{`count:${count}`}</Text>
72
+ </View>
73
+ );
74
+ }
75
+
76
+ render(<Counter />);
77
+
78
+ const viewId = (opsNamed("create").find((op: any) => op[2] === "view") as any)[1];
79
+ batches.length = 0;
80
+
81
+ dispatch({ kind: "event", nodeId: viewId, type: "click" });
82
+
83
+ expect(opsNamed("setText").some((op: any) => op[2] === "count:1")).toBe(true);
84
+ });
85
+
86
+ test("TextInput onChange receives the plain string value", () => {
87
+ const seen: string[] = [];
88
+
89
+ render(<TextInput onChange={(v) => seen.push(v)} />);
90
+
91
+ const inputId = (opsNamed("create").find((op: any) => op[2] === "textinput") as any)[1];
92
+ dispatch({ kind: "event", nodeId: inputId, type: "change", payload: { value: "abc" } });
93
+
94
+ expect(seen).toEqual(["abc"]);
95
+ });
96
+
97
+ test("conditional removal emits removeChild", () => {
98
+ function Toggle({ on }: { on: boolean }) {
99
+ return <View>{on ? <View className="w-2" /> : null}</View>;
100
+ }
101
+
102
+ render(<Toggle on={true} />);
103
+ // React creates instances bottom-up: the inner view is created first.
104
+ const childId = (opsNamed("create")[0] as any)[1];
105
+ batches.length = 0;
106
+
107
+ render(<Toggle on={false} />);
108
+ expect(opsNamed("removeChild").some((op: any) => op[2] === childId)).toBe(true);
109
+ });
110
+
111
+ test("unmount clears the container", () => {
112
+ render(<View />);
113
+ batches.length = 0;
114
+ unmount();
115
+ const ops = allOps();
116
+ expect(ops.some((op: any) => op[0] === "removeChild" || op[0] === "clearContainer")).toBe(
117
+ true,
118
+ );
119
+ });
120
+ });