@vsreact/core 0.0.10 → 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/controls.tsx CHANGED
@@ -152,6 +152,8 @@ export interface SliderProps {
152
152
  height?: number;
153
153
  /** Vertical fader — drag up for more, fill rises from the bottom. */
154
154
  vertical?: boolean;
155
+ /** Flat hardware-style bar thumb instead of the dot. */
156
+ barThumb?: boolean;
155
157
  label?: string;
156
158
  disabled?: boolean;
157
159
  /** Double-click resets to this (DAW convention). */
@@ -170,6 +172,7 @@ export function Slider({
170
172
  width = 160,
171
173
  height = 160,
172
174
  vertical,
175
+ barThumb,
173
176
  label,
174
177
  disabled,
175
178
  defaultValue,
@@ -223,10 +226,17 @@ export function Slider({
223
226
  className="absolute w-[4] rounded-full left-[7] bottom-0"
224
227
  style={{ height: clamped * height, backgroundColor: valueColor }}
225
228
  />
226
- <View
227
- className="absolute w-[12] h-[12] rounded-full left-[3]"
228
- style={{ top: (1 - clamped) * (height - 12), backgroundColor: valueColor }}
229
- />
229
+ {barThumb ? (
230
+ <View
231
+ className="absolute w-[18] h-[5] rounded-[2] left-0"
232
+ style={{ top: (1 - clamped) * (height - 5), backgroundColor: valueColor }}
233
+ />
234
+ ) : (
235
+ <View
236
+ className="absolute w-[12] h-[12] rounded-full left-[3]"
237
+ style={{ top: (1 - clamped) * (height - 12), backgroundColor: valueColor }}
238
+ />
239
+ )}
230
240
  </View>
231
241
  {label !== undefined ? (
232
242
  <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
@@ -251,10 +261,17 @@ export function Slider({
251
261
  className="absolute h-[4] rounded-full top-[7]"
252
262
  style={{ width: clamped * width, backgroundColor: valueColor }}
253
263
  />
254
- <View
255
- className="absolute w-[12] h-[12] rounded-full top-[3]"
256
- style={{ left: clamped * (width - 12), backgroundColor: valueColor }}
257
- />
264
+ {barThumb ? (
265
+ <View
266
+ className="absolute w-[5] h-[18] rounded-[2] top-0"
267
+ style={{ left: clamped * (width - 5), backgroundColor: valueColor }}
268
+ />
269
+ ) : (
270
+ <View
271
+ className="absolute w-[12] h-[12] rounded-full top-[3]"
272
+ style={{ left: clamped * (width - 12), backgroundColor: valueColor }}
273
+ />
274
+ )}
258
275
  </View>
259
276
  {label !== undefined ? (
260
277
  <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
@@ -295,6 +312,9 @@ export function ParamSlider({ paramId, label, ...rest }: ParamSliderProps) {
295
312
  export interface ToggleProps {
296
313
  on: boolean;
297
314
  label?: string;
315
+ /** Side captions, hardware style: <Toggle offLabel="OFF" onLabel="ON" />. */
316
+ offLabel?: string;
317
+ onLabel?: string;
298
318
  /** Track height; width is 1.8×. Default 22. */
299
319
  size?: number;
300
320
  disabled?: boolean;
@@ -308,6 +328,8 @@ export interface ToggleProps {
308
328
  export function Toggle({
309
329
  on,
310
330
  label,
331
+ offLabel,
332
+ onLabel,
311
333
  size = 22,
312
334
  disabled,
313
335
  trackColor = "#2A2F27",
@@ -322,21 +344,39 @@ export function Toggle({
322
344
 
323
345
  return (
324
346
  <View className="items-center gap-2">
325
- <View
326
- className={`relative rounded-full ${disabled ? "opacity-40" : "cursor-pointer"}`}
327
- style={{ width: trackWidth, height: size, backgroundColor: on ? onColor : trackColor }}
328
- onClick={disabled ? undefined : () => onChange(!on)}
329
- >
347
+ <View className="flex-row items-center gap-2">
348
+ {offLabel !== undefined ? (
349
+ <Text
350
+ className="text-[9] font-bold tracking-widest"
351
+ style={{ color: on ? "#6f6e66" : "#ECF2E8" }}
352
+ >
353
+ {offLabel}
354
+ </Text>
355
+ ) : null}
330
356
  <View
331
- className="absolute rounded-full"
332
- style={{
333
- width: thumb,
334
- height: thumb,
335
- top: 3,
336
- left: 3 + clamp01(t) * travel,
337
- backgroundColor: thumbColor,
338
- }}
339
- />
357
+ className={`relative rounded-full ${disabled ? "opacity-40" : "cursor-pointer"}`}
358
+ style={{ width: trackWidth, height: size, backgroundColor: on ? onColor : trackColor }}
359
+ onClick={disabled ? undefined : () => onChange(!on)}
360
+ >
361
+ <View
362
+ className="absolute rounded-full"
363
+ style={{
364
+ width: thumb,
365
+ height: thumb,
366
+ top: 3,
367
+ left: 3 + clamp01(t) * travel,
368
+ backgroundColor: thumbColor,
369
+ }}
370
+ />
371
+ </View>
372
+ {onLabel !== undefined ? (
373
+ <Text
374
+ className="text-[9] font-bold tracking-widest"
375
+ style={{ color: on ? "#ECF2E8" : "#6f6e66" }}
376
+ >
377
+ {onLabel}
378
+ </Text>
379
+ ) : null}
340
380
  </View>
341
381
  {label !== undefined ? (
342
382
  <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
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
+ });