Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions web/src/app/pattern-lab/panels/KnobsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<KnobsPanel />);
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(<KnobsPanel />);
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(<KnobsPanel />);
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);
});
});
8 changes: 8 additions & 0 deletions web/src/app/pattern-lab/panels/KnobsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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")}
</div>
Expand Down
13 changes: 13 additions & 0 deletions web/src/app/pattern-lab/panels/TextPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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(<TextPanel />);

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" });
Expand Down
7 changes: 7 additions & 0 deletions web/src/app/pattern-lab/panels/TextPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}}
/>
</label>
)}
Expand Down
50 changes: 50 additions & 0 deletions web/src/lib/pattern/controls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
LOGICAL_KNOB_TO_WEB_KNOB,
LOGICAL_KNOB_WRAP,
getKnobValueDelta,
knobArrowValue,
knobDetentStep,
knobUnitsPerTurn,
logicalKnobUnitsPerTurn,
Expand Down Expand Up @@ -55,6 +56,55 @@ describe("travel", () => {
});
});

describe("arrow keys", () => {
const key = (name: string, mods: Partial<Record<"altKey" | "ctrlKey" | "metaKey", boolean>> = {}) => ({
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]);
Expand Down
31 changes: 31 additions & 0 deletions web/src/lib/pattern/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KnobId, number>;
Expand Down
Loading