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

@@ -18,5 +18,7 @@ export function resolveControllerConfigForGamepad(
...config,
buttonIndices: profile.buttonIndices,
bindings: profile.bindings,
reviewBindings: profile.reviewBindings ?? config.reviewBindings,
fieldGroupingBindings: profile.fieldGroupingBindings ?? config.fieldGroupingBindings,
};
}
@@ -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,
});
}
+8 -5
View File
@@ -569,20 +569,20 @@
</div>
<div class="kiku-cards-container">
<div id="kikuCard1" class="kiku-card active" tabindex="0">
<div class="kiku-card-label">1 &mdash; Original Card</div>
<div id="kikuCard1Label" class="kiku-card-label">1 &mdash; Original Card</div>
<div class="kiku-card-expression" id="kikuCard1Expression"></div>
<div class="kiku-card-sentence" id="kikuCard1Sentence"></div>
<div class="kiku-card-meta" id="kikuCard1Meta"></div>
</div>
<div id="kikuCard2" class="kiku-card" tabindex="0">
<div class="kiku-card-label">2 &mdash; New Card</div>
<div id="kikuCard2Label" class="kiku-card-label">2 &mdash; New Card</div>
<div class="kiku-card-expression" id="kikuCard2Expression"></div>
<div class="kiku-card-sentence" id="kikuCard2Sentence"></div>
<div class="kiku-card-meta" id="kikuCard2Meta"></div>
</div>
</div>
<div class="kiku-footer">
<label class="kiku-delete-toggle">
<label id="kikuDeleteToggle" class="kiku-delete-toggle">
<input id="kikuDeleteDuplicate" type="checkbox" checked />
Delete duplicate card after merge
</label>
@@ -597,8 +597,11 @@
<div id="kikuPreviewStep" class="hidden">
<div class="kiku-preview-header">
<div class="kiku-preview-title">Final Merge Preview</div>
<div class="kiku-preview-toggle">
<div class="kiku-preview-title">
Final Merge Preview
<span id="kikuPreviewScrollHint" class="kiku-preview-scroll-hint">Scroll</span>
</div>
<div id="kikuPreviewToggle" class="kiku-preview-toggle">
<button id="kikuPreviewCompact" type="button">Compact</button>
<button id="kikuPreviewFull" type="button">Full</button>
</div>
@@ -1,7 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core';
import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping';
import { getDefaultControllerReviewBindings } from '../../shared/controller-review';
import { CONTROLLER_SCREENS } from '../../shared/controller-screens';
import type { ResolvedControllerConfig } from '../../types';
import type { ControllerScreenLearning } from './controller-config-form';
import { createControllerConfigForm } from './controller-config-form.js';
type ActionId = keyof ResolvedControllerConfig['bindings'];
@@ -91,7 +96,11 @@ function findOne(root: FakeElement, className: string, text?: string): FakeEleme
/** Renders the form into a fake container with `document` stubbed for the duration. */
function renderForm(
learningActionId: ActionId | null,
options: { bindings?: typeof BINDINGS; mapping?: string } = {},
options: {
bindings?: typeof BINDINGS;
mapping?: string;
faceButtonLabels?: 'xbox' | 'nintendo';
} = {},
) {
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', {
@@ -106,6 +115,7 @@ function renderForm(
container: container as unknown as HTMLElement,
getBindings: () => options.bindings ?? BINDINGS,
getControllerMapping: () => options.mapping,
getFaceButtonLabels: () => options.faceButtonLabels,
getLearningActionId: () => learningActionId,
getDpadLearningActionId: () => null,
onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`),
@@ -263,3 +273,213 @@ test('controller config form names buttons by the W3C standard layout for standa
assert.equal(badge(raw.row('Quit MPV')), 'Back / Select');
assert.equal(badge(raw.row('Previous Entry')), 'L3 / LS');
});
test('controller config form names face buttons Nintendo-style when configured', () => {
const badge = (row: FakeElement) => findOne(row, 'controller-config-badge').textContent;
const bindings: typeof BINDINGS = {
...BINDINGS,
toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: { kind: 'button', buttonIndex: 1 },
mineCard: { kind: 'button', buttonIndex: 2 },
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
};
const form = renderForm(null, { bindings, mapping: 'standard', faceButtonLabels: 'nintendo' });
assert.equal(badge(form.row('Toggle Lookup')), 'B');
assert.equal(badge(form.row('Close Lookup')), 'A');
assert.equal(badge(form.row('Mine Card')), 'Y');
assert.equal(badge(form.row('Toggle Keyboard-Only Mode')), 'X');
// Non-face buttons keep their names.
assert.equal(badge(form.row('Next Audio')), 'RB / R1');
});
type ReviewFormOptions = {
reviewBindings?: ResolvedControllerConfig['reviewBindings'];
fieldGroupingBindings?: ResolvedControllerConfig['fieldGroupingBindings'];
learning?: ControllerScreenLearning | null;
};
/** Renders the form with the dialog groups; `act` runs while `document` is still stubbed. */
function withReviewForm(
options: ReviewFormOptions,
act: (env: {
calls: string[];
container: FakeElement;
reviewRow: (label: string) => FakeElement;
}) => void,
): void {
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: { createElement: () => new FakeElement() },
});
try {
const calls: string[] = [];
const container = new FakeElement();
createControllerConfigForm({
container: container as unknown as HTMLElement,
getBindings: () => BINDINGS,
getLearningActionId: () => null,
getDpadLearningActionId: () => null,
onLearn: () => {},
onClear: () => {},
onReset: () => {},
onDpadLearn: () => {},
onDpadClear: () => {},
onDpadReset: () => {},
getScreenGroups: () => {
const bindings = {
reviewBindings: options.reviewBindings ?? getDefaultControllerReviewBindings(),
fieldGroupingBindings:
options.fieldGroupingBindings ?? getDefaultControllerFieldGroupingBindings(),
};
return CONTROLLER_SCREENS.map((screen) => ({
screen,
bindings: bindings[screen.configKey],
}));
},
getScreenInputContext: () => ({
mapping: 'standard',
faceButtonLabels: 'xbox',
buttonIndices: CORE_DEFAULT_CONFIG.controller.buttonIndices,
}),
getScreenLearning: () => options.learning ?? null,
onScreenLearn: (screenId, actionId) => calls.push(`learn:${screenId}:${actionId}`),
onScreenClear: (screenId, actionId) => calls.push(`clear:${screenId}:${actionId}`),
onScreenReset: (screenId, actionId) => calls.push(`reset:${screenId}:${actionId}`),
onScreenResetAll: (screenId) => calls.push(`resetAll:${screenId}`),
}).render();
const reviewRow = (label: string) => {
const match = findAll(container, 'controller-config-row').find(
(el) => findOne(el, 'controller-config-label').textContent === label,
);
assert.ok(match, `missing row "${label}"`);
return match;
};
act({ calls, container, reviewRow });
} finally {
if (previousDocument) {
Object.defineProperty(globalThis, 'document', previousDocument);
} else {
Reflect.deleteProperty(globalThis, 'document');
}
}
}
const badgeOf = (row: FakeElement) => findOne(row, 'controller-config-badge').textContent;
test('controller config form keeps the review group collapsed until opened', () => {
withReviewForm({}, ({ calls, container, reviewRow }) => {
const header = findOne(container, 'controller-config-group-toggle');
assert.match(header.textContent, /Review media timing/);
assert.equal(
findAll(container, 'controller-config-subgroup').length,
0,
'collapsed group shows no rows',
);
header.click();
assert.deepEqual(
findAll(container, 'controller-config-subgroup').map((el) => el.textContent),
['While editing', 'After Cancel'],
);
assert.equal(badgeOf(reviewRow('Use this timing')), 'A');
assert.equal(badgeOf(reviewRow('Earlier −2s')), 'D-pad ◀');
assert.equal(badgeOf(reviewRow('Start +0.1s')), 'L1 + D-pad ▶');
assert.equal(badgeOf(reviewRow('Reset screenshot frame')), 'L1 + R1');
assert.equal(badgeOf(reviewRow('Keep editing')), 'B');
findOne(container, 'controller-config-group-action', 'Reset all').click();
findOne(reviewRow('Start +0.1s'), 'controller-config-edit-icon').click();
findOne(reviewRow('Start +0.1s'), 'controller-config-reset-icon').click();
assert.deepEqual(calls, [
'resetAll:review',
'learn:review:startForward',
'reset:review:startForward',
]);
});
});
test('controller config form shows the hold button while a review combo is being learned', () => {
withReviewForm(
{ learning: { screenId: 'review', actionId: 'startForward', holdLabel: 'L2' } },
({ container, reviewRow }) => {
assert.equal(badgeOf(reviewRow('Start +0.1s')), 'L2 + …');
assert.match(
findOne(container, 'controller-config-edit-hint').textContent,
/Holding L2: press another button for a combo, or let go to use L2 alone/,
);
},
);
});
test('controller config form warns about review bindings that clash', () => {
const reviewBindings = {
...getDefaultControllerReviewBindings(),
play: {
kind: 'combo' as const,
press: { kind: 'face' as const, letter: 'a' as const },
hold: null,
},
};
withReviewForm({ reviewBindings }, ({ container, reviewRow }) => {
findOne(container, 'controller-config-group-toggle').click();
const warnings = findAll(container, 'controller-config-warning').map((el) => el.textContent);
assert.deepEqual(warnings, ['⚠ Same as Play selection', '⚠ Same as Use this timing']);
assert.equal(reviewRow('Play selection').classList.contains('has-warning'), true);
});
});
/** The dialog group headers, in order. */
const groupHeaders = (container: FakeElement) =>
findAll(container, 'controller-config-group-toggle');
test('controller config form lists the duplicate card layout as its own group', () => {
withReviewForm({}, ({ calls, container, reviewRow }) => {
const headers = groupHeaders(container);
assert.deepEqual(
headers.map((el) => el.textContent.replace(/Reset all$/, '')),
['▸ Review media timing', '▸ Duplicate card (field grouping)'],
);
headers[1]!.click();
assert.deepEqual(
findAll(container, 'controller-config-subgroup').map((el) => el.textContent),
['Choosing the card', 'Merge preview'],
'only the opened group shows rows',
);
assert.equal(badgeOf(reviewRow('Original card')), 'D-pad ◀');
assert.equal(badgeOf(reviewRow('New card')), 'D-pad ▶');
assert.equal(badgeOf(reviewRow('Delete duplicate on/off')), 'X');
assert.equal(badgeOf(reviewRow('Continue')), 'A');
assert.equal(badgeOf(reviewRow('Confirm merge')), 'A');
assert.equal(badgeOf(reviewRow('Back')), 'B');
assert.equal(badgeOf(reviewRow('Compact / Full')), 'Y');
assert.equal(badgeOf(reviewRow('Scroll down')), 'D-pad ▼');
// Cancel on the preview is unbound by default (B goes back, B again cancels).
const cancels = findAll(container, 'controller-config-row').filter(
(el) => findOne(el, 'controller-config-label').textContent === 'Cancel',
);
assert.deepEqual(cancels.map(badgeOf), ['B', 'None']);
assert.equal(findAll(container, 'controller-config-warning').length, 0);
findOne(container, 'controller-config-group-action', 'Reset all').click();
findOne(reviewRow('Continue'), 'controller-config-edit-icon').click();
assert.deepEqual(calls, ['resetAll:fieldGrouping', 'learn:fieldGrouping:continue']);
});
});
test('learning a duplicate card action opens only that group', () => {
withReviewForm(
{ learning: { screenId: 'fieldGrouping', actionId: 'back', holdLabel: null } },
({ container }) => {
const headers = groupHeaders(container);
assert.equal(headers[0]!.getAttribute('aria-expanded'), 'false');
assert.equal(headers[1]!.getAttribute('aria-expanded'), 'true');
assert.match(
findOne(container, 'controller-config-edit-hint').textContent,
/Press a button, or hold one and press another for a combo/,
);
},
);
});
+185 -3
View File
@@ -1,3 +1,26 @@
import {
findControllerScreenBindingIssues,
formatControllerReviewBinding,
type ControllerReviewInputContext,
type ControllerScreenActionDefinition,
type ControllerScreenBindings,
type ControllerScreenDefinition,
} from '../../shared/controller-review';
import type { ControllerScreenId } from '../../shared/controller-screens';
import type { ResolvedControllerReviewBinding } from '../../types';
/** A dialog layout shown as a collapsible group in Alt+C. */
export type ControllerScreenGroup = {
screen: ControllerScreenDefinition;
bindings: ControllerScreenBindings;
};
/** The dialog action being learned, and the hold button pressed so far, if any. */
export type ControllerScreenLearning = {
screenId: ControllerScreenId;
actionId: string;
holdLabel: string | null;
};
import type {
ControllerDpadFallback,
ResolvedControllerAxisBinding,
@@ -215,6 +238,10 @@ const W3C_STANDARD_AXIS_NAMES: Record<number, string> = {
};
type ControllerMapping = string | null | undefined;
type FaceButtonLabels = ResolvedControllerConfig['faceButtonLabels'] | undefined;
/** Nintendo-style pads print B at the bottom and A on the right; positions 0-3 are the same. */
const NINTENDO_FACE_BUTTON_NAMES: Record<number, string> = { 0: 'B', 1: 'A', 2: 'Y', 3: 'X' };
const DPAD_FALLBACK_LABELS: Record<ControllerDpadFallback, string> = {
none: 'None',
@@ -222,7 +249,14 @@ const DPAD_FALLBACK_LABELS: Record<ControllerDpadFallback, string> = {
vertical: 'D-pad \u2195',
};
export function getFriendlyButtonName(buttonIndex: number, mapping?: ControllerMapping): string {
export function getFriendlyButtonName(
buttonIndex: number,
mapping?: ControllerMapping,
faceButtonLabels?: FaceButtonLabels,
): string {
const nintendoName =
faceButtonLabels === 'nintendo' ? NINTENDO_FACE_BUTTON_NAMES[buttonIndex] : undefined;
if (nintendoName) return nintendoName;
const names = mapping === 'standard' ? W3C_STANDARD_BUTTON_NAMES : RAW_LAYOUT_BUTTON_NAMES;
return names[buttonIndex] ?? `Button ${buttonIndex}`;
}
@@ -261,13 +295,16 @@ function formatFriendlyStickLabel(
function formatFriendlyBindingLabel(
binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding,
mapping: ControllerMapping,
faceButtonLabels: FaceButtonLabels,
): string {
if (binding.kind === 'none') return 'None';
if ('direction' in binding) {
const name = getFriendlyAxisName(binding.axisIndex, mapping);
return `${name} ${binding.direction === 'positive' ? '+' : '\u2212'}`;
}
if ('buttonIndex' in binding) return getFriendlyButtonName(binding.buttonIndex, mapping);
if ('buttonIndex' in binding) {
return getFriendlyButtonName(binding.buttonIndex, mapping, faceButtonLabels);
}
return getFriendlyAxisName(binding.axisIndex, mapping);
}
@@ -287,8 +324,20 @@ export function createControllerConfigForm(options: {
onDpadReset: (actionId: ControllerBindingActionId) => void;
/** Gamepad API `mapping` of the controller being configured; picks the button names. */
getControllerMapping?: () => ControllerMapping;
/** `controller.faceButtonLabels`; names the face buttons as printed on the pad. */
getFaceButtonLabels?: () => FaceButtonLabels;
/** Dialog layouts (review, field grouping) of the controller being configured. */
getScreenGroups?: () => ControllerScreenGroup[];
getScreenInputContext?: () => ControllerReviewInputContext;
getScreenLearning?: () => ControllerScreenLearning | null;
onScreenLearn?: (screenId: ControllerScreenId, actionId: string) => void;
onScreenClear?: (screenId: ControllerScreenId, actionId: string) => void;
onScreenReset?: (screenId: ControllerScreenId, actionId: string) => void;
onScreenResetAll?: (screenId: ControllerScreenId) => void;
}) {
let expandedRowKey: ExpandedRowKey | null = null;
/** Dialog groups start collapsed; this holds the opened ones. */
const expandedScreens = new Set<ControllerScreenId>();
function render(): void {
options.container.innerHTML = '';
@@ -325,6 +374,127 @@ export function createControllerConfigForm(options: {
renderDiscreteRow(definition, binding, learningActionId);
}
}
const context = options.getScreenInputContext?.();
if (context) {
for (const group of options.getScreenGroups?.() ?? []) {
renderScreenGroup(group, context);
}
}
}
function renderScreenGroup(
{ screen, bindings }: ControllerScreenGroup,
context: ControllerReviewInputContext,
): void {
const learning = options.getScreenLearning?.() ?? null;
const learningHere = learning?.screenId === screen.id ? learning : null;
if (learningHere) {
expandedScreens.add(screen.id);
expandedRowKey = `${screen.id}:${learningHere.actionId}`;
}
const expanded = expandedScreens.has(screen.id);
const header = document.createElement('div');
header.className = 'controller-config-group controller-config-group-toggle';
header.setAttribute('role', 'button');
header.setAttribute('aria-expanded', String(expanded));
header.textContent = `${expanded ? '\u25be' : '\u25b8'} ${screen.title}`;
header.addEventListener('click', () => {
if (expanded) expandedScreens.delete(screen.id);
else expandedScreens.add(screen.id);
render();
});
if (expanded) {
const resetAll = document.createElement('button');
resetAll.type = 'button';
resetAll.className = 'btn-secondary controller-config-group-action';
resetAll.textContent = 'Reset all';
resetAll.addEventListener('click', (e) => {
e.stopPropagation();
options.onScreenResetAll?.(screen.id);
});
header.appendChild(resetAll);
}
options.container.appendChild(header);
if (!expanded) return;
const issues = findControllerScreenBindingIssues(screen, bindings, context);
for (const step of screen.steps) {
const subgroup = document.createElement('div');
subgroup.className = 'controller-config-subgroup';
subgroup.textContent = step.label;
options.container.appendChild(subgroup);
for (const action of screen.actions) {
if (action.step !== step.id) continue;
renderScreenRow(
screen,
action,
bindings[action.id] ?? { kind: 'none' },
context,
issues[action.id],
learningHere,
);
}
}
}
function renderScreenRow(
screen: ControllerScreenDefinition,
action: ControllerScreenActionDefinition,
binding: ResolvedControllerReviewBinding,
context: ControllerReviewInputContext,
issue: string | undefined,
learning: ControllerScreenLearning | null,
): void {
const rowKey = `${screen.id}:${action.id}`;
const isExpanded = expandedRowKey === rowKey;
const isLearning = learning?.actionId === action.id;
const holdLabel = isLearning ? learning.holdLabel : null;
const current = formatControllerReviewBinding(binding, context, { dpadPrefix: true });
const learn = (e: Event) => {
e.stopPropagation();
expandedRowKey = rowKey;
options.onScreenLearn?.(screen.id, action.id);
};
const reset = (e: Event) => {
e.stopPropagation();
options.onScreenReset?.(screen.id, action.id);
};
const row = createRow(
action.label,
holdLabel ? `${holdLabel} + \u2026` : current,
binding.kind === 'none',
isExpanded,
`Learn ${action.label}`,
learn,
`Reset ${action.label}`,
reset,
issue,
);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
render();
});
options.container.appendChild(row);
if (isExpanded) {
const hint = !isLearning
? `Currently: ${current}`
: holdLabel
? `Holding ${holdLabel}: press another button for a combo, or let go to use ${holdLabel} alone\u2026`
: 'Press a button, or hold one and press another for a combo\u2026';
const panel = createEditPanel(hint, isLearning, {
onLearn: learn,
onClear: (e) => {
e.stopPropagation();
options.onScreenClear?.(screen.id, action.id);
},
onReset: reset,
});
options.container.appendChild(panel);
}
}
function renderDiscreteRow(
@@ -338,7 +508,11 @@ export function createControllerConfigForm(options: {
const row = createRow(
definition.label,
formatFriendlyBindingLabel(binding, options.getControllerMapping?.()),
formatFriendlyBindingLabel(
binding,
options.getControllerMapping?.(),
options.getFaceButtonLabels?.(),
),
binding.kind === 'none',
isExpanded,
`Learn ${definition.label}`,
@@ -500,6 +674,7 @@ export function createControllerConfigForm(options: {
onEdit: (e: Event) => void,
resetLabel: string,
onReset: (e: Event) => void,
warningText?: string,
): HTMLDivElement {
const row = document.createElement('div');
row.className = 'controller-config-row';
@@ -508,6 +683,13 @@ export function createControllerConfigForm(options: {
const label = document.createElement('div');
label.className = 'controller-config-label';
label.textContent = labelText;
if (warningText) {
row.classList.add('has-warning');
const warning = document.createElement('div');
warning.className = 'controller-config-warning';
warning.textContent = `\u26a0 ${warningText}`;
label.appendChild(warning);
}
const right = document.createElement('div');
right.className = 'controller-config-right';
@@ -3,6 +3,8 @@ import test from 'node:test';
import { createRendererState } from '../state.js';
import { createControllerDebugModal } from './controller-debug.js';
import { getDefaultControllerReviewBindings } from '../../shared/controller-review';
import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
@@ -46,6 +48,7 @@ test('controller debug modal renders active controller axes, buttons, and config
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
faceButtonLabels: 'xbox',
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
@@ -79,6 +82,8 @@ test('controller debug modal renders active controller axes, buttons, and config
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
reviewBindings: getDefaultControllerReviewBindings(),
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
profiles: {},
};
@@ -164,6 +169,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
faceButtonLabels: 'xbox',
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
@@ -197,6 +203,8 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
reviewBindings: getDefaultControllerReviewBindings(),
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
profiles: {},
};
@@ -281,6 +289,7 @@ test('controller debug modal stays closed and notifies when controller support i
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
faceButtonLabels: 'xbox',
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
@@ -314,6 +323,8 @@ test('controller debug modal stays closed and notifies when controller support i
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
reviewBindings: getDefaultControllerReviewBindings(),
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
profiles: {},
};
const ctx = {
@@ -3,6 +3,8 @@ import test from 'node:test';
import { createRendererState } from '../state.js';
import { createControllerSelectModal } from './controller-select.js';
import { getDefaultControllerReviewBindings } from '../../shared/controller-review';
import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
@@ -128,6 +130,7 @@ function buildContext() {
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
faceButtonLabels: 'xbox',
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
@@ -161,6 +164,8 @@ function buildContext() {
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
reviewBindings: getDefaultControllerReviewBindings(),
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
profiles: {},
};
state.connectedGamepads = [
@@ -328,6 +333,8 @@ test('controller select modal reset control stores the default binding in the se
...state.controllerConfig.bindings,
toggleLookup: { kind: 'button', buttonIndex: 11 },
},
reviewBindings: state.controllerConfig.reviewBindings,
fieldGroupingBindings: state.controllerConfig.fieldGroupingBindings,
},
};
}
@@ -442,6 +449,8 @@ test('controller select modal learn mode falls back to global bindings without a
try {
const { state, dom } = buildContext();
state.connectedGamepads = [];
// No controller visible and none saved: edits go to the global bindings.
state.controllerConfig!.preferredGamepadId = '';
state.activeGamepadId = null;
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
@@ -617,3 +626,274 @@ test('controller select modal uses unique picker values for duplicate controller
domHandle.restore();
}
});
function findByClass(root: any, className: string, text?: string): any {
for (const child of root.children ?? []) {
const classes = String(child.className ?? '').split(' ');
if (classes.includes(className) && (text === undefined || child.textContent === text)) {
return child;
}
const nested = findByClass(child, className, text);
if (nested) return nested;
}
return null;
}
function findReviewRow(root: any, label: string): any {
for (const child of root.children ?? []) {
if (String(child.className).split(' ').includes('controller-config-row')) {
if (child.children[0]?.textContent === label) return child;
}
}
return null;
}
test('controller select modal learns a review combo and saves it to the controller profile', async () => {
const domHandle = installFakeDom();
const saved: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerConfig: async (update: unknown) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {},
},
},
});
try {
const { state, dom } = buildContext();
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
const header = findByClass(dom.controllerConfigList, 'controller-config-group-toggle');
assert.ok(header, 'review group header');
header.dispatch('click');
const row = findReviewRow(dom.controllerConfigList, 'Start +0.1s');
assert.ok(row, 'Start +0.1s row');
row.children[1].children[0].dispatch('click');
const buttons = (pressed: number[]) =>
Array.from({ length: 17 }, (_, index) => ({
value: pressed.includes(index) ? 1 : 0,
pressed: pressed.includes(index),
touched: pressed.includes(index),
}));
// Hold L2, then press A.
state.controllerRawButtons = buttons([6]);
modal.updateDevices();
assert.equal(
findReviewRow(dom.controllerConfigList, 'Start +0.1s').children[1].children[0].textContent,
'L2 + …',
);
state.controllerRawButtons = buttons([6, 0]);
modal.updateDevices();
await Promise.resolve();
assert.deepEqual(saved.at(-1), {
profiles: {
'pad-1': {
reviewBindings: {
startForward: {
kind: 'combo',
press: { kind: 'button', buttonIndex: 0 },
hold: { kind: 'button', buttonIndex: 6 },
},
},
},
},
});
assert.deepEqual(state.controllerConfig?.profiles['pad-1']?.reviewBindings.startForward, {
kind: 'combo',
press: { kind: 'button', buttonIndex: 0 },
hold: { kind: 'button', buttonIndex: 6 },
});
} finally {
domHandle.restore();
}
});
function findAllByClass(root: any, className: string, found: any[] = []): any[] {
for (const child of root.children ?? []) {
if (
String(child.className ?? '')
.split(' ')
.includes(className)
)
found.push(child);
findAllByClass(child, className, found);
}
return found;
}
test('controller select modal learns a duplicate card button and saves it to the profile', async () => {
const domHandle = installFakeDom();
const saved: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerConfig: async (update: unknown) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {},
},
},
});
try {
const { state, dom } = buildContext();
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
const headers = findAllByClass(dom.controllerConfigList, 'controller-config-group-toggle');
assert.equal(headers.length, 2, 'review and duplicate card groups');
headers[1].dispatch('click');
const row = findReviewRow(dom.controllerConfigList, 'Back');
assert.ok(row, 'Back row');
row.children[1].children[0].dispatch('click');
const buttons = (pressed: number[]) =>
Array.from({ length: 17 }, (_, index) => ({
value: pressed.includes(index) ? 1 : 0,
pressed: pressed.includes(index),
touched: pressed.includes(index),
}));
// Press and release Select.
state.controllerRawButtons = buttons([8]);
modal.updateDevices();
state.controllerRawButtons = buttons([]);
modal.updateDevices();
await Promise.resolve();
const back = { kind: 'combo', press: { kind: 'button', buttonIndex: 8 }, hold: null };
assert.deepEqual(saved.at(-1), {
profiles: { 'pad-1': { fieldGroupingBindings: { back } } },
});
const profile = state.controllerConfig?.profiles['pad-1'];
assert.deepEqual(profile?.fieldGroupingBindings.back, back);
// Other dialogs keep their layout.
assert.deepEqual(profile?.reviewBindings, state.controllerConfig?.reviewBindings);
} finally {
domHandle.restore();
}
});
test('controller select modal shows and edits the saved controller before this window sees it', async () => {
const domHandle = installFakeDom();
const saved: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerConfig: async (update: unknown) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {},
},
},
});
try {
const { state, dom } = buildContext();
// A fresh Alt+C window has not seen the pad yet (no button pressed in it).
state.connectedGamepads = [];
state.activeGamepadId = null;
const config = state.controllerConfig!;
config.profiles = {
'pad-1': {
label: 'pad-1',
buttonIndices: config.buttonIndices,
bindings: config.bindings,
fieldGroupingBindings: config.fieldGroupingBindings,
reviewBindings: {
...config.reviewBindings,
startBack: {
kind: 'combo',
press: { kind: 'button', buttonIndex: 15 },
hold: { kind: 'button', buttonIndex: 6 },
},
},
},
};
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
findByClass(dom.controllerConfigList, 'controller-config-group-toggle').dispatch('click');
const row = findReviewRow(dom.controllerConfigList, 'Start \u22120.1s');
assert.equal(row.children[1].children[0].textContent, 'L2 + D-pad \u25b6');
const waiting = 'Press any button on your controller so this window can see it.';
assert.equal(dom.controllerSelectStatus.textContent, waiting);
// Controller polling keeps publishing snapshots while no pad is visible.
modal.updateDevices();
modal.updateDevices();
assert.equal(dom.controllerSelectStatus.textContent, waiting, 'the prompt stays');
// Reset goes to the saved controller's profile, not the global layout.
row.children[1].children[1].dispatch('click');
await Promise.resolve();
assert.deepEqual(saved.at(-1), {
profiles: {
'pad-1': {
reviewBindings: {
startBack: {
kind: 'combo',
press: { kind: 'dpad', direction: 'left' },
hold: { kind: 'button', buttonIndex: 4 },
},
},
},
},
});
await new Promise((resolve) => setTimeout(resolve, 0));
const afterReset = dom.controllerSelectStatus.textContent;
assert.equal(afterReset, 'Start \u22120.1s reset.');
modal.updateDevices();
assert.equal(dom.controllerSelectStatus.textContent, afterReset, 'the reset message stays');
// The pad appears, then disappears again.
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-1';
modal.updateDevices();
state.connectedGamepads = [];
state.activeGamepadId = null;
modal.updateDevices();
assert.equal(dom.controllerSelectStatus.textContent, waiting);
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-1';
modal.updateDevices();
assert.equal(
dom.controllerSelectStatus.textContent,
'Choose a controller or click Learn to remap an action.',
);
} finally {
domHandle.restore();
}
});
+208 -9
View File
@@ -1,6 +1,20 @@
import type { ModalStateReader, RendererContext } from '../context';
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
import { createControllerComboCapture } from '../handlers/controller-combo-capture.js';
import {
formatControllerReviewInput,
getControllerScreenAction,
getDefaultControllerScreenBindings,
type ControllerReviewInputContext,
type ControllerScreenBindings,
type ControllerScreenDefinition,
} from '../../shared/controller-review';
import {
CONTROLLER_SCREENS,
getControllerScreen,
type ControllerScreenBindingsKey,
} from '../../shared/controller-screens';
import {
createControllerConfigForm,
getControllerBindingDefinition,
@@ -20,6 +34,11 @@ function clampSelectedIndex(ctx: RendererContext): void {
);
}
const NO_CONTROLLER_STATUS = 'No controllers detected.';
const WAITING_FOR_SAVED_CONTROLLER_STATUS =
'Press any button on your controller so this window can see it.';
const NO_CONTROLLER_STATUSES = new Set([NO_CONTROLLER_STATUS, WAITING_FOR_SAVED_CONTROLLER_STATUS]);
export function createControllerSelectModal(
ctx: RendererContext,
options: {
@@ -39,13 +58,24 @@ export function createControllerSelectModal(
let learningActionId: ControllerBindingKey | null = null;
let dpadLearningActionId: ControllerBindingKey | null = null;
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
let screenLearning: {
screen: ControllerScreenDefinition;
actionId: string;
capture: ReturnType<typeof createControllerComboCapture>;
holdLabel: string | null;
} | null = null;
function getSelectedController() {
return ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex] ?? null;
}
/**
* The controller whose profile Alt+C shows and edits. A freshly opened window cannot see
* any pad until a button is pressed in it, so until then use the saved (preferred)
* controller rather than the global defaults it does not actually use.
*/
function getSelectedControllerId(): string | null {
return getSelectedController()?.id ?? null;
return getSelectedController()?.id ?? (ctx.state.controllerConfig?.preferredGamepadId || null);
}
function getSelectedControllerConfig() {
@@ -54,9 +84,39 @@ export function createControllerSelectModal(
return resolveControllerConfigForGamepad(config, getSelectedControllerId());
}
function getScreenInputContext(): ControllerReviewInputContext {
const config = getSelectedControllerConfig();
return {
mapping: getSelectedController()?.mapping ?? 'standard',
faceButtonLabels: config?.faceButtonLabels ?? 'xbox',
buttonIndices: config?.buttonIndices ?? {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
};
}
function cancelScreenLearning(): void {
screenLearning = null;
}
function actionLabel(screen: ControllerScreenDefinition, actionId: string): string {
return getControllerScreenAction(screen, actionId).label;
}
const controllerConfigForm = createControllerConfigForm({
container: ctx.dom.controllerConfigList,
getControllerMapping: () => getSelectedController()?.mapping ?? null,
getFaceButtonLabels: () => ctx.state.controllerConfig?.faceButtonLabels,
getBindings: () =>
getSelectedControllerConfig()?.bindings ?? {
toggleLookup: { kind: 'button', buttonIndex: 0 },
@@ -76,12 +136,71 @@ export function createControllerSelectModal(
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
getScreenGroups: () => {
const config = getSelectedControllerConfig();
if (!config) return [];
return CONTROLLER_SCREENS.map((screen) => ({
screen,
bindings: config[screen.configKey] as ControllerScreenBindings,
}));
},
getScreenInputContext,
getScreenLearning: () =>
screenLearning
? {
screenId: screenLearning.screen.id,
actionId: screenLearning.actionId,
holdLabel: screenLearning.holdLabel,
}
: null,
onScreenLearn: (screenId, actionId) => {
learningActionId = null;
dpadLearningActionId = null;
bindingCapture = null;
const config = ctx.state.controllerConfig;
const capture = createControllerComboCapture({
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
stickDeadzone: config?.stickDeadzone ?? 0.2,
});
capture.arm({ axes: ctx.state.controllerRawAxes, buttons: ctx.state.controllerRawButtons });
const screen = getControllerScreen(screenId);
screenLearning = { screen, actionId, capture, holdLabel: null };
controllerConfigForm.render();
setStatus(
`Press a button for ${actionLabel(screen, actionId)}, or hold one and press another for a combo.`,
);
},
onScreenClear: (screenId, actionId) => {
const screen = getControllerScreen(screenId);
void saveScreenBindings(
screen,
{ [actionId]: { kind: 'none' } },
`${actionLabel(screen, actionId)} cleared.`,
);
},
onScreenReset: (screenId, actionId) => {
const screen = getControllerScreen(screenId);
void saveScreenBindings(
screen,
{ [actionId]: structuredClone(screen.defaults[actionId]!) },
`${actionLabel(screen, actionId)} reset.`,
);
},
onScreenResetAll: (screenId) => {
const screen = getControllerScreen(screenId);
void saveScreenBindings(
screen,
getDefaultControllerScreenBindings(screen),
`${screen.title} layout reset.`,
);
},
getLearningActionId: () => learningActionId,
getDpadLearningActionId: () => dpadLearningActionId,
onLearn: (actionId, bindingType) => {
const definition = getControllerBindingDefinition(actionId);
if (!definition) return;
dpadLearningActionId = null;
cancelScreenLearning();
const config = ctx.state.controllerConfig;
bindingCapture = createControllerBindingCapture({
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
@@ -122,6 +241,7 @@ export function createControllerSelectModal(
const definition = getControllerBindingDefinition(actionId);
if (!definition) return;
learningActionId = null;
cancelScreenLearning();
const config = ctx.state.controllerConfig;
bindingCapture = createControllerBindingCapture({
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
@@ -236,6 +356,15 @@ export function createControllerSelectModal(
...update.bindings,
} as typeof ctx.state.controllerConfig.bindings;
}
const config = ctx.state.controllerConfig;
for (const { configKey } of CONTROLLER_SCREENS) {
if (update[configKey]) {
(config as Record<ControllerScreenBindingsKey, unknown>)[configKey] = {
...config[configKey],
...update[configKey],
};
}
}
if (update.profiles) {
ctx.state.controllerConfig.profiles = ctx.state.controllerConfig.profiles ?? {};
for (const [profileId, profileUpdate] of Object.entries(update.profiles)) {
@@ -244,6 +373,8 @@ export function createControllerSelectModal(
label: profileUpdate.label ?? profileId,
buttonIndices: ctx.state.controllerConfig.buttonIndices,
bindings: ctx.state.controllerConfig.bindings,
reviewBindings: ctx.state.controllerConfig.reviewBindings,
fieldGroupingBindings: ctx.state.controllerConfig.fieldGroupingBindings,
};
ctx.state.controllerConfig.profiles[profileId] = {
label: profileUpdate.label ?? baseProfile.label,
@@ -255,6 +386,15 @@ export function createControllerSelectModal(
...baseProfile.bindings,
...(profileUpdate.bindings ?? {}),
},
reviewBindings: {
...(baseProfile.reviewBindings ?? ctx.state.controllerConfig.reviewBindings),
...(profileUpdate.reviewBindings ?? {}),
},
fieldGroupingBindings: {
...(baseProfile.fieldGroupingBindings ??
ctx.state.controllerConfig.fieldGroupingBindings),
...(profileUpdate.fieldGroupingBindings ?? {}),
},
} as (typeof ctx.state.controllerConfig.profiles)[string];
}
}
@@ -264,8 +404,8 @@ export function createControllerSelectModal(
actionId: ControllerBindingKey,
binding: ControllerBindingValue,
): Parameters<typeof window.electronAPI.saveControllerConfig>[0] {
const selected = getSelectedController();
if (!selected) {
const profileId = getSelectedControllerId();
if (!profileId) {
return {
bindings: {
[actionId]: binding,
@@ -274,7 +414,7 @@ export function createControllerSelectModal(
}
return {
profiles: {
[selected.id]: {
[profileId]: {
bindings: {
[actionId]: binding,
},
@@ -301,6 +441,24 @@ export function createControllerSelectModal(
}
}
async function saveScreenBindings(
screen: ControllerScreenDefinition,
bindings: Partial<ControllerScreenBindings>,
message: string,
): Promise<void> {
const profileId = getSelectedControllerId();
const patch = { [screen.configKey]: bindings };
try {
await saveControllerConfig(profileId ? { profiles: { [profileId]: patch } } : patch);
cancelScreenLearning();
controllerConfigForm.render();
setStatus(message);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus(`Failed to save ${screen.title} binding: ${message}`, true);
}
}
async function saveDpadFallback(
actionId: ControllerBindingKey,
dpadFallback: import('../../types').ControllerDpadFallback,
@@ -376,6 +534,30 @@ export function createControllerSelectModal(
}
}
if (screenLearning) {
const learning = screenLearning;
const state = learning.capture.poll({
axes: ctx.state.controllerRawAxes,
buttons: ctx.state.controllerRawButtons,
});
if (state.status === 'holding') {
const holdLabel = formatControllerReviewInput(state.hold, getScreenInputContext(), {
dpadPrefix: true,
});
if (holdLabel !== learning.holdLabel) {
learning.holdLabel = holdLabel;
controllerConfigForm.render();
}
} else if (state.status === 'done') {
screenLearning = null;
void saveScreenBindings(
learning.screen,
{ [learning.actionId]: state.binding },
`${actionLabel(learning.screen, learning.actionId)} updated.`,
);
}
}
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
const shouldRender =
getDevicesKey() !== lastRenderedDevicesKey ||
@@ -386,11 +568,26 @@ export function createControllerSelectModal(
controllerConfigForm.render();
}
if (ctx.state.connectedGamepads.length === 0 && !learningActionId && !dpadLearningActionId) {
setStatus('No controllers detected.');
// Polling publishes a snapshot every frame, pad or not: only a change in the devices
// sets the no-pad status, so it neither replaces the prompt nor a message like "Reset".
const idle = !learningActionId && !dpadLearningActionId && !screenLearning;
if (ctx.state.connectedGamepads.length === 0) {
if (idle && shouldRender) setStatus(getNoControllerStatus());
} else if (
idle &&
NO_CONTROLLER_STATUSES.has(ctx.dom.controllerSelectStatus.textContent ?? '')
) {
setStatus('Choose a controller or click Learn to remap an action.');
}
}
/** A saved controller is only waiting for a press; without one there is nothing to show. */
function getNoControllerStatus(): string {
return ctx.state.controllerConfig?.preferredGamepadId
? WAITING_FOR_SAVED_CONTROLLER_STATUS
: NO_CONTROLLER_STATUS;
}
function openControllerSelectModal(): boolean {
if (ctx.state.controllerConfig?.enabled !== true) {
options.notifyControllerDisabled();
@@ -407,7 +604,7 @@ export function createControllerSelectModal(
renderPicker();
controllerConfigForm.render();
if (ctx.state.connectedGamepads.length === 0) {
setStatus('No controllers detected.');
setStatus(getNoControllerStatus());
} else {
setStatus('Choose a controller or click Learn to remap an action.');
}
@@ -419,6 +616,7 @@ export function createControllerSelectModal(
learningActionId = null;
dpadLearningActionId = null;
bindingCapture = null;
cancelScreenLearning();
ctx.state.controllerSelectModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.controllerSelectModal.classList.add('hidden');
@@ -432,10 +630,11 @@ export function createControllerSelectModal(
function handleControllerSelectKeydown(event: KeyboardEvent): boolean {
if (event.key === 'Escape') {
event.preventDefault();
if (learningActionId || dpadLearningActionId) {
if (learningActionId || dpadLearningActionId || screenLearning) {
learningActionId = null;
dpadLearningActionId = null;
bindingCapture = null;
cancelScreenLearning();
controllerConfigForm.render();
setStatus('Controller learn mode cancelled.');
return true;
@@ -472,7 +671,7 @@ export function createControllerSelectModal(
return true;
}
if (event.key === 'Enter' && !learningActionId && !dpadLearningActionId) {
if (event.key === 'Enter' && !learningActionId && !dpadLearningActionId && !screenLearning) {
event.preventDefault();
void saveSelectedController();
return true;
+175
View File
@@ -0,0 +1,175 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../../types';
import { createKikuModal, formatKikuScrollHint, KIKU_CONTROLLER_SCROLL_STEP_PX } from './kiku';
class FakeClassList {
private readonly names = new Set<string>();
add(name: string): void {
this.names.add(name);
}
remove(name: string): void {
this.names.delete(name);
}
toggle(name: string, force?: boolean): void {
if (force ?? !this.names.has(name)) this.names.add(name);
else this.names.delete(name);
}
contains(name: string): boolean {
return this.names.has(name);
}
}
class FakeElement {
classList = new FakeClassList();
dataset: Record<string, string | undefined> = {};
textContent = '';
checked = false;
disabled = false;
scrollTop = 0;
setAttribute(): void {}
addEventListener(): void {}
}
const CARD = (noteId: number): KikuDuplicateCardInfo => ({
noteId,
expression: '猫',
sentencePreview: '',
hasAudio: true,
hasImage: false,
isOriginal: noteId === 1,
});
function setup() {
const dom = new Proxy({} as Record<string, FakeElement>, {
get: (target, key: string) => (target[key] ??= new FakeElement()),
});
const state = {
kikuModalOpen: false,
kikuSelectedCard: 1 as 1 | 2,
kikuOriginalData: null,
kikuDuplicateData: null,
kikuPendingChoice: null,
kikuPreviewCompactData: null,
kikuPreviewFullData: null,
kikuPreviewMode: 'compact' as 'compact' | 'full',
kikuModalStep: 'select' as 'select' | 'preview',
isOverSubtitle: false,
};
const responses: KikuFieldGroupingChoice[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
kikuBuildMergePreview: async () => ({ ok: true, compact: { a: 1 }, full: { a: 1, b: 2 } }),
kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => responses.push(choice),
notifyOverlayModalClosed: () => {},
},
},
});
const modal = createKikuModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openKikuFieldGroupingModal({ original: CARD(1), duplicate: CARD(2) });
return {
dom,
state,
modal,
responses,
restore: () => {
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
},
};
}
test('controller actions walk the duplicate card dialog like the keyboard does', async () => {
const { dom, state, modal, responses, restore } = setup();
try {
assert.equal(modal.getControllerStep(), 'select');
assert.equal(modal.handleControllerAction('selectNew'), true);
assert.equal(state.kikuSelectedCard, 2);
assert.equal(dom.kikuCard2!.classList.contains('active'), true);
assert.equal(dom.kikuDeleteDuplicateCheckbox!.checked, true);
modal.handleControllerAction('toggleDeleteDuplicate');
assert.equal(dom.kikuDeleteDuplicateCheckbox!.checked, false);
// Preview-only actions do nothing on the first step.
assert.equal(modal.handleControllerAction('confirmMerge'), false);
modal.handleControllerAction('continue');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(modal.getControllerStep(), 'preview');
modal.handleControllerAction('scrollDown');
modal.handleControllerAction('scrollDown');
modal.handleControllerAction('scrollUp');
assert.equal(dom.kikuPreviewJson!.scrollTop, KIKU_CONTROLLER_SCROLL_STEP_PX);
modal.handleControllerAction('toggleView');
assert.equal(state.kikuPreviewMode, 'full');
assert.equal(modal.handleControllerAction('cancel'), false, 'first-step Cancel is hidden');
modal.handleControllerAction('back');
assert.equal(modal.getControllerStep(), 'select');
modal.handleControllerAction('continue');
await new Promise((resolve) => setTimeout(resolve, 0));
modal.handleControllerAction('confirmMerge');
assert.deepEqual(responses, [
{ keepNoteId: 2, deleteNoteId: 1, deleteDuplicate: false, cancelled: false },
]);
assert.equal(state.kikuModalOpen, false);
assert.equal(modal.handleControllerAction('continue'), false, 'closed dialog ignores input');
} finally {
restore();
}
});
test('Continue is ignored while the merge preview is being built', () => {
const { dom, modal, restore } = setup();
try {
dom.kikuConfirmButton!.disabled = true;
assert.equal(modal.handleControllerAction('continue'), false);
} finally {
restore();
}
});
test('controller hint tags land on the cards, buttons and preview header', () => {
const { dom, modal, restore } = setup();
try {
modal.setControllerHints({
selectOriginal: '◀',
selectNew: '▶',
toggleDeleteDuplicate: 'X',
continue: 'A',
cancel: 'B',
confirmMerge: 'A',
back: 'B',
cancelMerge: null,
toggleView: 'Y',
scrollUp: '▲',
scrollDown: '▼',
});
assert.equal(dom.kikuCard1Label!.dataset.controllerHint, '◀');
assert.equal(dom.kikuCard2Label!.dataset.controllerHint, '▶');
assert.equal(dom.kikuDeleteToggle!.dataset.controllerHint, 'X');
assert.equal(dom.kikuConfirmButton!.dataset.controllerHint, 'A');
assert.equal(dom.kikuBackButton!.dataset.controllerHint, 'B');
assert.equal(dom.kikuFinalCancelButton!.dataset.controllerHint, undefined);
assert.equal(dom.kikuPreviewToggle!.dataset.controllerHint, 'Y');
assert.equal(dom.kikuPreviewScrollHint!.dataset.controllerHint, '▲ ▼');
modal.setControllerHintsVisible(true);
assert.equal(dom.kikuModal!.classList.contains('controller-hints-visible'), true);
} finally {
restore();
}
});
test('the scroll tag shows whichever directions are bound', () => {
assert.equal(formatKikuScrollHint('▲', '▼'), '▲ ▼');
assert.equal(formatKikuScrollHint(null, 'RS ▼'), 'RS ▼');
assert.equal(formatKikuScrollHint(null, null), null);
});
+115
View File
@@ -1,9 +1,48 @@
import type { ControllerFieldGroupingStep } from '../../shared/controller-field-grouping';
import type {
ControllerFieldGroupingAction,
KikuDuplicateCardInfo,
KikuFieldGroupingChoice,
KikuMergePreviewResponse,
} from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
import type { RendererDom } from '../utils/dom';
/** Pixels the merge preview moves per controller scroll step (repeats while held). */
export const KIKU_CONTROLLER_SCROLL_STEP_PX = 48;
/**
* Where each controller action's hint tag is drawn. The two scroll actions share one
* "Scroll" tag in the preview header instead.
*/
export const KIKU_CONTROLLER_HINT_TARGETS: Record<
Exclude<ControllerFieldGroupingAction, 'scrollUp' | 'scrollDown'>,
keyof RendererDom
> = {
selectOriginal: 'kikuCard1Label',
selectNew: 'kikuCard2Label',
toggleDeleteDuplicate: 'kikuDeleteToggle',
continue: 'kikuConfirmButton',
cancel: 'kikuCancelButton',
confirmMerge: 'kikuFinalConfirmButton',
back: 'kikuBackButton',
cancelMerge: 'kikuFinalCancelButton',
toggleView: 'kikuPreviewToggle',
};
/** One tag for both scroll directions, e.g. `▲ ▼`; null when neither is bound. */
export function formatKikuScrollHint(up: string | null, down: string | null): string | null {
if (up && down) return `${up} ${down}`;
return up ?? down ?? null;
}
function setHint(element: HTMLElement, hint: string | null | undefined): void {
if (hint === null || hint === undefined) {
if (element.dataset.controllerHint !== undefined) delete element.dataset.controllerHint;
} else if (element.dataset.controllerHint !== hint) {
element.dataset.controllerHint = hint;
}
}
export function createKikuModal(
ctx: RendererContext,
@@ -176,6 +215,78 @@ export function createKikuModal(
setKikuModalStep('select');
}
function getControllerStep(): ControllerFieldGroupingStep {
return ctx.state.kikuModalStep;
}
function selectCard(card: 1 | 2): void {
ctx.state.kikuSelectedCard = card;
updateKikuCardSelection();
}
/** Runs a controller action with the same guards as the matching click or key. */
function handleControllerAction(action: ControllerFieldGroupingAction): boolean {
if (!ctx.state.kikuModalOpen) return false;
const onPreview = ctx.state.kikuModalStep === 'preview';
switch (action) {
case 'selectOriginal':
case 'selectNew':
if (onPreview) return false;
selectCard(action === 'selectOriginal' ? 1 : 2);
return true;
case 'toggleDeleteDuplicate':
if (onPreview) return false;
ctx.dom.kikuDeleteDuplicateCheckbox.checked = !ctx.dom.kikuDeleteDuplicateCheckbox.checked;
return true;
case 'continue':
// Disabled while the merge preview is being built.
if (onPreview || ctx.dom.kikuConfirmButton.disabled) return false;
void confirmKikuSelection();
return true;
case 'cancel':
case 'cancelMerge':
if (onPreview !== (action === 'cancelMerge')) return false;
cancelKikuFieldGrouping();
return true;
case 'confirmMerge':
if (!onPreview) return false;
confirmKikuMerge();
return true;
case 'back':
if (!onPreview) return false;
goBackFromKikuPreview();
return true;
case 'toggleView':
if (!onPreview) return false;
ctx.state.kikuPreviewMode = ctx.state.kikuPreviewMode === 'compact' ? 'full' : 'compact';
renderKikuPreview();
return true;
case 'scrollUp':
case 'scrollDown':
if (!onPreview) return false;
ctx.dom.kikuPreviewJson.scrollTop +=
action === 'scrollUp' ? -KIKU_CONTROLLER_SCROLL_STEP_PX : KIKU_CONTROLLER_SCROLL_STEP_PX;
return true;
}
}
/**
* Hint tags are drawn by CSS from `data-controller-hint`. Unbound actions show no tag;
* only changed attributes are written, since this runs every frame.
*/
function setControllerHints(hints: Record<ControllerFieldGroupingAction, string | null>): void {
for (const [action, domKey] of Object.entries(KIKU_CONTROLLER_HINT_TARGETS) as Array<
[keyof typeof KIKU_CONTROLLER_HINT_TARGETS, keyof RendererDom]
>) {
setHint(ctx.dom[domKey] as HTMLElement, hints[action]);
}
setHint(ctx.dom.kikuPreviewScrollHint, formatKikuScrollHint(hints.scrollUp, hints.scrollDown));
}
function setControllerHintsVisible(visible: boolean): void {
ctx.dom.kikuModal.classList.toggle('controller-hints-visible', visible);
}
function cancelKikuFieldGrouping(): void {
const choice: KikuFieldGroupingChoice = {
keepNoteId: 0,
@@ -292,7 +403,11 @@ export function createKikuModal(
return {
cancelKikuFieldGrouping,
closeKikuFieldGroupingModal,
getControllerStep,
handleControllerAction,
handleKikuKeydown,
setControllerHints,
setControllerHintsVisible,
openKikuFieldGroupingModal,
wireDomEvents,
};
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { CONTROLLER_REVIEW_ACTIONS } from '../../shared/controller-review';
import {
MEDIA_TIMING_REVIEW_CONTROLLER_BUTTONS,
isMediaTimingReviewButtonAvailable,
buildMediaTimingLineSelection,
buildMediaTimingWaveformPath,
constrainMediaTimingSelection,
@@ -155,3 +159,21 @@ test('trailing silence trim follows the last speech slice and keeps cut-off or s
null,
);
});
test('every controller action presses a real button on the review screen', () => {
const html = readFileSync('src/renderer/index.html', 'utf8');
const actions = new Set<string>(CONTROLLER_REVIEW_ACTIONS.map((action) => action.id));
assert.deepEqual(new Set(Object.keys(MEDIA_TIMING_REVIEW_CONTROLLER_BUTTONS)), actions);
for (const domId of Object.values(MEDIA_TIMING_REVIEW_CONTROLLER_BUTTONS)) {
assert.match(html, new RegExp(`<button\\s+id="${domId}"`), `missing <button id="${domId}">`);
}
});
test('controller presses skip disabled buttons and buttons on the hidden step', () => {
const button = (disabled: boolean, insideHidden: boolean) =>
({ disabled, closest: () => (insideHidden ? {} : null) }) as unknown as HTMLButtonElement;
assert.equal(isMediaTimingReviewButtonAvailable(button(false, false)), true);
assert.equal(isMediaTimingReviewButtonAvailable(button(true, false)), false);
assert.equal(isMediaTimingReviewButtonAvailable(button(false, true)), false);
});
@@ -6,6 +6,11 @@ import type {
MediaTimingReviewPreviewPosition,
} from '../../types/anki';
import type { ModalStateReader, RendererContext } from '../context';
import type {
MediaTimingReviewControllerAction,
MediaTimingReviewStep,
} from '../handlers/media-timing-review-controller.js';
import type { RendererDom } from '../utils/dom';
import { createModalFocusGuard } from './modal-focus-guard';
const MINIMUM_CLIP_SECONDS = 0.1;
@@ -24,6 +29,43 @@ const MINIMUM_TRAILING_TRIM_SECONDS = 0.1;
/** Allow buffering and output-device delays without cutting off an advancing preview. */
const PREVIEW_STALL_TIMEOUT_MS = 15_000;
type ButtonDomKey = {
[Key in keyof RendererDom]: RendererDom[Key] extends HTMLButtonElement ? Key : never;
}[keyof RendererDom];
/** The on-screen button each controller action presses. */
export const MEDIA_TIMING_REVIEW_CONTROLLER_BUTTONS: Record<
MediaTimingReviewControllerAction,
ButtonDomKey
> = {
confirm: 'mediaTimingReviewConfirm',
cancel: 'mediaTimingReviewCancel',
play: 'mediaTimingReviewPlay',
resetTiming: 'mediaTimingReviewReset',
showEarlier: 'mediaTimingReviewShowEarlier',
showLater: 'mediaTimingReviewShowLater',
startBack: 'mediaTimingReviewStartBack',
startForward: 'mediaTimingReviewStartForward',
endBack: 'mediaTimingReviewEndBack',
endForward: 'mediaTimingReviewEndForward',
previousLineAdd: 'mediaTimingReviewPrevAdd',
previousLineRemove: 'mediaTimingReviewPrevRemove',
nextLineAdd: 'mediaTimingReviewNextAdd',
nextLineRemove: 'mediaTimingReviewNextRemove',
framePrevious: 'mediaTimingReviewFramePrevious',
frameNext: 'mediaTimingReviewFrameNext',
frameReset: 'mediaTimingReviewFrameReset',
keepEditing: 'mediaTimingReviewCancelBack',
useOriginal: 'mediaTimingReviewUseOriginal',
skipMedia: 'mediaTimingReviewSkipMedia',
discard: 'mediaTimingReviewDiscard',
};
/** A controller press only counts when a mouse click could have reached the button. */
export function isMediaTimingReviewButtonAvailable(button: HTMLButtonElement): boolean {
return !button.disabled && button.closest('.hidden') === null;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
@@ -962,6 +1004,44 @@ export function createMediaTimingReviewModal(
return false;
}
function getControllerStep(): MediaTimingReviewStep {
return ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden') ? 'editor' : 'cancel';
}
/** Presses the on-screen button for a controller action, so every guard it has applies. */
function handleControllerAction(action: MediaTimingReviewControllerAction): boolean {
if (!ctx.state.mediaTimingReviewModalOpen) return false;
const button = ctx.dom[MEDIA_TIMING_REVIEW_CONTROLLER_BUTTONS[action]];
if (!isMediaTimingReviewButtonAvailable(button)) return false;
button.click();
return true;
}
/**
* Hints live in a data attribute drawn by CSS, so they survive code that rewrites a
* button's text (Skip media / Discard labels change with the card). Unbound actions
* show no tag. Only changed attributes are written, since this runs every frame.
*/
function setControllerHints(
hints: Record<MediaTimingReviewControllerAction, string | null>,
): void {
for (const [action, domKey] of Object.entries(MEDIA_TIMING_REVIEW_CONTROLLER_BUTTONS) as Array<
[MediaTimingReviewControllerAction, ButtonDomKey]
>) {
const button = ctx.dom[domKey];
const hint = hints[action];
if (hint === null || hint === undefined) {
if (button.dataset.controllerHint !== undefined) delete button.dataset.controllerHint;
} else if (button.dataset.controllerHint !== hint) {
button.dataset.controllerHint = hint;
}
}
}
function setControllerHintsVisible(visible: boolean): void {
ctx.dom.mediaTimingReviewModal.classList.toggle('controller-hints-visible', visible);
}
function wireDomEvents(): void {
ctx.dom.mediaTimingReviewFrameSlider.addEventListener('input', () => {
if (!resolveInFlight) framePicker.choose(Number(ctx.dom.mediaTimingReviewFrameSlider.value));
@@ -1050,5 +1130,9 @@ export function createMediaTimingReviewModal(
requestCancel,
handleMediaTimingReviewKeydown,
wireDomEvents,
getControllerStep,
handleControllerAction,
setControllerHints,
setControllerHintsVisible,
};
}
+84 -4
View File
@@ -50,6 +50,20 @@ import { createSubsyncModal } from './modals/subsync.js';
import { createSubtitleGenerationModal } from './modals/subtitle-generation.js';
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
import { createMediaTimingReviewModal } from './modals/media-timing-review.js';
import {
createMediaTimingReviewControllerLayout,
formatMediaTimingReviewControllerHints,
} from './handlers/media-timing-review-controller.js';
import { resolveControllerConfigForGamepad } from './controller-profile-config.js';
import { getDefaultControllerReviewBindings } from '../shared/controller-review';
import {
CONTROLLER_FIELD_GROUPING_SCREEN,
getDefaultControllerFieldGroupingBindings,
} from '../shared/controller-field-grouping';
import {
createControllerComboScreenLayout,
formatControllerScreenHints,
} from './handlers/controller-screen-layout.js';
import { createPositioningController } from './positioning.js';
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
@@ -311,6 +325,20 @@ const mediaTimingReviewModal = createMediaTimingReviewModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const mediaTimingReviewControllerLayout = createMediaTimingReviewControllerLayout({
getStep: () => mediaTimingReviewModal.getControllerStep(),
dispatch: (action) => {
mediaTimingReviewModal.handleControllerAction(action);
},
});
const fieldGroupingControllerLayout = createControllerComboScreenLayout({
screen: CONTROLLER_FIELD_GROUPING_SCREEN,
getBindings: (config) => config.fieldGroupingBindings,
getStep: () => kikuModal.getControllerStep(),
dispatch: (action) => {
kikuModal.handleControllerAction(action);
},
});
const keyboardHandlers = createKeyboardHandlers(ctx, {
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
@@ -389,16 +417,54 @@ function applyControllerSnapshot(snapshot: {
rawAxes: number[];
rawButtons: Array<{ value: number; pressed: boolean; touched?: boolean }>;
}): void {
controllerStatusIndicator.update({
connectedGamepads: snapshot.connectedGamepads,
activeGamepadId: snapshot.activeGamepadId,
});
// Modal windows are fresh documents that only see the pad after a press; announcing
// that as "Controller detected" on every dialog reads like a reconnection.
if (!ctx.platform.isModalLayer) {
controllerStatusIndicator.update({
connectedGamepads: snapshot.connectedGamepads,
activeGamepadId: snapshot.activeGamepadId,
});
}
ctx.state.connectedGamepads = snapshot.connectedGamepads;
ctx.state.activeGamepadId = snapshot.activeGamepadId;
ctx.state.controllerRawAxes = snapshot.rawAxes;
ctx.state.controllerRawButtons = snapshot.rawButtons;
controllerSelectModal.updateDevices();
controllerDebugModal.updateSnapshot();
// Shown whenever controller support is on: a fresh dialog window cannot see the pad
// until its first press, so "connected" would hide the hints exactly when needed.
const controllerConfig = ctx.state.controllerConfig;
const hintsVisible = controllerConfig?.enabled === true;
mediaTimingReviewModal.setControllerHintsVisible(hintsVisible);
kikuModal.setControllerHintsVisible(hintsVisible);
if (
!controllerConfig ||
!hintsVisible ||
(!ctx.state.mediaTimingReviewModalOpen && !ctx.state.kikuModalOpen)
) {
return;
}
// Before the dialog window has seen the pad, use the saved controller's bindings.
const gamepadId = snapshot.activeGamepadId ?? controllerConfig.preferredGamepadId;
const mapping =
snapshot.connectedGamepads.find((device) => device.id === snapshot.activeGamepadId)?.mapping ??
'standard';
const padConfig = resolveControllerConfigForGamepad(controllerConfig, gamepadId);
if (ctx.state.mediaTimingReviewModalOpen) {
mediaTimingReviewModal.setControllerHints(
formatMediaTimingReviewControllerHints(padConfig, mapping),
);
}
if (ctx.state.kikuModalOpen) {
kikuModal.setControllerHints(
formatControllerScreenHints(
CONTROLLER_FIELD_GROUPING_SCREEN,
padConfig.fieldGroupingBindings,
padConfig,
mapping,
),
);
}
}
function showControllerDisabledNotice(): void {
@@ -436,6 +502,7 @@ function startControllerPolling(): void {
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
faceButtonLabels: 'xbox',
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
@@ -469,6 +536,8 @@ function startControllerPolling(): void {
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
reviewBindings: getDefaultControllerReviewBindings(),
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
profiles: {},
},
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
@@ -519,6 +588,13 @@ function startControllerPolling(): void {
onState: (snapshot) => {
applyControllerSnapshot(snapshot);
},
getScreenLayout: () =>
ctx.state.mediaTimingReviewModalOpen
? mediaTimingReviewControllerLayout
: ctx.state.kikuModalOpen
? fieldGroupingControllerLayout
: null,
getSuspended: () => ctx.state.controllerSuspendedByModalWindow,
});
const poll = (now: number): void => {
@@ -721,6 +797,10 @@ function registerKeyboardCommandHandlers(): void {
});
});
window.electronAPI.onControllerSuspendedChanged((suspended: boolean) => {
ctx.state.controllerSuspendedByModalWindow = suspended;
});
window.electronAPI.onKeyboardModeToggleRequested(() => {
runGuarded('keyboard-mode-toggle:requested', () => {
keyboardHandlers.handleKeyboardModeToggleRequested();
+3
View File
@@ -67,6 +67,8 @@ export type RendererState = {
youtubePickerStatus: string;
mediaTimingReviewModalOpen: boolean;
/** True while a modal in the separate modal window owns the controller. */
controllerSuspendedByModalWindow: boolean;
kikuModalOpen: boolean;
kikuSelectedCard: 1 | 2;
@@ -207,6 +209,7 @@ export function createRendererState(): RendererState {
youtubePickerStatus: '',
mediaTimingReviewModalOpen: false,
controllerSuspendedByModalWindow: false,
kikuModalOpen: false,
kikuSelectedCard: 1,
+76
View File
@@ -2072,6 +2072,34 @@ body:focus-visible,
text-align: center;
}
/* Controller hints: shown beside each control while controller support is on. */
.media-timing-review-modal.controller-hints-visible button[data-controller-hint]::after,
#kikuFieldGroupingModal.controller-hints-visible [data-controller-hint]::after {
content: attr(data-controller-hint);
text-transform: none;
display: inline-block;
margin-left: 6px;
padding: 1px 5px;
border: 1px solid var(--ctp-surface1);
border-bottom-width: 2px;
border-radius: 5px;
background: var(--ctp-mantle);
color: var(--ctp-subtext0);
font-family: inherit;
font-size: 9px;
font-weight: 750;
letter-spacing: 0.02em;
line-height: 1.4;
white-space: nowrap;
vertical-align: middle;
}
.media-timing-review-modal.controller-hints-visible .media-timing-review-line-stepper button {
width: auto;
min-width: 24px;
padding: 0 5px;
}
.media-timing-review-cancel-step {
min-height: 300px;
padding: 44px 38px 34px;
@@ -2951,9 +2979,23 @@ iframe[id^='yomitan-popup'],
.kiku-preview-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
}
/* "Scroll ▲ ▼" tag in the preview header, only while controller hints show. */
.kiku-preview-scroll-hint {
display: none;
margin-left: 8px;
font-size: 11px;
font-weight: 500;
color: var(--ctp-subtext0);
}
#kikuFieldGroupingModal.controller-hints-visible .kiku-preview-scroll-hint[data-controller-hint] {
display: inline;
}
.kiku-preview-toggle button {
padding: 5px 10px;
border-radius: 5px;
@@ -3476,6 +3518,40 @@ iframe[id^='yomitan-popup'],
margin-top: 0;
}
.controller-config-group-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
user-select: none;
}
.controller-config-group-action {
padding: 2px 8px;
font-size: 11px;
letter-spacing: 0;
text-transform: none;
}
.controller-config-subgroup {
padding: 10px 10px 4px;
font-size: 11px;
font-weight: 650;
letter-spacing: 0.04em;
color: var(--ctp-subtext0);
}
.controller-config-warning {
margin-top: 2px;
font-size: 11px;
color: var(--ctp-yellow);
}
.controller-config-row.has-warning .controller-config-badge {
border-color: var(--ctp-yellow);
}
.controller-config-row {
display: flex;
align-items: center;
+10
View File
@@ -103,6 +103,11 @@ export type RendererDom = {
kikuCard2Sentence: HTMLDivElement;
kikuCard1Meta: HTMLDivElement;
kikuCard2Meta: HTMLDivElement;
kikuCard1Label: HTMLDivElement;
kikuCard2Label: HTMLDivElement;
kikuDeleteToggle: HTMLLabelElement;
kikuPreviewToggle: HTMLDivElement;
kikuPreviewScrollHint: HTMLSpanElement;
kikuConfirmButton: HTMLButtonElement;
kikuCancelButton: HTMLButtonElement;
kikuDeleteDuplicateCheckbox: HTMLInputElement;
@@ -359,6 +364,11 @@ export function resolveRendererDom(): RendererDom {
kikuCard2Sentence: getRequiredElement<HTMLDivElement>('kikuCard2Sentence'),
kikuCard1Meta: getRequiredElement<HTMLDivElement>('kikuCard1Meta'),
kikuCard2Meta: getRequiredElement<HTMLDivElement>('kikuCard2Meta'),
kikuCard1Label: getRequiredElement<HTMLDivElement>('kikuCard1Label'),
kikuCard2Label: getRequiredElement<HTMLDivElement>('kikuCard2Label'),
kikuDeleteToggle: getRequiredElement<HTMLLabelElement>('kikuDeleteToggle'),
kikuPreviewToggle: getRequiredElement<HTMLDivElement>('kikuPreviewToggle'),
kikuPreviewScrollHint: getRequiredElement<HTMLSpanElement>('kikuPreviewScrollHint'),
kikuConfirmButton: getRequiredElement<HTMLButtonElement>('kikuConfirmButton'),
kikuCancelButton: getRequiredElement<HTMLButtonElement>('kikuCancelButton'),
kikuDeleteDuplicateCheckbox: getRequiredElement<HTMLInputElement>('kikuDeleteDuplicate'),