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;