diff --git a/packages/studio/client/src/app.test.tsx b/packages/studio/client/src/app.test.tsx index aac1b8d..002067e 100644 --- a/packages/studio/client/src/app.test.tsx +++ b/packages/studio/client/src/app.test.tsx @@ -6,19 +6,28 @@ import type { StudioDocumentSnapshot } from "../../src/edit-types.js"; import { App } from "./main.js"; class MockWebSocket extends EventTarget { + static CONNECTING = 0; static OPEN = 1; + static CLOSED = 3; static instances: MockWebSocket[] = []; - readyState = MockWebSocket.OPEN; + readyState = MockWebSocket.CONNECTING; sent: unknown[] = []; + throwOnSend = false; constructor(_url: URL) { super(); MockWebSocket.instances.push(this); } send(value: string) { + if (this.throwOnSend) throw new Error("send failed"); this.sent.push(JSON.parse(value)); } + open() { + this.readyState = MockWebSocket.OPEN; + this.dispatchEvent(new Event("open")); + } close() { - this.readyState = 3; + if (this.readyState === MockWebSocket.CLOSED) return; + this.readyState = MockWebSocket.CLOSED; this.dispatchEvent(new Event("close")); } message(value: unknown) { @@ -57,7 +66,14 @@ const state = { }, effects: [], }, - keyboard: { voice: { frequencyHz: 261.625, gate: true }, activeNoteLabel: "C3" }, + keyboard: { + baseNote: 60, + octaveLabel: "C3", + activeCode: "KeyA", + activeNoteLabel: "C3", + heldCodes: ["KeyA"], + voice: { frequencyHz: 261.625, gate: true }, + }, summary: "261.63 Hz; 1 oscillator; 0 effects", status: "Audio running", document: documentSnapshot, @@ -74,17 +90,41 @@ beforeEach(() => { }); afterEach(() => { cleanup(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); function mountStudio(initialState: unknown = state) { const view = render(); - const socket = MockWebSocket.instances[0]; - socket.dispatchEvent(new Event("open")); + const socket = MockWebSocket.instances.at(-1)!; + socket.open(); socket.message({ type: "state", state: initialState }); return { ...view, socket }; } +function performanceEvent( + target: EventTarget, + type: "keydown" | "keyup", + code: string, + init: KeyboardEventInit = {}, +): KeyboardEvent { + const event = new KeyboardEvent(type, { + bubbles: true, + cancelable: true, + code, + key: code.startsWith("Key") ? code.slice(3).toLowerCase() : code, + ...init, + }); + target.dispatchEvent(event); + return event; +} + +function performanceMessages(socket: MockWebSocket): unknown[] { + return socket.sent.filter((message: any) => + ["keyDown", "keyUp", "releaseAll"].includes(message.type), + ); +} + test("editable frequency uses authored source instead of performed keyboard pitch", async () => { mountStudio(); const input = await screen.findByRole("spinbutton", { name: /Frequency/ }); @@ -166,7 +206,284 @@ test("window blur cancels an active gesture and releases performance keys", asyn const { socket } = mountStudio(); const slider = await screen.findByRole("slider", { name: /Frequency/ }); fireEvent.change(slider, { target: { value: "220" } }); + performanceEvent(slider, "keydown", "KeyA"); fireEvent.blur(window); expect(socket.sent.filter((message: any) => message.type === "cancelPreview")).toHaveLength(1); - expect(socket.sent.filter((message: any) => message.type === "releaseAll")).toHaveLength(1); + expect(performanceMessages(socket)).toEqual([ + { type: "keyDown", code: "KeyA" }, + { type: "releaseAll" }, + ]); + const released = performanceEvent(slider, "keyup", "KeyA"); + expect(released.defaultPrevented).toBe(false); +}); + +test("mapped keys dominate focused number, range, select, button, and document targets", async () => { + const { socket } = mountStudio(); + const number = await screen.findByRole("spinbutton", { name: /Frequency/ }); + const range = screen.getByRole("slider", { name: /Frequency/ }); + const button = screen.getByRole("button", { name: "+ Filter" }); + const targets: EventTarget[] = [number, range, button, document.body]; + const codes = ["KeyA", "KeyW", "KeyS", "KeyD", "KeyZ"]; + + for (const [index, target] of targets.entries()) { + const down = performanceEvent(target, "keydown", codes[index]); + const up = performanceEvent(target, "keyup", codes[index]); + expect(down.defaultPrevented, `${codes[index]} keydown on ${(target as Node).nodeName}`).toBe( + true, + ); + expect(up.defaultPrevented, `${codes[index]} keyup on ${(target as Node).nodeName}`).toBe(true); + } + fireEvent.click(screen.getByRole("button", { name: "Edit Oscillator 1" })); + const select = screen.getByRole("combobox", { name: /Waveform/ }); + const selectDown = performanceEvent(select, "keydown", "KeyZ"); + const selectUp = performanceEvent(select, "keyup", "KeyZ"); + expect(selectDown.defaultPrevented).toBe(true); + expect(selectUp.defaultPrevented).toBe(true); + + expect(performanceMessages(socket)).toEqual( + codes.flatMap((code) => [ + { type: "keyDown", code }, + { type: "keyUp", code }, + ]), + ); +}); + +test("repeats stay captured without duplicate note-on and keyup follows ownership across focus", async () => { + const { socket } = mountStudio(); + const number = await screen.findByRole("spinbutton", { name: /Frequency/ }); + const button = screen.getByRole("button", { name: "+ Filter" }); + const down = performanceEvent(number, "keydown", "KeyA"); + const repeat = performanceEvent(number, "keydown", "KeyA", { + ctrlKey: true, + repeat: true, + }); + const up = performanceEvent(button, "keyup", "KeyA", { ctrlKey: true }); + expect([down.defaultPrevented, repeat.defaultPrevented, up.defaultPrevented]).toEqual([ + true, + true, + true, + ]); + expect(performanceMessages(socket)).toEqual([ + { type: "keyDown", code: "KeyA" }, + { type: "keyUp", code: "KeyA" }, + ]); +}); + +test("a first-seen repeat remains native and never sends a release", async () => { + const { socket } = mountStudio(); + const number = await screen.findByRole("spinbutton", { name: /Frequency/ }); + const repeat = performanceEvent(number, "keydown", "KeyA", { repeat: true }); + const up = performanceEvent(number, "keyup", "KeyA"); + expect(repeat.defaultPrevented).toBe(false); + expect(up.defaultPrevented).toBe(false); + expect(performanceMessages(socket)).toEqual([]); +}); + +test("a failed initial keydown rolls back ownership and stays native", async () => { + const { socket } = mountStudio(); + const number = await screen.findByRole("spinbutton", { name: /Frequency/ }); + socket.throwOnSend = true; + const down = performanceEvent(number, "keydown", "KeyA"); + const up = performanceEvent(number, "keyup", "KeyA"); + expect(down.defaultPrevented).toBe(false); + expect(up.defaultPrevented).toBe(false); + expect(socket.readyState).toBe(MockWebSocket.CLOSED); + expect(performanceMessages(socket)).toEqual([]); +}); + +test("Ctrl, Meta, and Alt remain native while Shift still captures", async () => { + const { socket } = mountStudio(); + const number = await screen.findByRole("spinbutton", { name: /Frequency/ }); + for (const modifier of [{ ctrlKey: true }, { metaKey: true }, { altKey: true }]) { + const down = performanceEvent(number, "keydown", "KeyA", modifier); + const up = performanceEvent(number, "keyup", "KeyA"); + expect(down.defaultPrevented).toBe(false); + expect(up.defaultPrevented).toBe(false); + } + const shiftedDown = performanceEvent(number, "keydown", "KeyA", { shiftKey: true }); + const shiftedUp = performanceEvent(number, "keyup", "KeyA", { shiftKey: true }); + expect(shiftedDown.defaultPrevented).toBe(true); + expect(shiftedUp.defaultPrevented).toBe(true); + expect(performanceMessages(socket)).toEqual([ + { type: "keyDown", code: "KeyA" }, + { type: "keyUp", code: "KeyA" }, + ]); +}); + +test("non-performance editing keys and ordinary control blur stay native", async () => { + const { socket } = mountStudio(); + const number = await screen.findByRole("spinbutton", { name: /Frequency/ }); + for (const code of ["Digit1", "ArrowUp", "Enter", "Escape", "Tab"]) { + const down = performanceEvent(number, "keydown", code); + const up = performanceEvent(number, "keyup", code); + expect(down.defaultPrevented).toBe(false); + expect(up.defaultPrevented).toBe(false); + } + fireEvent.blur(number); + expect(performanceMessages(socket)).toEqual([]); +}); + +test("visibility and pagehide release owned performance keys", async () => { + const { socket } = mountStudio(); + const number = await screen.findByRole("spinbutton", { name: /Frequency/ }); + performanceEvent(number, "keydown", "KeyA"); + vi.spyOn(document, "hidden", "get").mockReturnValue(true); + document.dispatchEvent(new Event("visibilitychange")); + performanceEvent(number, "keydown", "KeyS"); + window.dispatchEvent(new Event("pagehide")); + expect(performanceMessages(socket)).toEqual([ + { type: "keyDown", code: "KeyA" }, + { type: "releaseAll" }, + { type: "keyDown", code: "KeyS" }, + { type: "releaseAll" }, + ]); +}); + +test("socket close clears ownership and offline keys retain native behavior", async () => { + const { socket } = mountStudio(); + const number = await screen.findByRole("spinbutton", { name: /Frequency/ }); + performanceEvent(number, "keydown", "KeyA"); + socket.close(); + const up = performanceEvent(number, "keyup", "KeyA"); + const offlineDown = performanceEvent(number, "keydown", "KeyS"); + expect(up.defaultPrevented).toBe(false); + expect(offlineDown.defaultPrevented).toBe(false); + expect(performanceMessages(socket)).toEqual([{ type: "keyDown", code: "KeyA" }]); + expect(await screen.findByText("Offline", { exact: true })).toBeTruthy(); + await waitFor(() => expect(number.hasAttribute("disabled")).toBe(true)); + expect(screen.queryByLabelText("A note key, held")).toBeNull(); + expect(screen.getByLabelText("A note key").hasAttribute("data-held")).toBe(false); + expect(screen.queryByText("C3", { exact: true })).toBeNull(); + expect(screen.getByText(/Octave — · — Hz · gate closed/)).toBeTruthy(); +}); + +test("unmount best-effort releases before closing an open socket", () => { + const { socket, unmount } = mountStudio(); + performanceEvent(document.body, "keydown", "KeyA"); + unmount(); + expect(performanceMessages(socket)).toEqual([ + { type: "keyDown", code: "KeyA" }, + { type: "releaseAll" }, + ]); +}); + +test("unmount still closes the socket when release transport throws", () => { + const { socket, unmount } = mountStudio(); + socket.throwOnSend = true; + expect(() => unmount()).not.toThrow(); + expect(socket.readyState).toBe(MockWebSocket.CLOSED); + expect(performanceMessages(socket)).toEqual([]); +}); + +test("performance guidance exposes octave, modifiers, and held-key state", async () => { + mountStudio(); + expect(await screen.findByText(/octave down Z/i)).toBeTruthy(); + expect(screen.getByText(/Cmd\/Ctrl\/Alt shortcuts stay native/i)).toBeTruthy(); + expect(screen.getByLabelText("A note key, held").getAttribute("data-held")).toBe("true"); + expect(screen.getByText(/Octave C3/)).toBeTruthy(); +}); + +test("connecting, loading, and document phases use explanatory presentation", async () => { + render(); + const socket = MockWebSocket.instances[0]; + expect(screen.getAllByText("Connecting", { exact: true }).length).toBeGreaterThan(0); + socket.open(); + expect((await screen.findAllByText("Loading patch", { exact: true })).length).toBeGreaterThan(0); + socket.message({ type: "state", state }); + + const phases: Array<[StudioDocumentSnapshot["phase"], string]> = [ + ["ready", "Ready"], + ["writing", "Saving"], + ["previewing", "Previewing"], + ["source-written", "Source saved"], + ["reloading", "Reloading"], + ["audio-accepted", "Audio updated"], + ["conflict", "Source conflict"], + ["error", "Update failed"], + ]; + for (const [phase, label] of phases) { + socket.message({ + type: "document", + protocol: 1, + document: { ...documentSnapshot, phase }, + }); + expect(await screen.findByText(label, { exact: true })).toBeTruthy(); + } + expect(screen.queryByText("audio-accepted", { exact: true })).toBeNull(); +}); + +test("a rejected edit remains visible while the document is ready", async () => { + const { socket } = mountStudio(); + socket.message({ + type: "editResult", + protocol: 1, + requestId: "rejected-edit", + status: "rejected", + revision, + message: "That value cannot be represented in source.", + document: { ...documentSnapshot, phase: "ready" }, + }); + expect(await screen.findByText("Edit rejected", { exact: true })).toBeTruthy(); + expect(screen.getByText(/cannot be represented in source/i)).toBeTruthy(); + + socket.message({ + type: "editResult", + protocol: 1, + requestId: "successful-preview", + status: "previewed", + revision, + document: { ...documentSnapshot, phase: "ready" }, + }); + await waitFor(() => expect(screen.queryByText("Edit rejected", { exact: true })).toBeNull()); + expect(screen.getByText("Ready", { exact: true })).toBeTruthy(); +}); + +test("computed controls reference their explanation", async () => { + const computedState: any = structuredClone(state); + computedState.document.bindings[0] = { + ...computedState.document.bindings[0], + sourceForm: { + kind: "computed", + reason: "expression", + message: "Frequency comes from an expression.", + }, + }; + mountStudio(computedState); + const number = await screen.findByRole("spinbutton", { name: /Frequency/ }); + const slider = screen.getByRole("slider", { name: /Frequency/ }); + expect(number.hasAttribute("disabled")).toBe(true); + const helpId = number.getAttribute("aria-describedby"); + expect(helpId).toBeTruthy(); + expect(slider.getAttribute("aria-describedby")).toBe(helpId); + expect(document.getElementById(helpId!)?.textContent).toContain( + "Frequency comes from an expression.", + ); +}); + +test("read-only and conflict documents disable literal and structural edits", async () => { + const readOnlyState: any = structuredClone(state); + readOnlyState.document.writable = false; + readOnlyState.document.diagnostic = "This source shape cannot be safely rewritten."; + const readOnly = mountStudio(readOnlyState); + const readOnlyNumber = await screen.findByRole("spinbutton", { name: /Frequency/ }); + const readOnlyFilter = screen.getByRole("button", { name: "+ Filter" }); + expect(readOnlyNumber.hasAttribute("disabled")).toBe(true); + expect(readOnlyFilter.hasAttribute("disabled")).toBe(true); + fireEvent.click(readOnlyFilter); + expect(readOnly.socket.sent.filter((message: any) => message.type === "commit")).toHaveLength(0); + expect(await screen.findByText("Read-only source.", { exact: true })).toBeTruthy(); + expect(screen.getByText(/cannot be safely rewritten/i)).toBeTruthy(); + readOnly.unmount(); + + const conflictState: any = structuredClone(state); + conflictState.document.phase = "conflict"; + conflictState.document.diagnostic = "The patch changed outside Studio."; + const conflict = mountStudio(conflictState); + const conflictNumber = await screen.findByRole("spinbutton", { name: /Frequency/ }); + const conflictFilter = screen.getByRole("button", { name: "+ Filter" }); + expect(conflictNumber.hasAttribute("disabled")).toBe(true); + expect(conflictFilter.hasAttribute("disabled")).toBe(true); + fireEvent.click(conflictFilter); + expect(conflict.socket.sent.filter((message: any) => message.type === "commit")).toHaveLength(0); + expect(await screen.findByText("Source conflict", { exact: true })).toBeTruthy(); }); diff --git a/packages/studio/client/src/keyboard.test.ts b/packages/studio/client/src/keyboard.test.ts index 052068e..26dd7a0 100644 --- a/packages/studio/client/src/keyboard.test.ts +++ b/packages/studio/client/src/keyboard.test.ts @@ -1,29 +1,70 @@ // @vitest-environment jsdom import { describe, expect, it } from "vitest"; -import { isEditingTarget, shouldHandlePerformanceKey } from "./keyboard.js"; +import { PerformanceKeyCapture, type PerformanceKeyEvent } from "./keyboard.js"; -describe("performance keyboard focus safety", () => { - it("ignores form controls and modifier shortcuts", () => { - const mapped = new Set(["KeyA"]); - const input = document.createElement("input"); - expect(isEditingTarget(input)).toBe(true); - expect( - shouldHandlePerformanceKey( - { code: "KeyA", ctrlKey: false, metaKey: false, altKey: false, target: input }, - mapped, - ), - ).toBe(false); - expect( - shouldHandlePerformanceKey( - { code: "KeyA", ctrlKey: true, metaKey: false, altKey: false, target: document.body }, - mapped, - ), - ).toBe(false); - expect( - shouldHandlePerformanceKey( - { code: "KeyA", ctrlKey: false, metaKey: false, altKey: false, target: document.body }, - mapped, - ), - ).toBe(true); +const mapped = new Set(["KeyA", "KeyZ"]); +const key = (code = "KeyA", overrides: Partial = {}): PerformanceKeyEvent => ({ + code, + ctrlKey: false, + metaKey: false, + altKey: false, + repeat: false, + shiftKey: false, + ...overrides, +}); + +describe("performance key ownership", () => { + it("owns an initial connected mapped key and suppresses later modified repeats", () => { + const capture = new PerformanceKeyCapture(); + expect(capture.keyDown(key(), mapped, true)).toEqual({ captured: true, send: true }); + expect(capture.owns("KeyA")).toBe(true); + expect(capture.keyDown(key("KeyA", { ctrlKey: true, repeat: true }), mapped, false)).toEqual({ + captured: true, + send: false, + }); + expect(capture.keyUp("KeyA")).toBe(true); + expect(capture.keyUp("KeyA")).toBe(false); + }); + + it("does not acquire ownership from a first-seen repeat", () => { + const capture = new PerformanceKeyCapture(); + expect(capture.keyDown(key("KeyA", { repeat: true }), mapped, true)).toEqual({ + captured: false, + send: false, + }); + expect(capture.owns("KeyA")).toBe(false); + expect(capture.keyUp("KeyA")).toBe(false); + }); + + it("leaves offline, unmapped, and command-modified keys unowned", () => { + const cases: Array<[PerformanceKeyEvent, boolean]> = [ + [key("KeyA"), false], + [key("KeyQ"), true], + [key("KeyA", { ctrlKey: true }), true], + [key("KeyA", { metaKey: true }), true], + [key("KeyA", { altKey: true }), true], + ]; + for (const [event, connected] of cases) { + const capture = new PerformanceKeyCapture(); + expect(capture.keyDown(event, mapped, connected)).toEqual({ captured: false, send: false }); + } + }); + + it("allows Shift and releases ownership independently of keyup modifiers", () => { + const capture = new PerformanceKeyCapture(); + expect(capture.keyDown(key("KeyZ", { shiftKey: true }), mapped, true)).toEqual({ + captured: true, + send: true, + }); + expect(capture.keyUp("KeyZ")).toBe(true); + }); + + it("clears all locally owned keys", () => { + const capture = new PerformanceKeyCapture(); + capture.keyDown(key("KeyA"), mapped, true); + capture.keyDown(key("KeyZ"), mapped, true); + capture.clear(); + expect(capture.owns("KeyA")).toBe(false); + expect(capture.owns("KeyZ")).toBe(false); }); }); diff --git a/packages/studio/client/src/keyboard.ts b/packages/studio/client/src/keyboard.ts index 8154a16..612c23b 100644 --- a/packages/studio/client/src/keyboard.ts +++ b/packages/studio/client/src/keyboard.ts @@ -1,21 +1,45 @@ -export function isEditingTarget(target: EventTarget | null): boolean { - return ( - target instanceof HTMLInputElement || - target instanceof HTMLSelectElement || - target instanceof HTMLButtonElement || - target instanceof HTMLTextAreaElement - ); -} +export type PerformanceKeyEvent = Pick< + KeyboardEvent, + "altKey" | "code" | "ctrlKey" | "metaKey" | "repeat" | "shiftKey" +>; + +export type PerformanceKeyDownResult = Readonly<{ + captured: boolean; + send: boolean; +}>; + +export class PerformanceKeyCapture { + #owned = new Set(); + + keyDown( + event: PerformanceKeyEvent, + mapped: ReadonlySet, + connected: boolean, + ): PerformanceKeyDownResult { + if (this.#owned.has(event.code)) return { captured: true, send: false }; + if ( + event.repeat || + !connected || + !mapped.has(event.code) || + event.ctrlKey || + event.metaKey || + event.altKey + ) { + return { captured: false, send: false }; + } + this.#owned.add(event.code); + return { captured: true, send: true }; + } + + keyUp(code: string): boolean { + return this.#owned.delete(code); + } + + clear(): void { + this.#owned.clear(); + } -export function shouldHandlePerformanceKey( - event: Pick, - mapped: ReadonlySet, -): boolean { - return ( - mapped.has(event.code) && - !event.ctrlKey && - !event.metaKey && - !event.altKey && - !isEditingTarget(event.target) - ); + owns(code: string): boolean { + return this.#owned.has(code); + } } diff --git a/packages/studio/client/src/main.tsx b/packages/studio/client/src/main.tsx index 9097cd6..8b4f2a4 100644 --- a/packages/studio/client/src/main.tsx +++ b/packages/studio/client/src/main.tsx @@ -12,7 +12,7 @@ import type { StudioEditResult, StudioServerMessage, } from "../../src/index.js"; -import { shouldHandlePerformanceKey } from "./keyboard.js"; +import { PerformanceKeyCapture } from "./keyboard.js"; import { cx, eyebrowClass, @@ -28,16 +28,75 @@ type Selection = { kind: "source" | "oscillator" | "filter" | "lfo" | "envelope" | "effect" | "safety"; index?: number; }; +type ConnectionState = "connecting" | "online" | "offline"; +type StudioTone = "neutral" | "info" | "warning" | "success" | "danger"; +type StudioPresentation = Readonly<{ + label: string; + detail: string; + tone: StudioTone; + busy: boolean; +}>; -const phaseToneClasses: Record = { - ready: "text-studio-text-phase", - writing: "text-studio-text-phase", - previewing: "text-studio-warning", - "source-written": "text-studio-info", - reloading: "text-studio-info", - "audio-accepted": "text-studio-success", - conflict: "text-studio-danger", - error: "text-studio-danger", +const toneClasses: Record = { + neutral: "text-studio-text-phase", + info: "text-studio-info", + warning: "text-studio-warning", + success: "text-studio-success", + danger: "text-studio-danger", +}; +const connectionToneClasses: Record = { + connecting: toneClasses.info, + online: toneClasses.success, + offline: toneClasses.danger, +}; + +const phasePresentation: Record< + StudioDocumentPhase, + Omit & { detail: string } +> = { + ready: { label: "Ready", detail: "Audio and source are in sync.", tone: "neutral", busy: false }, + writing: { + label: "Saving", + detail: "Writing this edit back to source.", + tone: "info", + busy: true, + }, + previewing: { + label: "Previewing", + detail: "Listening to a temporary parameter change.", + tone: "warning", + busy: false, + }, + "source-written": { + label: "Source saved", + detail: "Source updated; waiting for audio reload.", + tone: "info", + busy: true, + }, + reloading: { + label: "Reloading", + detail: "Loading the updated patch into the audio engine.", + tone: "info", + busy: true, + }, + "audio-accepted": { + label: "Audio updated", + detail: "The audio engine accepted the latest source.", + tone: "success", + busy: false, + }, + conflict: { + label: "Source conflict", + detail: "The file changed outside Studio. Reload before editing again.", + tone: "danger", + busy: false, + }, + error: { + label: "Update failed", + detail: "The last update failed; the previous audio remains active.", + tone: "danger", + busy: false, + }, }; const sourceBadgeToneClasses: Record = { @@ -52,25 +111,89 @@ const serialConnectorClass = const parameterClass = "block border-b border-studio-border-parameter py-3.75"; const parameterLabelClass = "mb-2.25 flex items-center justify-between text-studio-copy font-bold"; const nativeSelectClass = - "w-full appearance-auto [background-color:revert] [border-radius:revert] [border:revert] [color:revert] [padding:revert]"; + "w-full appearance-auto rounded-studio-input border border-studio-border-input bg-studio-control-deep p-1.5 text-studio-text-strong focus-visible:-outline-offset-1 focus-visible:outline-2 focus-visible:outline-studio-accent-strong disabled:cursor-not-allowed disabled:opacity-50"; const noteClass = "mt-3.5 rounded-studio-note border border-studio-note-border bg-studio-note-bg p-2.5 text-studio-copy leading-[1.5] text-studio-note"; const keyboardKeys = [ - { label: "A", raised: false }, - { label: "W", raised: true }, - { label: "S", raised: false }, - { label: "E", raised: true }, - { label: "D", raised: false }, - { label: "F", raised: false }, - { label: "T", raised: true }, - { label: "G", raised: false }, - { label: "Y", raised: true }, - { label: "H", raised: false }, - { label: "U", raised: true }, - { label: "J", raised: false }, - { label: "K", raised: false }, + { code: "KeyA", label: "A", raised: false }, + { code: "KeyW", label: "W", raised: true }, + { code: "KeyS", label: "S", raised: false }, + { code: "KeyE", label: "E", raised: true }, + { code: "KeyD", label: "D", raised: false }, + { code: "KeyF", label: "F", raised: false }, + { code: "KeyT", label: "T", raised: true }, + { code: "KeyG", label: "G", raised: false }, + { code: "KeyY", label: "Y", raised: true }, + { code: "KeyH", label: "H", raised: false }, + { code: "KeyU", label: "U", raised: true }, + { code: "KeyJ", label: "J", raised: false }, + { code: "KeyK", label: "K", raised: false }, ] as const; +const performanceCodes = new Set([...keyboardKeys.map(({ code }) => code), "KeyZ", "KeyX"]); + +function studioPresentation( + connection: ConnectionState, + runtime: RuntimeState, + document: StudioDocumentSnapshot | undefined, + pendingRequest: string | undefined, + notice: string, + rejectedMessage: string | undefined, +): StudioPresentation { + if (connection === "connecting") { + return { + label: "Connecting", + detail: "Opening the local Studio audio connection.", + tone: "info", + busy: true, + }; + } + if (connection === "offline") { + return { + label: "Offline", + detail: "Playback and editing are unavailable. Restart Patchwave to reconnect.", + tone: "danger", + busy: false, + }; + } + if (!runtime || !document) { + return { + label: "Loading patch", + detail: "Connected; waiting for the first patch state.", + tone: "info", + busy: true, + }; + } + if (runtime.error) { + return { + label: "Audio error", + detail: String(runtime.error), + tone: "danger", + busy: false, + }; + } + const base = phasePresentation[document.phase]; + if (document.phase === "conflict" || document.phase === "error") { + return { ...base, detail: document.diagnostic || notice || base.detail }; + } + if (rejectedMessage) { + return { + label: "Edit rejected", + detail: rejectedMessage, + tone: "danger", + busy: false, + }; + } + if (pendingRequest && !base.busy) { + return { + label: "Applying edit", + detail: "Waiting for Studio to confirm this source change.", + tone: "info", + busy: true, + }; + } + return base; +} function requestId(): string { return crypto.randomUUID(); @@ -80,23 +203,42 @@ function pathEqual(a: PatchFieldPath, b: PatchFieldPath): boolean { } export function App() { - const [connected, setConnected] = useState(false); + const [connection, setConnection] = useState("connecting"); const [runtime, setRuntime] = useState(); const [document, setDocument] = useState(); const [selection, setSelection] = useState({ kind: "source" }); const [notice, setNotice] = useState("Connecting…"); + const [rejectedMessage, setRejectedMessage] = useState(undefined); const [pendingRequest, setPendingRequest] = useState(undefined); const pendingRequestRef = useRef(undefined); const socket = useRef(undefined); + const performanceCapture = useRef(new PerformanceKeyCapture()); const send = useCallback((message: unknown): boolean => { - if (socket.current?.readyState !== WebSocket.OPEN) return false; - socket.current.send(JSON.stringify(message)); - return true; + const current = socket.current; + if (current?.readyState !== WebSocket.OPEN) return false; + try { + current.send(JSON.stringify(message)); + return true; + } catch { + try { + current.close(); + } catch { + // The transport is already unusable; server disconnect cleanup remains authoritative. + } + return false; + } }, []); const edit = useCallback( (operation: PatchEditOperation, gestureId: string | null = null): boolean => { - if (!document || pendingRequestRef.current) return false; + if ( + !document || + !document.writable || + document.phase === "conflict" || + pendingRequestRef.current + ) + return false; + setRejectedMessage(undefined); const id = requestId(); pendingRequestRef.current = id; setPendingRequest(id); @@ -124,13 +266,16 @@ export function App() { const ws = new WebSocket(url); socket.current = ws; ws.addEventListener("open", () => { - setConnected(true); - setNotice("Audio running"); + performanceCapture.current.clear(); + setRejectedMessage(undefined); + setConnection("online"); + setNotice("Audio running."); }); ws.addEventListener("close", () => { + performanceCapture.current.clear(); pendingRequestRef.current = undefined; setPendingRequest(undefined); - setConnected(false); + setConnection("offline"); setNotice("Studio disconnected. Restart Patchwave to reconnect."); }); ws.addEventListener("message", (event) => { @@ -151,6 +296,11 @@ export function App() { setPendingRequest(undefined); } if (result.document) setDocument(result.document); + setRejectedMessage( + result.status === "rejected" + ? (result.message ?? "Studio could not apply this edit.") + : undefined, + ); setNotice( result.message ?? (result.status === "committed" ? "Saved to code; reloading audio…" : result.status), @@ -160,43 +310,47 @@ export function App() { ws.close(); } }); - return () => ws.close(); - }, []); + return () => { + performanceCapture.current.clear(); + window.dispatchEvent(new Event("patchwave-cancel-gestures")); + try { + if (ws.readyState === WebSocket.OPEN) send({ type: "releaseAll" }); + } finally { + try { + ws.close(); + } catch { + // The transport is already unusable. + } + } + }; + }, [send]); useEffect(() => { - const mapped = new Set([ - "KeyA", - "KeyW", - "KeyS", - "KeyE", - "KeyD", - "KeyF", - "KeyT", - "KeyG", - "KeyY", - "KeyH", - "KeyU", - "KeyJ", - "KeyK", - "KeyZ", - "KeyX", - ]); const down = (event: KeyboardEvent) => { - if (!shouldHandlePerformanceKey(event, mapped)) return; + const result = performanceCapture.current.keyDown( + event, + performanceCodes, + socket.current?.readyState === WebSocket.OPEN, + ); + if (!result.captured) return; + if (result.send && !send({ type: "keyDown", code: event.code })) { + performanceCapture.current.keyUp(event.code); + return; + } event.preventDefault(); - if (!event.repeat) send({ type: "keyDown", code: event.code }); }; const up = (event: KeyboardEvent) => { - if (!mapped.has(event.code)) return; + if (!performanceCapture.current.keyUp(event.code)) return; event.preventDefault(); send({ type: "keyUp", code: event.code }); }; const release = () => { + performanceCapture.current.clear(); window.dispatchEvent(new Event("patchwave-cancel-gestures")); send({ type: "releaseAll" }); }; - window.addEventListener("keydown", down); - window.addEventListener("keyup", up); + window.addEventListener("keydown", down, true); + window.addEventListener("keyup", up, true); window.addEventListener("blur", release); const visibility = () => { if (globalThis.document.hidden) release(); @@ -204,11 +358,13 @@ export function App() { globalThis.document.addEventListener("visibilitychange", visibility); window.addEventListener("pagehide", release); return () => { - window.removeEventListener("keydown", down); - window.removeEventListener("keyup", up); + window.removeEventListener("keydown", down, true); + window.removeEventListener("keyup", up, true); window.removeEventListener("blur", release); globalThis.document.removeEventListener("visibilitychange", visibility); window.removeEventListener("pagehide", release); + performanceCapture.current.clear(); + window.dispatchEvent(new Event("patchwave-cancel-gestures")); }; }, [send]); @@ -217,11 +373,22 @@ export function App() { () => document?.bindings.filter((binding) => matchesSelection(binding.path, selection)) ?? [], [document, selection], ); - const phase = document?.phase ?? "ready"; - const busy = - Boolean(pendingRequest) || ["writing", "source-written", "reloading"].includes(phase); + const presentation = studioPresentation( + connection, + runtime, + document, + pendingRequest, + notice, + rejectedMessage, + ); + const busy = presentation.busy; + const controlsDisabled = + busy || connection !== "online" || !document?.writable || document.phase === "conflict"; + const keyboard = connection === "online" ? runtime?.keyboard : undefined; + const heldCodes = new Set(keyboard?.heldCodes ?? []); const history = (type: "undo" | "redo") => { - if (!document || pendingRequestRef.current) return; + if (controlsDisabled || !document || pendingRequestRef.current) return; + setRejectedMessage(undefined); const id = requestId(); pendingRequestRef.current = id; setPendingRequest(id); @@ -246,35 +413,55 @@ export function App() { }, [patch?.source?.oscillators?.length, patch?.source?.filter, patch?.effects?.length]); return ( -
+

PATCHWAVE

Studio

- history("undo")}> + history("undo")} + > Undo - history("redo")}> + history("redo")} + > Redo - {connected ? "Connected" : "Disconnected"} + {connection === "online" + ? "Connected" + : connection === "connecting" + ? "Connecting" + : "Disconnected"}
-
+
-
-
+
- KEYBOARD - - {runtime?.keyboard?.activeNoteLabel ?? "—"} - - - {format(runtime?.keyboard?.voice?.frequencyHz)} Hz · gate{" "} - {runtime?.keyboard?.voice?.gate ? "open" : "closed"} - +

