Controller: layouts for the media timing review and duplicate card dialogs (#313)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YashaNoosandClaude Opus 5.5 authored and GitHub committed 2026-11-02 01:38:26 -08:00
1 parent 696462a82e
commit 6d0f37ac21
50 files changed
+4555 -46

No files matched your search

@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createControllerComboCapture } from './controller-combo-capture.js';
const PRESSED = { value: 1, pressed: true };
const RELEASED = { value: 0, pressed: false };
function snapshot(pressed: number[] = [], axes: number[] = [0, 0, 0, 0]) {
return {
axes,
buttons: Array.from({ length: 17 }, (_, index) =>
pressed.includes(index) ? PRESSED : RELEASED,
),
};
}
function createCapture() {
return createControllerComboCapture({ triggerDeadzone: 0.5, stickDeadzone: 0.2 });
}
test('combo capture records a single button once it is released', () => {
const capture = createCapture();
capture.arm(snapshot());
assert.deepEqual(capture.poll(snapshot()), { status: 'waiting' });
assert.deepEqual(capture.poll(snapshot([1])), {
status: 'holding',
hold: { kind: 'button', buttonIndex: 1 },
});
assert.deepEqual(capture.poll(snapshot()), {
status: 'done',
binding: { kind: 'combo', press: { kind: 'button', buttonIndex: 1 }, hold: null },
});
});
test('combo capture records hold first + press second', () => {
const capture = createCapture();
capture.arm(snapshot());
capture.poll(snapshot([4]));
assert.deepEqual(capture.poll(snapshot([4, 15])), {
status: 'done',
binding: {
kind: 'combo',
press: { kind: 'button', buttonIndex: 15 },
hold: { kind: 'button', buttonIndex: 4 },
},
});
});
test('combo capture ignores buttons held when Learn starts until they are released', () => {
const capture = createCapture();
capture.arm(snapshot([0]));
assert.deepEqual(capture.poll(snapshot([0])), { status: 'waiting' });
capture.poll(snapshot());
assert.deepEqual(capture.poll(snapshot([0])), {
status: 'holding',
hold: { kind: 'button', buttonIndex: 0 },
});
});
test('combo capture never records a trigger axis resting position', () => {
const capture = createCapture();
// Some pads rest trigger axes at -1.
capture.arm(snapshot([], [0, 0, -1, 0]));
capture.poll(snapshot([], [0, 0, -0.2, 0]));
assert.deepEqual(capture.poll(snapshot([], [0, 0, -1, 0])), { status: 'waiting' });
assert.deepEqual(capture.poll(snapshot([], [0, 0, 0.9, 0])), {
status: 'holding',
hold: { kind: 'axis', axisIndex: 2, direction: 'positive' },
});
});
@@ -0,0 +1,99 @@
import type { ControllerReviewInput, ResolvedControllerReviewBinding } from '../../types';
type ControllerButtonState = {
value: number;
pressed?: boolean;
touched?: boolean;
};
type ControllerSnapshot = {
axes: readonly number[];
buttons: readonly ControllerButtonState[];
};
export type ControllerComboCaptureState =
| { status: 'waiting' }
| { status: 'holding'; hold: ControllerReviewInput }
| { status: 'done'; binding: ResolvedControllerReviewBinding };
type RawInput = Extract<ControllerReviewInput, { kind: 'button' | 'axis' }>;
function inputKey(input: RawInput): string {
return input.kind === 'button'
? `button:${input.buttonIndex}`
: `axis:${input.axisIndex}:${input.direction}`;
}
/**
* Learn for review bindings. Waits for a first input; letting go of it records it alone,
* while pressing a second input during the hold records "hold first + press second".
* Buttons already down when learning starts are ignored until released. Axis directions
* active at the start stay ignored for the whole capture: some pads rest a trigger axis at
* -1, and brushing it must not record that resting position as a press.
*/
export function createControllerComboCapture(options: {
triggerDeadzone: number;
stickDeadzone: number;
}) {
const blocked = new Set<string>();
const restingAxes = new Set<string>();
let first: RawInput | null = null;
function activeInputs(snapshot: ControllerSnapshot): RawInput[] {
const inputs: RawInput[] = [];
snapshot.buttons.forEach((button, buttonIndex) => {
if (button && (Boolean(button.pressed) || button.value >= options.triggerDeadzone)) {
inputs.push({ kind: 'button', buttonIndex });
}
});
const threshold = Math.max(options.stickDeadzone, 0.55);
snapshot.axes.forEach((value, axisIndex) => {
if (typeof value !== 'number' || !Number.isFinite(value) || Math.abs(value) < threshold) {
return;
}
inputs.push({ kind: 'axis', axisIndex, direction: value > 0 ? 'positive' : 'negative' });
});
return inputs;
}
function arm(snapshot: ControllerSnapshot): void {
first = null;
blocked.clear();
restingAxes.clear();
for (const input of activeInputs(snapshot)) {
(input.kind === 'axis' ? restingAxes : blocked).add(inputKey(input));
}
}
function poll(snapshot: ControllerSnapshot): ControllerComboCaptureState {
const active = activeInputs(snapshot).filter((input) => !restingAxes.has(inputKey(input)));
const activeKeys = new Set(active.map(inputKey));
for (const key of [...blocked]) {
if (!activeKeys.has(key)) blocked.delete(key);
}
const fresh = active.filter((input) => !blocked.has(inputKey(input)));
if (first === null) {
const next = fresh[0];
if (!next) return { status: 'waiting' };
first = next;
return { status: 'holding', hold: first };
}
const firstKey = inputKey(first);
if (!activeKeys.has(firstKey)) {
const press = first;
first = null;
return { status: 'done', binding: { kind: 'combo', press, hold: null } };
}
const second = fresh.find((input) => inputKey(input) !== firstKey);
if (second) {
const hold = first;
first = null;
return { status: 'done', binding: { kind: 'combo', press: second, hold } };
}
return { status: 'holding', hold: first };
}
return { arm, poll };
}
@@ -0,0 +1,157 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core';
import {
CONTROLLER_FIELD_GROUPING_ACTIONS,
CONTROLLER_FIELD_GROUPING_SCREEN,
type ControllerFieldGroupingStep,
} from '../../shared/controller-field-grouping';
import { findControllerScreenBindingIssues } from '../../shared/controller-review';
import type { ControllerFieldGroupingAction, ResolvedControllerConfig } from '../../types';
import {
createControllerComboScreenLayout,
formatControllerScreenHints,
getControllerReviewInputContext,
} from './controller-screen-layout.js';
import type { GamepadLike } from './gamepad-controller.js';
const CONFIG: ResolvedControllerConfig = { ...CORE_DEFAULT_CONFIG.controller, enabled: true };
const { repeatDelayMs, repeatIntervalMs } = CONFIG;
// Standard-layout indices; faces follow config.buttonIndices (Xbox positions by default).
const BUTTON = { A: 0, B: 1, X: 2, Y: 3, UP: 12, DOWN: 13, LEFT: 14, RIGHT: 15 };
type ButtonName = keyof typeof BUTTON;
function pad(pressed: ButtonName[] = []): GamepadLike {
const buttons = Array.from({ length: 17 }, () => ({ value: 0, pressed: false }));
for (const name of pressed) buttons[BUTTON[name]] = { value: 1, pressed: true };
return { id: 'pad', index: 0, connected: true, mapping: 'standard', axes: [0, 0, 0, 0], buttons };
}
/** The field grouping layout driving a fake dialog that follows the default flow. */
function createFieldGroupingLayout(config = CONFIG) {
const actions: ControllerFieldGroupingAction[] = [];
const state: { step: ControllerFieldGroupingStep } = { step: 'select' };
const layout = createControllerComboScreenLayout({
screen: CONTROLLER_FIELD_GROUPING_SCREEN,
getBindings: (resolved) => resolved.fieldGroupingBindings,
getStep: () => state.step,
dispatch: (action) => {
actions.push(action);
if (action === 'continue') state.step = 'preview';
if (action === 'back') state.step = 'select';
},
});
layout.activate(pad(), config, 0, { latchHeld: true });
return { layout, actions, state };
}
test('the default duplicate card layout binds every action but preview Cancel, without conflicts', () => {
for (const action of CONTROLLER_FIELD_GROUPING_ACTIONS) {
assert.equal(
CONFIG.fieldGroupingBindings[action.id]?.kind,
action.id === 'cancelMerge' ? 'none' : 'combo',
action.id,
);
}
for (const mapping of ['standard', ''] as const) {
for (const faceButtonLabels of ['xbox', 'nintendo'] as const) {
const context = getControllerReviewInputContext({ ...CONFIG, faceButtonLabels }, mapping);
assert.deepEqual(
findControllerScreenBindingIssues(
CONTROLLER_FIELD_GROUPING_SCREEN,
CONFIG.fieldGroupingBindings,
context,
),
{},
);
}
}
});
test('duplicate card hints read like the agreed layout', () => {
const hints = formatControllerScreenHints(
CONTROLLER_FIELD_GROUPING_SCREEN,
CONFIG.fieldGroupingBindings,
CONFIG,
'standard',
);
assert.deepEqual(hints, {
selectOriginal: '◀',
selectNew: '▶',
toggleDeleteDuplicate: 'X',
continue: 'A',
cancel: 'B',
confirmMerge: 'A',
back: 'B',
cancelMerge: null,
toggleView: 'Y',
scrollUp: '▲',
scrollDown: '▼',
});
});
test('the D-pad picks the card and X toggles delete on the first step', () => {
const { layout, actions } = createFieldGroupingLayout();
layout.poll(pad(['RIGHT']), CONFIG, 10);
layout.poll(pad(['RIGHT']), CONFIG, 2_000);
layout.poll(pad(), CONFIG, 2_010);
layout.poll(pad(['LEFT']), CONFIG, 2_020);
layout.poll(pad(['X']), CONFIG, 2_030);
// Up/down scroll only on the preview.
layout.poll(pad(['DOWN']), CONFIG, 2_040);
assert.deepEqual(actions, ['selectNew', 'selectOriginal', 'toggleDeleteDuplicate']);
});
test('B on the preview goes back; holding it does not also cancel', () => {
const { layout, actions, state } = createFieldGroupingLayout();
layout.poll(pad(['A']), CONFIG, 10);
assert.equal(state.step, 'preview');
// A still held after the step change does not confirm the merge.
layout.poll(pad(['A']), CONFIG, 20);
layout.poll(pad(), CONFIG, 30);
layout.poll(pad(['B']), CONFIG, 40);
assert.equal(state.step, 'select');
// B held across the step change is not a fresh press on the first step.
layout.poll(pad(['B']), CONFIG, 1_000);
layout.poll(pad(), CONFIG, 1_010);
layout.poll(pad(['B']), CONFIG, 1_020);
assert.deepEqual(actions, ['continue', 'back', 'cancel']);
});
test('preview scrolling repeats while the D-pad is held', () => {
const { layout, actions, state } = createFieldGroupingLayout();
state.step = 'preview';
layout.poll(pad(['DOWN']), CONFIG, 0);
layout.poll(pad(['DOWN']), CONFIG, repeatDelayMs - 1);
layout.poll(pad(['DOWN']), CONFIG, repeatDelayMs);
layout.poll(pad(['DOWN']), CONFIG, repeatDelayMs + repeatIntervalMs);
layout.poll(pad(), CONFIG, repeatDelayMs + repeatIntervalMs + 1);
layout.poll(pad(['Y']), CONFIG, 5_000);
layout.poll(pad(['Y']), CONFIG, 6_000);
assert.deepEqual(actions, ['scrollDown', 'scrollDown', 'scrollDown', 'toggleView']);
});
test('remapped duplicate card bindings replace the defaults', () => {
const config: ResolvedControllerConfig = {
...CONFIG,
fieldGroupingBindings: {
...CONFIG.fieldGroupingBindings,
back: { kind: 'none' },
cancelMerge: { kind: 'combo', press: { kind: 'face', letter: 'b' }, hold: null },
},
};
const { layout, actions, state } = createFieldGroupingLayout(config);
state.step = 'preview';
layout.poll(pad(['B']), config, 10);
assert.deepEqual(actions, ['cancelMerge']);
});
@@ -0,0 +1,213 @@
import {
compileControllerScreenBindings,
formatControllerReviewBinding,
type CompiledControllerScreenBinding,
type ControllerReviewInputContext,
type ControllerReviewInputSource,
type ControllerScreenBindings,
type ControllerScreenDefinition,
} from '../../shared/controller-review';
import type { ResolvedControllerConfig } from '../../types';
import {
createHoldState,
resolveDiscreteBindingPressed,
resolveDpadHorizontalValue,
resolveDpadVerticalValue,
shouldFireHeldAction,
type ControllerScreenLayout,
type GamepadLike,
type HoldState,
} from './gamepad-controller.js';
export function getControllerReviewInputContext(
config: ResolvedControllerConfig,
mapping: string | null | undefined,
): ControllerReviewInputContext {
return {
mapping,
faceButtonLabels: config.faceButtonLabels,
buttonIndices: config.buttonIndices,
};
}
/** Hint text per action of a screen, e.g. `A` or `L1 + ▶`; null when unbound. */
export function formatControllerScreenHints<A extends string>(
screen: ControllerScreenDefinition<A>,
bindings: Partial<ControllerScreenBindings<A>>,
config: ResolvedControllerConfig,
mapping: string | null | undefined,
): Record<A, string | null> {
const context = getControllerReviewInputContext(config, mapping);
const hints = {} as Record<A, string | null>;
for (const action of screen.actions) {
const binding = bindings[action.id];
hints[action.id] =
binding && binding.kind === 'combo' ? formatControllerReviewBinding(binding, context) : null;
}
return hints;
}
function isSourcePressed(
source: ControllerReviewInputSource,
gamepad: GamepadLike,
config: ResolvedControllerConfig,
): boolean {
if (source.kind === 'dpad') {
if (source.direction === 'left' || source.direction === 'right') {
const value = resolveDpadHorizontalValue(gamepad, config.triggerDeadzone);
return source.direction === 'left' ? value < 0 : value > 0;
}
const value = resolveDpadVerticalValue(gamepad, config.triggerDeadzone);
return source.direction === 'up' ? value < 0 : value > 0;
}
return resolveDiscreteBindingPressed(gamepad, source, config);
}
type PressChannel<A extends string> = {
action: A | null;
repeat: boolean;
/** Hold buttons that were down when this press started. */
modifiers: string;
/** Set when the hold buttons changed mid-press; the press must be released first. */
latched: boolean;
hold: HoldState;
};
type ScreenFrame<A extends string> = {
compiled: CompiledControllerScreenBinding<A>[];
pressed: Set<string>;
holdKeys: Set<string>;
};
/**
* Picks the action for a fresh press: with one hold button down, the binding that uses it;
* with none, the plain binding. Two hold buttons at once is ambiguous and matches nothing.
*/
function matchAction<A extends string>(
compiled: CompiledControllerScreenBinding<A>[],
pressKey: string,
modifiers: string[],
): A | null {
if (modifiers.length > 1) return null;
const holdKey = modifiers[0] ?? null;
return (
compiled.find((entry) => entry.pressKey === pressKey && entry.holdKey === holdKey)?.action ??
null
);
}
/**
* Controller layout for a dialog described by a screen definition: per step, press-or-combo
* bindings with optional repeat. Actions are dispatched; the dialog decides what they do.
*/
export function createControllerComboScreenLayout<A extends string, S extends string>(options: {
screen: ControllerScreenDefinition<A, S>;
getBindings: (config: ResolvedControllerConfig) => Partial<ControllerScreenBindings<A>>;
getStep: () => S;
dispatch: (action: A) => void;
}): ControllerScreenLayout {
const { screen } = options;
const channels = new Map<string, PressChannel<A>>();
const repeatByAction = new Map(screen.actions.map((action) => [action.id, action.repeat]));
function readFrame(gamepad: GamepadLike, config: ResolvedControllerConfig): ScreenFrame<A> {
const context = getControllerReviewInputContext(config, gamepad.mapping);
const step = options.getStep();
const bindings = options.getBindings(config);
const compiled = compileControllerScreenBindings(screen, bindings, step, context);
// Track inputs from every step so a press held across a step change is not new.
const sources = new Map<string, ControllerReviewInputSource>();
for (const { id: anyStep } of screen.steps) {
const entries =
anyStep === step
? compiled
: compileControllerScreenBindings(screen, bindings, anyStep, context);
for (const entry of entries) {
sources.set(entry.pressKey, entry.press);
if (entry.hold && entry.holdKey) sources.set(entry.holdKey, entry.hold);
}
}
const pressed = new Set<string>();
for (const [key, source] of sources) {
if (isSourcePressed(source, gamepad, config)) pressed.add(key);
}
const holdKeys = new Set<string>();
for (const entry of compiled) {
if (entry.holdKey) holdKeys.add(entry.holdKey);
}
return { compiled, pressed, holdKeys };
}
function heldModifiers(frame: ScreenFrame<A>, exceptKey: string): string[] {
return [...frame.holdKeys].filter((key) => key !== exceptKey && frame.pressed.has(key)).sort();
}
function poll(gamepad: GamepadLike, config: ResolvedControllerConfig, now: number): void {
const frame = readFrame(gamepad, config);
for (const key of [...channels.keys()]) {
if (!frame.pressed.has(key)) channels.delete(key);
}
for (const key of frame.pressed) {
const modifiers = heldModifiers(frame, key);
const signature = modifiers.join('|');
const channel = channels.get(key);
if (!channel) {
const action = matchAction(frame.compiled, key, modifiers);
const next: PressChannel<A> = {
action,
repeat: action ? (repeatByAction.get(action) ?? false) : false,
modifiers: signature,
latched: false,
hold: createHoldState(),
};
channels.set(key, next);
if (action) {
shouldFireHeldAction(next.hold, now, config.repeatDelayMs, config.repeatIntervalMs);
options.dispatch(action);
}
continue;
}
if (channel.latched || channel.action === null) continue;
if (channel.modifiers !== signature) {
// Letting go of L1 a moment before the D-pad must not fire the plain D-pad action.
channel.latched = true;
continue;
}
if (
channel.repeat &&
shouldFireHeldAction(channel.hold, now, config.repeatDelayMs, config.repeatIntervalMs)
) {
options.dispatch(channel.action);
}
}
}
function latchHeld(gamepad: GamepadLike, config: ResolvedControllerConfig): void {
channels.clear();
for (const key of readFrame(gamepad, config).pressed) {
channels.set(key, {
action: null,
repeat: false,
modifiers: '',
latched: true,
hold: createHoldState(),
});
}
}
return {
activate(gamepad, config, now, { latchHeld: shouldLatch }) {
if (shouldLatch) {
latchHeld(gamepad, config);
return;
}
channels.clear();
poll(gamepad, config, now);
},
poll,
};
}
@@ -3,7 +3,7 @@ import test from 'node:test';
import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core';
import type { ControllerRuntimeSnapshot, ResolvedControllerConfig } from '../../types';
import { createGamepadController } from './gamepad-controller.js';
import { createGamepadController, type ControllerScreenLayout } from './gamepad-controller.js';
type ControllerOptions = Parameters<typeof createGamepadController>[0];
type Bindings = ResolvedControllerConfig['bindings'];
@@ -290,6 +290,8 @@ test('gamepad controller uses active controller profile bindings before global b
label: 'Profile Pad',
buttonIndices: globalConfig.buttonIndices,
bindings: { ...globalConfig.bindings, toggleLookup: { kind: 'button', buttonIndex: 11 } },
reviewBindings: globalConfig.reviewBindings,
fieldGroupingBindings: globalConfig.fieldGroupingBindings,
},
},
},
@@ -659,3 +661,126 @@ test('gamepad controller requires keyboard-only mode for popup entry navigation'
assert.deepEqual(harness.calls, []);
});
function createRecordingScreenLayout() {
const events: string[] = [];
const layout: ControllerScreenLayout = {
activate: (gamepad, _config, _now, { latchHeld }) => {
events.push(`activate:${gamepad.id}:${latchHeld ? 'latch' : 'fresh'}`);
},
poll: (_gamepad, _config, now) => {
events.push(`poll:${now}`);
},
};
return { layout, events };
}
test('gamepad controller hands every input to an open screen layout', () => {
let screenOpen = true;
const { layout, events } = createRecordingScreenLayout();
const harness = createHarness({
// A (lookup), Y (keyboard mode) and R2 (secondary hold) are all pressed.
gamepads: [createGamepad('pad-1', { buttons: { 0: PRESSED, 3: PRESSED, 7: PRESSED } })],
config: createControllerConfig({ bindings: HOLD_ON_R2 }),
deps: { getScreenLayout: () => (screenOpen ? layout : null) },
});
harness.poll(0, 16, 32);
assert.deepEqual(events, ['activate:pad-1:latch', 'poll:16', 'poll:32']);
assert.deepEqual(harness.calls, []);
assert.deepEqual(harness.secondarySubHeld, []);
// Buttons still held when the screen closes do not fire their normal actions...
screenOpen = false;
harness.poll(48);
assert.deepEqual(harness.calls, []);
// ...but a level-triggered hold resumes, since it reflects what is held right now.
assert.deepEqual(harness.secondarySubHeld, [true]);
});
test('gamepad controller re-activates the screen layout for each new session', () => {
let screenOpen = true;
const { layout, events } = createRecordingScreenLayout();
const harness = createHarness({
gamepads: [createGamepad('pad-1')],
deps: { getScreenLayout: () => (screenOpen ? layout : null) },
});
harness.poll(0, 16);
screenOpen = false;
harness.poll(32);
screenOpen = true;
harness.poll(48, 64);
assert.deepEqual(events, ['activate:pad-1:latch', 'poll:16', 'activate:pad-1:latch', 'poll:64']);
});
test('gamepad controller ignores the screen layout while controller support is disabled', () => {
const { layout, events } = createRecordingScreenLayout();
const harness = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 0: PRESSED } })],
config: createControllerConfig({ enabled: false }),
deps: { getScreenLayout: () => layout },
});
harness.poll(0, 16);
assert.deepEqual(events, []);
});
test('gamepad controller stands down entirely while another window owns the controller', () => {
let suspended = true;
let r2: TestButton = PRESSED;
const harness = createHarness({
gamepads: () => [createGamepad('pad-1', { buttons: { 0: PRESSED, 3: PRESSED, 7: r2 } })],
config: createControllerConfig({ bindings: HOLD_ON_R2 }),
});
harness.poll(0);
assert.deepEqual(harness.secondarySubHeld, [true]);
const suspendedHarness = createHarness({
gamepads: () => [createGamepad('pad-1', { buttons: { 0: PRESSED, 3: PRESSED, 7: r2 } })],
config: createControllerConfig({ bindings: HOLD_ON_R2 }),
deps: { getSuspended: () => suspended },
});
suspendedHarness.poll(0, 16);
// Not even the keyboard-mode toggle (Y) fires, and the hold stays released.
assert.deepEqual(suspendedHarness.calls, []);
assert.deepEqual(suspendedHarness.secondarySubHeld, []);
suspended = false;
r2 = RELEASED;
suspendedHarness.poll(32);
assert.deepEqual(suspendedHarness.calls, []);
});
test('gamepad controller counts the press that reveals a pad to an open screen', () => {
let visible = false;
const { layout, events } = createRecordingScreenLayout();
const harness = createHarness({
// Until a button is pressed in a fresh window, the browser reports no pads at all.
gamepads: () => (visible ? [createGamepad('pad-1', { buttons: { 0: PRESSED } })] : []),
deps: { getScreenLayout: () => layout },
});
harness.poll(0, 16);
visible = true;
harness.poll(32, 48);
assert.deepEqual(events, ['activate:pad-1:fresh', 'poll:48']);
});
test('gamepad controller still ignores a button held when the screen opened', () => {
let screenOpen = false;
const { layout, events } = createRecordingScreenLayout();
const harness = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 2: PRESSED } })],
deps: { getScreenLayout: () => (screenOpen ? layout : null) },
});
harness.poll(0);
screenOpen = true;
harness.poll(16);
assert.deepEqual(events, ['activate:pad-1:latch']);
});
+62 -9
View File
@@ -13,7 +13,7 @@ type ControllerButtonState = {
touched?: boolean;
};
type GamepadLike = {
export type GamepadLike = {
id: string;
index: number;
connected: boolean;
@@ -22,6 +22,22 @@ type GamepadLike = {
buttons: readonly ControllerButtonState[];
};
/**
* A screen that takes over every controller input while it is open (for example the
* media timing review). `activate` runs on the first poll of a new session; with
* `latchHeld` it ignores buttons already held (so the press that opened the screen does
* not fire), otherwise it treats them as fresh presses. `poll` runs on every later frame.
*/
export type ControllerScreenLayout = {
activate: (
gamepad: GamepadLike,
config: ResolvedControllerConfig,
now: number,
options: { latchHeld: boolean },
) => void;
poll: (gamepad: GamepadLike, config: ResolvedControllerConfig, now: number) => void;
};
type GamepadControllerOptions = {
getGamepads: () => Array<GamepadLike | null>;
getConfig: () => ResolvedControllerConfig;
@@ -44,9 +60,13 @@ type GamepadControllerOptions = {
scrollPopup: (deltaPixels: number) => void;
jumpPopup: (deltaPixels: number) => void;
onState: (state: ControllerRuntimeSnapshot) => void;
/** Screen layout that currently owns the controller in this window, if any. */
getScreenLayout?: () => ControllerScreenLayout | null;
/** True while a modal in another window owns the controller; this window stands down. */
getSuspended?: () => boolean;
};
type HoldState = {
export type HoldState = {
repeatStarted: boolean;
direction: -1 | 1 | null;
lastFireAt: number;
@@ -65,7 +85,7 @@ const DPAD_AXIS_INDEX = {
vertical: 7,
} as const;
function normalizeRawButtonState(
export function normalizeRawButtonState(
button: ControllerButtonState | undefined,
triggerDeadzone: number,
): boolean {
@@ -114,7 +134,7 @@ function resolveDpadAxisValue(gamepad: GamepadLike, axisIndex: number): number {
return Math.sign(value);
}
function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
export function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.horizontal);
if (axisValue !== 0) {
return axisValue;
@@ -127,7 +147,7 @@ function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: numbe
);
}
function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
export function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.vertical);
if (axisValue !== 0) {
return axisValue;
@@ -141,7 +161,7 @@ function resolveConnectedGamepads(gamepads: Array<GamepadLike | null>): GamepadL
.sort((left, right) => left.index - right.index);
}
function createHoldState(): HoldState {
export function createHoldState(): HoldState {
return {
repeatStarted: false,
direction: null,
@@ -150,7 +170,7 @@ function createHoldState(): HoldState {
};
}
function shouldFireHeldAction(
export function shouldFireHeldAction(
state: HoldState,
now: number,
repeatDelayMs: number,
@@ -173,7 +193,7 @@ function shouldFireHeldAction(
return true;
}
function resetHeldAction(state: HoldState): void {
export function resetHeldAction(state: HoldState): void {
state.repeatStarted = false;
state.direction = null;
state.lastFireAt = 0;
@@ -198,7 +218,7 @@ function syncHeldActionBlocked(
state.initialFired = true;
}
function resolveDiscreteBindingPressed(
export function resolveDiscreteBindingPressed(
gamepad: GamepadLike,
binding: ResolvedControllerDiscreteBinding,
config: ResolvedControllerConfig,
@@ -263,6 +283,8 @@ export function createGamepadController(options: GamepadControllerOptions) {
let activeGamepadId: string | null = null;
let lastPollAt: number | null = null;
let secondarySubHeld = false;
let activeScreenLayout: ControllerScreenLayout | null = null;
let screenLayoutWaitingForPad = false;
function getConnectedGamepads(): GamepadLike[] {
return resolveConnectedGamepads(options.getGamepads());
@@ -423,6 +445,11 @@ export function createGamepadController(options: GamepadControllerOptions) {
if (!activeGamepad) {
syncSecondarySubHold(false);
activeScreenLayout = null;
// Browsers hide a pad from a page until a button is pressed while it is open, and
// each review opens in a fresh window. When a screen is already waiting for the pad,
// the press that reveals it started after the screen opened, so it should count.
screenLayoutWaitingForPad = config.enabled && (options.getScreenLayout?.() ?? null) !== null;
previousActions = new Map();
resetHeldAction(selectionHold);
resetHeldAction(jumpHold);
@@ -431,6 +458,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
}
if (activeGamepad.id !== previousActiveGamepadId) {
activeScreenLayout = null;
previousActions = new Map();
resetHeldAction(selectionHold);
resetHeldAction(jumpHold);
@@ -438,6 +466,31 @@ export function createGamepadController(options: GamepadControllerOptions) {
const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id);
// A screen layout or another window owning the controller pauses every normal action,
// including the keyboard-mode toggle and the secondary subtitle hold. Their edge state
// keeps tracking the pad, so a button still held when control returns does not fire.
const screenLayout = activeConfig.enabled ? (options.getScreenLayout?.() ?? null) : null;
const suspended = screenLayout === null && (options.getSuspended?.() ?? false);
if (screenLayout !== null || suspended) {
syncSecondarySubHold(false);
syncBlockedInteractionState(activeGamepad, activeConfig, now);
if (screenLayout === null) {
activeScreenLayout = null;
screenLayoutWaitingForPad = false;
} else if (screenLayout !== activeScreenLayout) {
activeScreenLayout = screenLayout;
screenLayout.activate(activeGamepad, activeConfig, now, {
latchHeld: !screenLayoutWaitingForPad,
});
screenLayoutWaitingForPad = false;
} else {
screenLayout.poll(activeGamepad, activeConfig, now);
}
return;
}
activeScreenLayout = null;
screenLayoutWaitingForPad = false;
// Unlike most actions, this one does not require keyboard-only mode, so it can be
// used while simply watching.
syncSecondarySubHold(
@@ -0,0 +1,307 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core';
import {
CONTROLLER_REVIEW_ACTIONS,
findControllerReviewBindingIssues,
} from '../../shared/controller-review';
import type { ResolvedControllerConfig } from '../../types';
import type { GamepadLike } from './gamepad-controller.js';
import {
createMediaTimingReviewControllerLayout,
formatMediaTimingReviewControllerHints,
getControllerReviewInputContext,
type MediaTimingReviewControllerAction,
type MediaTimingReviewStep,
} from './media-timing-review-controller.js';
const CONFIG: ResolvedControllerConfig = { ...CORE_DEFAULT_CONFIG.controller, enabled: true };
const { repeatDelayMs, repeatIntervalMs } = CONFIG;
// Standard-layout indices; the layout reads them from config.buttonIndices.
const BUTTON = { A: 0, B: 1, X: 2, Y: 3, L1: 4, R1: 5, UP: 12, DOWN: 13, LEFT: 14, RIGHT: 15 };
type ButtonName = keyof typeof BUTTON;
function pad(pressed: ButtonName[] = [], axes: number[] = [0, 0, 0, 0]): GamepadLike {
const buttons = Array.from({ length: 17 }, () => ({ value: 0, pressed: false }));
for (const name of pressed) buttons[BUTTON[name]] = { value: 1, pressed: true };
return { id: 'pad', index: 0, connected: true, mapping: 'standard', axes, buttons };
}
function createLayout(step: MediaTimingReviewStep = 'editor') {
const actions: MediaTimingReviewControllerAction[] = [];
const state = { step };
const layout = createMediaTimingReviewControllerLayout({
getStep: () => state.step,
dispatch: (action) => actions.push(action),
});
layout.activate(pad(), CONFIG, 0, { latchHeld: true });
return { layout, actions, state };
}
test('the default review layout binds all 21 actions without conflicts', () => {
assert.equal(CONTROLLER_REVIEW_ACTIONS.length, 21);
for (const action of CONTROLLER_REVIEW_ACTIONS) {
assert.equal(CONFIG.reviewBindings[action.id]?.kind, 'combo', action.id);
}
for (const mapping of ['standard', ''] as const) {
for (const faceButtonLabels of ['xbox', 'nintendo'] as const) {
const context = getControllerReviewInputContext({ ...CONFIG, faceButtonLabels }, mapping);
assert.deepEqual(findControllerReviewBindingIssues(CONFIG.reviewBindings, context), {});
}
}
});
test('controller hints read like the documented layout', () => {
const hints = formatMediaTimingReviewControllerHints(CONFIG, 'standard');
assert.equal(hints.confirm, 'A');
assert.equal(hints.showEarlier, '\u25c0');
assert.equal(hints.startForward, 'L1 + \u25b6');
assert.equal(hints.nextLineAdd, 'R1 + \u25b2');
assert.equal(hints.frameReset, 'L1 + R1');
});
test('face buttons press the editor buttons once per press', () => {
const { layout, actions } = createLayout();
layout.poll(pad(['A']), CONFIG, 10);
layout.poll(pad(['A']), CONFIG, 900);
layout.poll(pad(), CONFIG, 910);
layout.poll(pad(['B', 'X', 'Y']), CONFIG, 920);
assert.deepEqual(actions, ['confirm', 'cancel', 'play', 'resetTiming']);
});
test('face buttons press the cancel-step buttons on that step', () => {
const { layout, actions } = createLayout('cancel');
layout.poll(pad(['A', 'B', 'X', 'Y']), CONFIG, 10);
assert.deepEqual(actions, ['useOriginal', 'keepEditing', 'skipMedia', 'discard']);
});
test('buttons already held when the review opens wait for a fresh press', () => {
const actions: MediaTimingReviewControllerAction[] = [];
const layout = createMediaTimingReviewControllerLayout({
getStep: () => 'editor',
dispatch: (action) => actions.push(action),
});
// X mined the card that opened this review; it must not also start the preview.
layout.activate(pad(['X', 'RIGHT']), CONFIG, 0, { latchHeld: true });
layout.poll(pad(['X', 'RIGHT']), CONFIG, 1_000);
assert.deepEqual(actions, []);
layout.poll(pad(), CONFIG, 1_010);
layout.poll(pad(['X', 'RIGHT']), CONFIG, 1_020);
assert.deepEqual(actions, ['play', 'showLater']);
});
test('D-pad alone moves the timeline and steps screenshot frames', () => {
const { layout, actions } = createLayout();
for (const name of ['LEFT', 'RIGHT', 'UP', 'DOWN'] as const) {
layout.poll(pad([name]), CONFIG, 10);
layout.poll(pad(), CONFIG, 20);
}
assert.deepEqual(actions, ['showEarlier', 'showLater', 'framePrevious', 'frameNext']);
});
test('L1 combos adjust the start and previous lines, R1 combos the end and next lines', () => {
const { layout, actions } = createLayout();
const press = (names: ButtonName[]) => {
layout.poll(pad(names), CONFIG, 10);
layout.poll(pad(), CONFIG, 20);
};
press(['L1', 'LEFT']);
press(['L1', 'RIGHT']);
press(['L1', 'UP']);
press(['L1', 'DOWN']);
press(['R1', 'LEFT']);
press(['R1', 'RIGHT']);
press(['R1', 'UP']);
press(['R1', 'DOWN']);
assert.deepEqual(actions, [
'startBack',
'startForward',
'previousLineAdd',
'previousLineRemove',
'endBack',
'endForward',
'nextLineAdd',
'nextLineRemove',
]);
});
test('holding a D-pad combo repeats after the configured delay', () => {
const { layout, actions } = createLayout();
const hold = (now: number) => layout.poll(pad(['L1', 'RIGHT']), CONFIG, now);
hold(1_000);
hold(1_000 + repeatDelayMs - 1);
hold(1_000 + repeatDelayMs);
hold(1_000 + repeatDelayMs + repeatIntervalMs - 1);
hold(1_000 + repeatDelayMs + repeatIntervalMs);
assert.deepEqual(actions, ['startForward', 'startForward', 'startForward']);
});
test('letting go of L1 before the D-pad does not fire the plain D-pad action', () => {
const { layout, actions } = createLayout();
layout.poll(pad(['L1', 'RIGHT']), CONFIG, 10);
layout.poll(pad(['RIGHT']), CONFIG, 20);
layout.poll(pad(['RIGHT']), CONFIG, 2_000);
assert.deepEqual(actions, ['startForward']);
layout.poll(pad(), CONFIG, 2_010);
layout.poll(pad(['RIGHT']), CONFIG, 2_020);
assert.deepEqual(actions, ['startForward', 'showLater']);
});
test('pressing L1 and R1 together resets the screenshot frame once', () => {
const { layout, actions } = createLayout();
layout.poll(pad(['L1']), CONFIG, 10);
layout.poll(pad(['L1', 'R1']), CONFIG, 20);
layout.poll(pad(['L1', 'R1', 'RIGHT']), CONFIG, 30);
layout.poll(pad(['L1', 'R1', 'RIGHT']), CONFIG, 2_000);
assert.deepEqual(actions, ['frameReset']);
});
test('D-pads reported as axes (non-standard pads) drive the same layout', () => {
const { layout, actions } = createLayout();
const axes = (horizontal: number, vertical: number) => [0, 0, 0, 0, 0, 0, horizontal, vertical];
layout.poll(pad(['R1'], axes(-1, 0)), CONFIG, 10);
layout.poll(pad([], axes(0, 0)), CONFIG, 20);
layout.poll(pad([], axes(0, 1)), CONFIG, 30);
assert.deepEqual(actions, ['endBack', 'frameNext']);
});
test('Nintendo-style pads press the button labelled A to confirm, wherever it sits', () => {
const nintendo: ResolvedControllerConfig = { ...CONFIG, faceButtonLabels: 'nintendo' };
const actions: MediaTimingReviewControllerAction[] = [];
const layout = createMediaTimingReviewControllerLayout({
getStep: () => 'editor',
dispatch: (action) => actions.push(action),
});
layout.activate(pad(), nintendo, 0, { latchHeld: true });
// Positions, not letters: on these pads A is the right button (index 1), B the bottom
// one (0), X the top one (3) and Y the left one (2).
for (const position of ['B', 'A', 'Y', 'X'] as const) {
layout.poll(pad([position]), nintendo, 10);
layout.poll(pad(), nintendo, 20);
}
assert.deepEqual(actions, ['confirm', 'cancel', 'play', 'resetTiming']);
});
test('a press that first reveals the pad to a fresh dialog window counts', () => {
const actions: MediaTimingReviewControllerAction[] = [];
const layout = createMediaTimingReviewControllerLayout({
getStep: () => 'editor',
dispatch: (action) => actions.push(action),
});
layout.activate(pad(['A']), CONFIG, 0, { latchHeld: false });
layout.poll(pad(['A']), CONFIG, 16);
assert.deepEqual(actions, ['confirm']);
});
function withReviewBindings(
overrides: Partial<ResolvedControllerConfig['reviewBindings']>,
): ResolvedControllerConfig {
return { ...CONFIG, reviewBindings: { ...CONFIG.reviewBindings, ...overrides } };
}
test('remapped combos use any hold button and leave plain presses alone', () => {
const config = withReviewBindings({
// Hold L2 (raw button 6 on standard pads) and press A.
startForward: {
kind: 'combo',
press: { kind: 'face', letter: 'a' },
hold: { kind: 'button', buttonIndex: 6 },
},
});
const actions: MediaTimingReviewControllerAction[] = [];
const layout = createMediaTimingReviewControllerLayout({
getStep: () => 'editor',
dispatch: (action) => actions.push(action),
});
const press = (indices: number[], now: number) => {
const buttons = Array.from({ length: 17 }, (_, index) => ({
value: indices.includes(index) ? 1 : 0,
pressed: indices.includes(index),
}));
layout.poll({ ...pad(), buttons }, config, now);
};
layout.activate(pad(), config, 0, { latchHeld: true });
press([6], 10);
press([6, 0], 20);
press([], 30);
press([0], 40);
assert.deepEqual(actions, ['startForward', 'confirm']);
});
test('unbound review actions never fire', () => {
const config = withReviewBindings({ confirm: { kind: 'none' } });
const actions: MediaTimingReviewControllerAction[] = [];
const layout = createMediaTimingReviewControllerLayout({
getStep: () => 'editor',
dispatch: (action) => actions.push(action),
});
layout.activate(pad(), config, 0, { latchHeld: true });
layout.poll(pad(['A']), config, 10);
assert.deepEqual(actions, []);
});
test('repeat follows the action, not the button it is on', () => {
const config = withReviewBindings({
startForward: { kind: 'combo', press: { kind: 'face', letter: 'b' }, hold: null },
cancel: { kind: 'combo', press: { kind: 'dpad', direction: 'right' }, hold: null },
showLater: { kind: 'none' },
});
const actions: MediaTimingReviewControllerAction[] = [];
const layout = createMediaTimingReviewControllerLayout({
getStep: () => 'editor',
dispatch: (action) => actions.push(action),
});
layout.activate(pad(), config, 0, { latchHeld: true });
for (const now of [10, 10 + repeatDelayMs, 10 + repeatDelayMs + repeatIntervalMs]) {
layout.poll(pad(['B', 'RIGHT']), config, now);
}
// Both fire on the first press; only startForward repeats.
assert.deepEqual(actions, ['cancel', 'startForward', 'startForward', 'startForward']);
});
test('conflicts are reported, including a learned D-pad button that matches a default', () => {
const config = withReviewBindings({
// A learned raw D-pad left (button 14) is the same input as the default "D-pad ◀".
showLater: { kind: 'combo', press: { kind: 'button', buttonIndex: 14 }, hold: null },
// R1 alone, while other actions hold R1.
play: { kind: 'combo', press: { kind: 'button', buttonIndex: 5 }, hold: null },
});
const issues = findControllerReviewBindingIssues(
config.reviewBindings,
getControllerReviewInputContext(config, 'standard'),
);
assert.equal(issues.showLater, 'Same as Earlier −2s');
assert.equal(issues.showEarlier, 'Same as Later +2s');
assert.equal(issues.play, 'R1 is also a hold button on this step');
});
@@ -0,0 +1,43 @@
import {
CONTROLLER_REVIEW_SCREEN,
type ControllerReviewStep,
} from '../../shared/controller-review';
import type { ControllerReviewAction, ResolvedControllerConfig } from '../../types';
import {
createControllerComboScreenLayout,
formatControllerScreenHints,
} from './controller-screen-layout.js';
import type { ControllerScreenLayout } from './gamepad-controller.js';
export { getControllerReviewInputContext } from './controller-screen-layout.js';
/** Every button on the media timing review screen, named by what it does. */
export type MediaTimingReviewControllerAction = ControllerReviewAction;
/** The review shows either the timing editor or the "Stop reviewing?" step. */
export type MediaTimingReviewStep = ControllerReviewStep;
/** Hint text per action for the review buttons, e.g. `A` or `L1 + ▶`; null when unbound. */
export function formatMediaTimingReviewControllerHints(
config: ResolvedControllerConfig,
mapping: string | null | undefined,
): Record<MediaTimingReviewControllerAction, string | null> {
return formatControllerScreenHints(
CONTROLLER_REVIEW_SCREEN,
config.reviewBindings,
config,
mapping,
);
}
export function createMediaTimingReviewControllerLayout(options: {
getStep: () => MediaTimingReviewStep;
dispatch: (action: MediaTimingReviewControllerAction) => void;
}): ControllerScreenLayout {
return createControllerComboScreenLayout({
screen: CONTROLLER_REVIEW_SCREEN,
getBindings: (config) => config.reviewBindings,
getStep: options.getStep,
dispatch: options.dispatch,
});
}