diff --git a/web/src/app/pattern-lab/panels/KnobsPanel.test.tsx b/web/src/app/pattern-lab/panels/KnobsPanel.test.tsx new file mode 100644 index 000000000..410d4371f --- /dev/null +++ b/web/src/app/pattern-lab/panels/KnobsPanel.test.tsx @@ -0,0 +1,56 @@ +// The Knobs panel's sliders under the keyboard. A range input steps by its +// `step` on an arrow key, 0.001, so Freq's 10..120 took 110,000 presses; the +// panel turns one encoder detent instead and leaves the other keys alone. + +import { beforeEach, describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import KnobsPanel from "./KnobsPanel"; +import { useLabStore } from "@/lib/lab/store"; +import { knobDetentStep } from "@/lib/pattern/controls"; +import { DETENTS_PER_TURN, TURNS_PER_FULL_RANGE } from "@/lib/shared/constants"; + +const store = () => useLabStore.getState(); + +beforeEach(() => { + store().discardProject(); + useLabStore.setState({ + knobLabels: ["Hue", "Speed", "Quantize", "Freq"], + ranges: [[0, 1], [0.1, 10], [2, 6], [10, 120]], + knobs: [0, 2, 2, 10], + }); +}); + +describe("KnobsPanel", () => { + it("turns a knob one detent on an arrow key, and keeps the input's own step from running", () => { + render(); + const freq = screen.getByRole("slider", { name: "Freq value" }); + + expect(fireEvent.keyDown(freq, { key: "ArrowRight" })).toBe(false); + expect(store().knobs[3]).toBe(10 + knobDetentStep([10, 120])); + expect(screen.getByText("12.292")).toBeInTheDocument(); + + fireEvent.keyDown(freq, { key: "ArrowDown" }); + expect(store().knobs[3]).toBe(10); + expect(store().knobs.slice(0, 3)).toEqual([0, 2, 2]); + }); + + it("crosses a knob's range in two turns of presses, as the encoder does", () => { + render(); + const quantize = screen.getByRole("slider", { name: "Quantize value" }); + for (let press = 0; press < DETENTS_PER_TURN * TURNS_PER_FULL_RANGE; press++) { + fireEvent.keyDown(quantize, { key: "ArrowUp" }); + } + expect(store().knobs[2]).toBe(6); + fireEvent.keyDown(quantize, { key: "ArrowRight" }); + expect(store().knobs[2]).toBe(6); + }); + + it("leaves Home, End, Page keys and Alt-arrows to the slider and the browser", () => { + render(); + const speed = screen.getByRole("slider", { name: "Speed value" }); + for (const init of [{ key: "Home" }, { key: "End" }, { key: "PageUp" }, { key: "ArrowLeft", altKey: true }]) { + expect(fireEvent.keyDown(speed, init)).toBe(true); + } + expect(store().knobs[1]).toBe(2); + }); +}); diff --git a/web/src/app/pattern-lab/panels/KnobsPanel.tsx b/web/src/app/pattern-lab/panels/KnobsPanel.tsx index 596bfa539..d5e3a11b0 100644 --- a/web/src/app/pattern-lab/panels/KnobsPanel.tsx +++ b/web/src/app/pattern-lab/panels/KnobsPanel.tsx @@ -4,11 +4,13 @@ // physical encoders on the device: every code layer reads the same input. // Sliders + Blender-style digit dragging on the range bounds, and the encoder // Push buttons routed to the engine (buttons hit the ACTIVE code layer). +// An arrow key on a slider turns one detent, as on the device. import { useCallback, useEffect, useRef, useState } from "react"; import { labEngine } from "@/lib/lab/engine"; import { captureSession } from "@/lib/lab/capture/session"; import { useLabStore } from "@/lib/lab/store"; +import { knobArrowValue } from "@/lib/pattern/controls"; import local from "./KnobsPanel.module.css"; import dock from "../LabPanels.module.css"; @@ -287,6 +289,12 @@ export default function KnobsPanel() { value={value} aria-label={`${knobLabels[index]} value`} onChange={(event) => setKnob(index, Number(event.target.value))} + onKeyDown={(event) => { + const next = knobArrowValue(event, value, ranges[index]); + if (next === null) return; + event.preventDefault(); + setKnob(index, next); + }} /> {renderRangeValue(index, "max")} diff --git a/web/src/app/pattern-lab/panels/TextPanel.test.tsx b/web/src/app/pattern-lab/panels/TextPanel.test.tsx index 0cc2b3cf8..52da4b803 100644 --- a/web/src/app/pattern-lab/panels/TextPanel.test.tsx +++ b/web/src/app/pattern-lab/panels/TextPanel.test.tsx @@ -27,6 +27,7 @@ import PreviewPanel from "./PreviewPanel"; import RampPanel from "./RampPanel"; import { useLabStore } from "@/lib/lab/store"; import type { TextLayer } from "@/lib/lab/types"; +import { knobDetentStep } from "@/lib/pattern/controls"; const store = () => useLabStore.getState(); const text = (): TextLayer => { @@ -185,6 +186,18 @@ describe("an effect on the text", () => { expect(textPanel.getByText(/Rate sets how fast/)).toHaveTextContent("turn it up here or in Knobs."); }); + it("turns the speed knob a detent per arrow key, as Knobs does", () => { + store().addTextLayer(); + store().setTextSpec(text().id, { effect: "scroll" }); + useLabStore.setState({ knobLabels: ["Hue", "Rate", "Mode", "Shift"] }); + const range = store().ranges[1]; + useLabStore.setState({ knobs: [0, range[0], 0, 0] }); + render(); + + expect(fireEvent.keyDown(screen.getByRole("slider", { name: "Rate" }), { key: "ArrowRight" })).toBe(false); + expect(store().knobs[1]).toBeCloseTo(range[0] + knobDetentStep(range), 12); + }); + it("moves the slider to the knob a new pick names, and has none without an effect", () => { store().addTextLayer(); store().setTextSpec(text().id, { effect: "twinkle" }); diff --git a/web/src/app/pattern-lab/panels/TextPanel.tsx b/web/src/app/pattern-lab/panels/TextPanel.tsx index 7f88a69b1..466c6d0f9 100644 --- a/web/src/app/pattern-lab/panels/TextPanel.tsx +++ b/web/src/app/pattern-lab/panels/TextPanel.tsx @@ -45,6 +45,7 @@ import { type TextFill, type TextLayer, } from "@/lib/lab/types"; +import { knobArrowValue } from "@/lib/pattern/controls"; import type { CodeCopyState } from "./pixel/PixelToolbars"; import local from "./TextPanel.module.css"; import dock from "../LabPanels.module.css"; @@ -413,6 +414,12 @@ function TextEditor({ layer }: { layer: TextLayer }) { step="0.001" value={knobValue} onChange={(event) => setKnob(spec.effectKnob, Number(event.target.value))} + onKeyDown={(event) => { + const next = knobArrowValue(event, knobValue, knobRange); + if (next === null) return; + event.preventDefault(); + setKnob(spec.effectKnob, next); + }} /> )} diff --git a/web/src/lib/pattern/controls.test.ts b/web/src/lib/pattern/controls.test.ts index 6c5604285..f986a0c7a 100644 --- a/web/src/lib/pattern/controls.test.ts +++ b/web/src/lib/pattern/controls.test.ts @@ -16,6 +16,7 @@ import { LOGICAL_KNOB_TO_WEB_KNOB, LOGICAL_KNOB_WRAP, getKnobValueDelta, + knobArrowValue, knobDetentStep, knobUnitsPerTurn, logicalKnobUnitsPerTurn, @@ -55,6 +56,55 @@ describe("travel", () => { }); }); +describe("arrow keys", () => { + const key = (name: string, mods: Partial> = {}) => ({ + key: name, + altKey: false, + ctrlKey: false, + metaKey: false, + ...mods, + }); + + it("move a knob one detent, up on Right and Up, down on Left and Down", () => { + const range = [10, 120] as const; + expect(knobArrowValue(key("ArrowRight"), 10, range)).toBe(10 + knobDetentStep(range)); + expect(knobArrowValue(key("ArrowUp"), 10, range)).toBe(10 + knobDetentStep(range)); + expect(knobArrowValue(key("ArrowLeft"), 65, range)).toBe(65 - knobDetentStep(range)); + expect(knobArrowValue(key("ArrowDown"), 65, range)).toBe(65 - knobDetentStep(range)); + }); + + it("cross any range in two turns of detents and stop at each end", () => { + for (const range of [[0, 1], [0.1, 10], [0, 4.9], [10, 120], [2, 6], [-1, 1], [0, 360]] as const) { + let value: number = range[0]; + let presses = 0; + while (value < range[1] && presses < 100) { + value = knobArrowValue(key("ArrowRight"), value, range)!; + presses++; + if (presses === DETENTS_PER_TURN) expect(value).toBeCloseTo((range[0] + range[1]) / 2, 12); + } + expect([range, presses, value]).toEqual([range, DETENTS_PER_TURN * TURNS_PER_FULL_RANGE, range[1]]); + expect(knobArrowValue(key("ArrowRight"), value, range)).toBe(range[1]); + for (presses = 0; value > range[0] && presses < 100; presses++) value = knobArrowValue(key("ArrowLeft"), value, range)!; + expect([range, presses, value]).toEqual([range, DETENTS_PER_TURN * TURNS_PER_FULL_RANGE, range[0]]); + expect(knobArrowValue(key("ArrowLeft"), value, range)).toBe(range[0]); + } + }); + + it("turn a knob between detents by one detent from where it is", () => { + const range = [0, 1] as const; + expect(knobArrowValue(key("ArrowRight"), 0.06, range)).toBeCloseTo(0.06 + knobDetentStep(range), 12); + }); + + it("leave other keys and modified arrows to the input and the browser", () => { + for (const name of ["Home", "End", "PageUp", "PageDown", "Tab", "a"]) { + expect(knobArrowValue(key(name), 0.5, [0, 1])).toBeNull(); + } + for (const mod of ["altKey", "ctrlKey", "metaKey"] as const) { + expect(knobArrowValue(key("ArrowRight", { [mod]: true }), 0.5, [0, 1])).toBeNull(); + } + }); +}); + describe("front-panel ids", () => { it("finds each knob's range through the logical order (Hue, Speed, Mode, Freq)", () => { expect(webKnobRange("c1")).toEqual(LOGICAL_KNOB_RANGES[0]); diff --git a/web/src/lib/pattern/controls.ts b/web/src/lib/pattern/controls.ts index 50ae53086..f5a2de248 100644 --- a/web/src/lib/pattern/controls.ts +++ b/web/src/lib/pattern/controls.ts @@ -34,6 +34,37 @@ export function knobDetentStep(range: readonly [number, number]) { return knobUnitsPerTurn(range) / ENCODER_CLICKS_PER_TURN; } +/** + * Where one arrow key leaves a knob: a detent up (ArrowRight, ArrowUp) or + * down (ArrowLeft, ArrowDown), held to the range the way PFParams::apply holds + * the Lab's header. A range input's own arrow step is its `step`, 0.001, which + * took 110,000 presses across a 10..120 knob; this takes 48, as the encoder + * does. Any other key, or one with Alt, Ctrl or Cmd, gives null, so the input + * keeps Home, End and Page Up/Down and the browser keeps its shortcuts. + */ +export function knobArrowValue( + event: { key: string; altKey: boolean; ctrlKey: boolean; metaKey: boolean }, + value: number, + range: readonly [number, number], +): number | null { + if (event.altKey || event.ctrlKey || event.metaKey) return null; + const direction = + event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 + : event.key === "ArrowLeft" || event.key === "ArrowDown" ? -1 + : 0; + const [min, max] = range; + const span = max - min; + if (!direction || !(span > 0)) return null; + // Counted in detents from the bottom, so a knob on a detent lands on the + // next one exactly. Adding 4 / 48 to 2 forty-eight times stops short of 6, + // and the top took a 49th press. + const across = TURNS_PER_FULL_RANGE * ENCODER_CLICKS_PER_TURN; + const from = ((value - min) / span) * across; + const nearest = Math.round(from); + const detent = (Math.abs(from - nearest) < 1e-6 ? nearest : from) + direction; + return Math.min(max, Math.max(min, min + (detent * span) / across)); +} + export type KnobId = 'c1' | 'c2' | 'c3' | 'c4'; export type KnobValues = Record;