PERFORMANCE

+

+ {keyboard?.activeNoteLabel ?? "—"} +

+

+ Octave {keyboard?.octaveLabel ?? "—"} · {format(keyboard?.voice?.frequencyHz)} Hz · gate{" "} + {keyboard?.voice?.gate ? "open" : "closed"} +

-
- {keyboardKeys.map(({ label, raised }) => ( - - {label} +
+
+
+ {keyboardKeys.map(({ code, label, raised }) => { + const held = heldCodes.has(code); + const active = keyboard?.activeCode === code; + return ( + + {label} + + ); + })} +
+
+

+ Play A–K · octave down Z · octave up X · Cmd/Ctrl/Alt shortcuts stay native. +

+
+
+
+ + Z - ))} + + Octave + + + X + +
+

+ {presentation.label}. {presentation.detail} +

-

- {notice} -

); @@ -809,6 +1052,7 @@ function Parameter({ onCommit: (operation: PatchEditOperation, gesture?: string | null) => boolean; }) { const labelId = useId(); + const computedHelpId = `${labelId}-computed-help`; const controlName = binding.path.map(String).join("."); const [draft, setDraft] = useState(value); const draftRef = useRef(value); @@ -901,9 +1145,10 @@ function Parameter({ <> @@ -975,9 +1222,10 @@ function Parameter({ ) : ( )} {binding.sourceForm.kind === "computed" ? ( - + {binding.sourceForm.message} {binding.location ? ` · ${binding.location.fileLabel}:${binding.location.line}:${binding.location.column}` diff --git a/packages/studio/client/src/ui.tsx b/packages/studio/client/src/ui.tsx index 530119c..a2fbc6b 100644 --- a/packages/studio/client/src/ui.tsx +++ b/packages/studio/client/src/ui.tsx @@ -5,7 +5,7 @@ export function cx(...classes: Array): string } const buttonBaseClass = - "cursor-pointer border border-studio-border-control bg-studio-control text-studio-text-control enabled:hover:border-studio-accent enabled:hover:bg-studio-control-hover disabled:cursor-not-allowed disabled:opacity-35"; + "cursor-pointer border border-studio-border-control bg-studio-control text-studio-text-control focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-studio-accent-strong enabled:hover:border-studio-accent enabled:hover:bg-studio-control-hover disabled:cursor-not-allowed disabled:border-studio-border disabled:text-studio-text-subtle disabled:opacity-50"; const buttonSizeClasses = { default: "rounded-studio-control px-2.5 py-1.75", @@ -17,8 +17,19 @@ type StudioButtonProps = ButtonHTMLAttributes & { size?: keyof typeof buttonSizeClasses; }; -export function StudioButton({ className, size = "default", ...props }: StudioButtonProps) { - return