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

+114
View File
@@ -2190,3 +2190,117 @@ test('template generator shows built-in default keybindings in the keybindings a
assert.deepEqual(parsed.keybindings, DEFAULT_KEYBINDINGS);
});
test('parses controller face button labels and warns on unknown styles', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
'{ "controller": { "faceButtonLabels": "nintendo" } }',
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(DEFAULT_CONFIG.controller.faceButtonLabels, 'xbox');
assert.equal(validService.getConfig().controller.faceButtonLabels, 'nintendo');
assert.deepEqual(validService.getWarnings(), []);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
'{ "controller": { "faceButtonLabels": "sega" } }',
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(invalidService.getConfig().controller.faceButtonLabels, 'xbox');
assert.ok(
invalidService.getWarnings().some((warning) => warning.path === 'controller.faceButtonLabels'),
);
});
test('parses controller review bindings globally and per profile, and warns on bad entries', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
JSON.stringify({
controller: {
reviewBindings: {
confirm: { press: { kind: 'button', buttonIndex: 9 } },
discard: { kind: 'none' },
play: { press: { kind: 'face', letter: 'q' } },
notAnAction: { kind: 'none' },
},
profiles: {
'pad-1': {
reviewBindings: {
startForward: {
press: { kind: 'dpad', direction: 'right' },
hold: { kind: 'axis', axisIndex: 2, direction: 'positive' },
},
},
},
},
},
}),
'utf-8',
);
const service = new ConfigService(dir);
const controller = service.getConfig().controller;
assert.deepEqual(controller.reviewBindings.confirm, {
kind: 'combo',
press: { kind: 'button', buttonIndex: 9 },
hold: null,
});
assert.deepEqual(controller.reviewBindings.discard, { kind: 'none' });
// Invalid entries keep the default.
assert.deepEqual(controller.reviewBindings.play, DEFAULT_CONFIG.controller.reviewBindings.play);
// Profiles start from the resolved global layout, then apply their own overrides.
const profile = controller.profiles['pad-1']!.reviewBindings;
assert.deepEqual(profile.confirm, controller.reviewBindings.confirm);
assert.deepEqual(profile.startForward, {
kind: 'combo',
press: { kind: 'dpad', direction: 'right' },
hold: { kind: 'axis', axisIndex: 2, direction: 'positive' },
});
const warnedPaths = service.getWarnings().map((warning) => warning.path);
assert.ok(warnedPaths.includes('controller.reviewBindings.play'));
assert.ok(warnedPaths.includes('controller.reviewBindings.notAnAction'));
});
test('parses duplicate card field grouping bindings globally and per profile', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
JSON.stringify({
controller: {
fieldGroupingBindings: {
cancelMerge: { press: { kind: 'button', buttonIndex: 8 } },
// A review action is not a field grouping action.
confirm: { kind: 'none' },
},
profiles: {
'pad-1': { fieldGroupingBindings: { back: { kind: 'none' } } },
},
},
}),
'utf-8',
);
const service = new ConfigService(dir);
const controller = service.getConfig().controller;
assert.deepEqual(controller.fieldGroupingBindings.cancelMerge, {
kind: 'combo',
press: { kind: 'button', buttonIndex: 8 },
hold: null,
});
assert.deepEqual(
controller.fieldGroupingBindings.continue,
DEFAULT_CONFIG.controller.fieldGroupingBindings.continue,
);
const profile = controller.profiles['pad-1']!.fieldGroupingBindings;
assert.deepEqual(profile.cancelMerge, controller.fieldGroupingBindings.cancelMerge);
assert.deepEqual(profile.back, { kind: 'none' });
// The review layout is untouched.
assert.deepEqual(controller.reviewBindings, DEFAULT_CONFIG.controller.reviewBindings);
const warnedPaths = service.getWarnings().map((warning) => warning.path);
assert.ok(warnedPaths.includes('controller.fieldGroupingBindings.confirm'));
});
+5
View File
@@ -1,3 +1,5 @@
import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping';
import { getDefaultControllerReviewBindings } from '../../shared/controller-review';
import { ResolvedConfig } from '../../types/config';
export const CORE_DEFAULT_CONFIG: Pick<
@@ -54,6 +56,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
faceButtonLabels: 'xbox',
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
@@ -87,6 +90,8 @@ export const CORE_DEFAULT_CONFIG: Pick<
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
reviewBindings: getDefaultControllerReviewBindings(),
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
profiles: {},
},
shortcuts: {
+106
View File
@@ -1,4 +1,6 @@
import { CONTROLLER_SCREENS, type ControllerScreenId } from '../../shared/controller-screens';
import { ResolvedConfig } from '../../types/config';
import type { ControllerReviewInput, ResolvedControllerReviewBinding } from '../../types/runtime';
import {
NOTIFICATION_TYPE_VALUES,
OVERLAY_NOTIFICATION_POSITION_VALUES,
@@ -6,6 +8,12 @@ import {
} from '../../types/notification';
import { ConfigOptionRegistryEntry } from './shared';
const CONTROLLER_SCREEN_INTROS: Record<ControllerScreenId, string> = {
review: 'Controller layout for the media timing review dialog, one entry per action.',
fieldGrouping:
'Controller layout for the duplicate card (field grouping) dialog, one entry per action.',
};
export function buildCoreConfigOptionRegistry(
defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] {
@@ -224,6 +232,14 @@ export function buildCoreConfigOptionRegistry(
description:
'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.',
},
{
path: 'controller.faceButtonLabels',
kind: 'enum',
enumValues: ['xbox', 'nintendo'],
defaultValue: defaultConfig.controller.faceButtonLabels,
description:
"Letters printed on your controller's face buttons: xbox (A at the bottom; also PlayStation) or nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads). Used by fixed controller layouts and button names in Alt+C.",
},
{
path: 'controller.triggerDeadzone',
kind: 'number',
@@ -330,6 +346,96 @@ export function buildCoreConfigOptionRegistry(
description:
'Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.',
},
...CONTROLLER_SCREENS.flatMap((screen) => {
const defaults = defaultConfig.controller[screen.configKey] as Record<
string,
ResolvedControllerReviewBinding
>;
const stepLabels = new Map(screen.steps.map((step, index) => [step.id, index]));
return [
{
path: `controller.${screen.configKey}`,
kind: 'object' as const,
defaultValue: defaults,
description: `${CONTROLLER_SCREEN_INTROS[screen.id]} Set it with the ${screen.title} group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.`,
},
...screen.actions.flatMap((action): ConfigOptionRegistryEntry[] => {
const path = `controller.${screen.configKey}.${action.id}`;
const binding = defaults[action.id]!;
const press = binding.kind === 'combo' ? binding.press : undefined;
const hold = binding.kind === 'combo' ? binding.hold : undefined;
// Steps after the first are named, e.g. "(after Cancel)".
const stepNote =
(stepLabels.get(action.step) ?? 0) > 0
? ` (${screen.steps.find((step) => step.id === action.step)!.label.toLowerCase()})`
: '';
const inputFields = (
name: 'press' | 'hold',
input: ControllerReviewInput | null | undefined,
): ConfigOptionRegistryEntry[] => [
{
path: `${path}.${name}.kind`,
kind: 'enum',
enumValues: ['button', 'axis', 'face', 'dpad'],
defaultValue: input?.kind,
description:
'Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction.',
},
{
path: `${path}.${name}.buttonIndex`,
kind: 'number',
defaultValue: input?.kind === 'button' ? input.buttonIndex : undefined,
description: 'Raw button index.',
},
{
path: `${path}.${name}.letter`,
kind: 'enum',
enumValues: ['a', 'b', 'x', 'y'],
defaultValue: input?.kind === 'face' ? input.letter : undefined,
description: 'Printed face letter, placed by controller.faceButtonLabels.',
},
{
path: `${path}.${name}.direction`,
kind: 'enum',
enumValues: ['left', 'right', 'up', 'down', 'negative', 'positive'],
defaultValue:
input?.kind === 'dpad' || input?.kind === 'axis' ? input.direction : undefined,
description: 'D-pad direction, or axis direction for raw axis inputs.',
},
];
return [
{
path,
kind: 'object',
defaultValue: binding,
description: `${screen.describe}: ${action.label}${stepNote}.${action.repeat ? ' Repeats while held.' : ''}`,
},
{
path: `${path}.kind`,
kind: 'enum',
enumValues: ['none', 'combo'],
defaultValue: binding.kind,
description:
'none disables the action; combo presses "press" (while holding "hold", if set).',
},
{
path: `${path}.press`,
kind: 'object',
defaultValue: press,
description: 'Input that triggers the action.',
},
{
path: `${path}.hold`,
kind: 'object',
defaultValue: hold,
description: 'Optional input held while pressing; null for none.',
},
...inputFields('press', press),
...inputFields('hold', hold),
];
}),
];
}),
...discreteBindings.flatMap((binding) => [
{
path: `controller.bindings.${binding.id}`,
+70
View File
@@ -8,6 +8,13 @@ import type {
ResolvedControllerBindingsConfig,
ResolvedControllerDiscreteBinding,
} from '../../types/runtime';
import {
isControllerScreenAction,
parseControllerReviewBinding,
type ControllerScreenBindings,
type ControllerScreenDefinition,
} from '../../shared/controller-review';
import { CONTROLLER_SCREENS } from '../../shared/controller-screens';
import { ResolveContext } from './context';
import { asBoolean, asNumber, asString, isObject } from './shared';
@@ -268,6 +275,37 @@ function applyControllerBindings(
}
}
function applyControllerScreenBindings(
screen: ControllerScreenDefinition,
source: unknown,
target: ControllerScreenBindings,
pathPrefix: string,
warn: ResolveContext['warn'],
): void {
if (source === undefined) return;
if (!isObject(source)) {
warn(pathPrefix, source, target, `Expected an object keyed by ${screen.describe} action.`);
return;
}
for (const [action, value] of Object.entries(source)) {
if (!isControllerScreenAction(screen, action)) {
warn(`${pathPrefix}.${action}`, value, undefined, `Unknown ${screen.describe} action.`);
continue;
}
const parsed = parseControllerReviewBinding(value);
if (parsed) {
target[action] = parsed;
} else {
warn(
`${pathPrefix}.${action}`,
value,
target[action],
'Expected {"kind": "none"} or {"press": input, "hold"?: input} with button, axis, face or dpad inputs.',
);
}
}
}
export function applyControllerConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
if (!isObject(src.controller)) return;
@@ -322,6 +360,18 @@ export function applyControllerConfig(context: ResolveContext): void {
);
}
const faceButtonLabels = asString(src.controller.faceButtonLabels);
if (faceButtonLabels === 'xbox' || faceButtonLabels === 'nintendo') {
resolved.controller.faceButtonLabels = faceButtonLabels;
} else if (src.controller.faceButtonLabels !== undefined) {
warn(
'controller.faceButtonLabels',
src.controller.faceButtonLabels,
resolved.controller.faceButtonLabels,
"Expected 'xbox' or 'nintendo'.",
);
}
const boundedNumberKeys = [
'scrollPixelsPerSecond',
'horizontalJumpPixels',
@@ -370,6 +420,15 @@ export function applyControllerConfig(context: ResolveContext): void {
'controller.bindings',
warn,
);
for (const screen of CONTROLLER_SCREENS) {
applyControllerScreenBindings(
screen,
src.controller[screen.configKey],
resolved.controller[screen.configKey],
`controller.${screen.configKey}`,
warn,
);
}
if (isObject(src.controller.profiles)) {
for (const [profileId, rawProfile] of Object.entries(src.controller.profiles)) {
@@ -406,6 +465,8 @@ export function applyControllerConfig(context: ResolveContext): void {
label: label ?? profileId,
buttonIndices: structuredClone(resolved.controller.buttonIndices),
bindings: structuredClone(resolved.controller.bindings),
reviewBindings: structuredClone(resolved.controller.reviewBindings),
fieldGroupingBindings: structuredClone(resolved.controller.fieldGroupingBindings),
};
applyControllerButtonIndices(
rawProfile.buttonIndices,
@@ -420,6 +481,15 @@ export function applyControllerConfig(context: ResolveContext): void {
`controller.profiles.${profileId}.bindings`,
warn,
);
for (const screen of CONTROLLER_SCREENS) {
applyControllerScreenBindings(
screen,
rawProfile[screen.configKey],
profile[screen.configKey],
`controller.profiles.${profileId}.${screen.configKey}`,
warn,
);
}
resolved.controller.profiles[profileId] = profile;
}
}
+2
View File
@@ -58,6 +58,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'ankiConnect.isLapis.sentenceCardAudioField',
'ankiConnect.fields.translation',
'controller.bindings',
'controller.reviewBindings',
'controller.fieldGroupingBindings',
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
'controller.profiles',
+5
View File
@@ -4,6 +4,8 @@ import assert from 'node:assert/strict';
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { SessionActionDispatchRequest, SubtitleSidebarSnapshot } from '../../types';
import { getDefaultControllerReviewBindings } from '../../shared/controller-review';
import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping';
interface FakeIpcRegistrar {
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
@@ -45,6 +47,7 @@ function createControllerConfigFixture() {
stickDeadzone: 0.2,
triggerInputMode: 'auto' as const,
triggerDeadzone: 0.5,
faceButtonLabels: 'xbox' as const,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
@@ -82,6 +85,8 @@ function createControllerConfigFixture() {
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
},
reviewBindings: getDefaultControllerReviewBindings(),
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
profiles: {},
};
}
+57
View File
@@ -129,3 +129,60 @@ test('applyControllerConfigUpdate ignores reserved profile ids', () => {
assert.equal(Object.hasOwn(next.profiles ?? {}, 'prototype'), false);
assert.equal(next.profiles?.['pad-1']?.label, 'Pad 1');
});
test('applyControllerConfigUpdate merges review bindings per action inside a profile', () => {
const next = applyControllerConfigUpdate(
{
profiles: {
'pad-1': {
bindings: { toggleLookup: { kind: 'button', buttonIndex: 0 } },
reviewBindings: {
confirm: { press: { kind: 'button', buttonIndex: 1 } },
cancel: { kind: 'none' },
},
},
},
},
{
profiles: {
'pad-1': {
reviewBindings: {
cancel: {
kind: 'combo',
press: { kind: 'button', buttonIndex: 0 },
hold: { kind: 'button', buttonIndex: 4 },
},
},
},
},
},
);
assert.deepEqual(next.profiles?.['pad-1'], {
bindings: { toggleLookup: { kind: 'button', buttonIndex: 0 } },
reviewBindings: {
confirm: { press: { kind: 'button', buttonIndex: 1 } },
cancel: {
kind: 'combo',
press: { kind: 'button', buttonIndex: 0 },
hold: { kind: 'button', buttonIndex: 4 },
},
},
});
});
test('applyControllerConfigUpdate merges field grouping bindings next to review bindings', () => {
const next = applyControllerConfigUpdate(
{
reviewBindings: { confirm: { kind: 'none' } },
fieldGroupingBindings: { back: { kind: 'none' } },
},
{ fieldGroupingBindings: { cancelMerge: { press: { kind: 'face', letter: 'b' } } } },
);
assert.deepEqual(next.reviewBindings, { confirm: { kind: 'none' } });
assert.deepEqual(next.fieldGroupingBindings, {
back: { kind: 'none' },
cancelMerge: { press: { kind: 'face', letter: 'b' } },
});
});
+35
View File
@@ -2,6 +2,9 @@ import type { ControllerConfigUpdate, RawConfig } from '../types';
type RawControllerConfig = NonNullable<RawConfig['controller']>;
type RawControllerBindings = NonNullable<RawControllerConfig['bindings']>;
type RawControllerScreenBindings = Record<string, unknown>;
/** Per-dialog controller layouts stored as per-action patches. */
const SCREEN_BINDING_KEYS = ['reviewBindings', 'fieldGroupingBindings'] as const;
type RawControllerButtonIndices = NonNullable<RawControllerConfig['buttonIndices']>;
type RawControllerProfiles = NonNullable<RawControllerConfig['profiles']>;
type RawControllerProfile = NonNullable<RawControllerProfiles[string]>;
@@ -27,6 +30,34 @@ function mergeBindingPatch(
return nextBindings;
}
function mergeScreenBindingPatch(
currentBindings: RawControllerScreenBindings | undefined,
updateBindings: RawControllerScreenBindings | undefined,
): RawControllerScreenBindings | undefined {
if (!currentBindings && !updateBindings) return undefined;
const nextBindings: RawControllerScreenBindings = { ...(currentBindings ?? {}) };
for (const [action, binding] of Object.entries(updateBindings ?? {})) {
if (binding === undefined) continue;
nextBindings[action] = structuredClone(binding);
}
return nextBindings;
}
/** Merges every per-dialog layout patch from `update` onto `current` into `target`. */
function mergeScreenBindingPatches(
current: Partial<Record<(typeof SCREEN_BINDING_KEYS)[number], unknown>> | undefined,
update: Partial<Record<(typeof SCREEN_BINDING_KEYS)[number], unknown>>,
target: Partial<Record<(typeof SCREEN_BINDING_KEYS)[number], unknown>>,
): void {
for (const key of SCREEN_BINDING_KEYS) {
const merged = mergeScreenBindingPatch(
current?.[key] as RawControllerScreenBindings | undefined,
update[key] as RawControllerScreenBindings | undefined,
);
if (merged) target[key] = merged;
}
}
function mergeButtonIndexPatch(
currentButtonIndices: RawControllerButtonIndices | undefined,
updateButtonIndices: RawControllerButtonIndices | undefined,
@@ -60,6 +91,8 @@ function mergeControllerProfile(
nextProfile.bindings = bindings;
}
mergeScreenBindingPatches(currentProfile, updateProfile, nextProfile);
return nextProfile;
}
@@ -85,6 +118,8 @@ export function applyControllerConfigUpdate(
nextController.bindings = bindings;
}
mergeScreenBindingPatches(currentController, update, nextController);
if (currentController?.profiles || update.profiles) {
const nextProfiles: RawControllerProfiles = {};
for (const [profileId, profile] of Object.entries(currentController?.profiles ?? {}) as Array<
+40 -1
View File
@@ -8,7 +8,7 @@ import {
createHarness,
type FakeOverlayWindowInit,
} from './overlay-runtime-test-harness';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import { IPC_CHANNELS, type OverlayHostedModal } from '../shared/ipc/contracts';
const SUBSYNC_PAYLOAD = {
ffsubsyncAvailable: true,
@@ -749,3 +749,42 @@ test('Linux keeps the dedicated modal window unmapped until the renderer opens t
assert.equal(modal.isVisible(), true);
assert.equal(modal.ignoreMouseEvents, false);
});
test('modal window path suspends the main overlay controller until the last modal closes', () => {
const main = new FakeOverlayWindow({ visible: true });
const h = createHarness({ main, modal: new FakeOverlayWindow() });
const suspendMessages = () =>
main.sent.filter(([channel]) => channel === IPC_CHANNELS.event.controllerSuspendedChanged);
h.runtime.sendToActiveOverlayWindow(
'media-timing-review:open',
{ reviewId: 'r-1' },
{ restoreOnModalClose: 'media-timing-review', preferModalWindow: true },
);
h.runtime.notifyOverlayModalOpened('media-timing-review');
assert.deepEqual(suspendMessages(), [[IPC_CHANNELS.event.controllerSuspendedChanged, true]]);
h.runtime.handleOverlayModalClosed('media-timing-review');
assert.deepEqual(suspendMessages(), [
[IPC_CHANNELS.event.controllerSuspendedChanged, true],
[IPC_CHANNELS.event.controllerSuspendedChanged, false],
]);
});
test('a modal hosted in the main overlay keeps that overlay controller active', () => {
const main = new FakeOverlayWindow({ visible: true });
const h = createHarness({ main, createModal: () => new FakeOverlayWindow() });
h.runtime.sendToActiveOverlayWindow(
'media-timing-review:open',
{ reviewId: 'r-1' },
{ restoreOnModalClose: 'media-timing-review' },
);
h.runtime.notifyOverlayModalOpened('media-timing-review');
h.runtime.handleOverlayModalClosed('media-timing-review');
assert.equal(
main.sent.some(([channel]) => channel === IPC_CHANNELS.event.controllerSuspendedChanged),
false,
);
});
+14 -1
View File
@@ -1,5 +1,5 @@
import type { BrowserWindow } from 'electron';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import { IPC_CHANNELS, type OverlayHostedModal } from '../shared/ipc/contracts';
import type { WindowGeometry } from '../types';
import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement';
import { applyOverlayClickThrough } from '../core/services/overlay-click-through';
@@ -83,6 +83,7 @@ export function createOverlayModalRuntimeService(
let modalActive = false;
let mainWindowMousePassthroughForcedByModal = false;
let mainWindowHiddenByModal = false;
let mainWindowControllerSuspended = false;
let modalWindowPrimedForImmediateShow = false;
let pendingModalWindowReveal: BrowserWindow | null = null;
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
@@ -411,6 +412,16 @@ export function createOverlayModalRuntimeService(
mainWindowHiddenByModal = false;
};
// The hidden main overlay keeps polling the gamepad (background throttling is off), so
// tell it to stand down while the modal window owns the controller.
const setMainWindowControllerSuspended = (suspended: boolean): void => {
if (mainWindowControllerSuspended === suspended) return;
mainWindowControllerSuspended = suspended;
const mainWindow = deps.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send(IPC_CHANNELS.event.controllerSuspendedChanged, suspended);
};
const scheduleModalWindowReveal = (window: BrowserWindow): void => {
pendingModalWindowReveal = window;
if (pendingModalWindowRevealTimeout !== null) {
@@ -560,6 +571,7 @@ export function createOverlayModalRuntimeService(
}
mainWindowMousePassthroughForcedByModal = false;
setMainWindowVisibilityForModal(false);
setMainWindowControllerSuspended(false);
try {
options.onFinalModalClosed?.();
} catch {
@@ -591,6 +603,7 @@ export function createOverlayModalRuntimeService(
const handOffMainWindowToModal = (): void => {
setMainWindowMousePassthroughForModal(true);
setMainWindowVisibilityForModal(true);
setMainWindowControllerSuspended(true);
};
if (targetIsModalWindow && deferModalRevealUntilOpened) {
+5
View File
@@ -186,6 +186,10 @@ const onSessionNumericSelectionStartEvent =
IPC_CHANNELS.event.sessionNumericSelectionStart,
(payload) => payload as SessionNumericSelectionStartPayload,
);
const onControllerSuspendedChangedEvent = createQueuedIpcListenerWithPayload<boolean>(
IPC_CHANNELS.event.controllerSuspendedChanged,
(payload) => payload === true,
);
const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener(
IPC_CHANNELS.event.keyboardModeToggleRequested,
);
@@ -491,6 +495,7 @@ const electronAPI: ElectronAPI = {
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
onSessionNumericSelectionStart: onSessionNumericSelectionStartEvent,
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
onControllerSuspendedChanged: onControllerSuspendedChangedEvent,
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
getPlaylistBrowserSnapshot: (): Promise<PlaylistBrowserSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaylistBrowserSnapshot),
@@ -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'),
+79
View File
@@ -0,0 +1,79 @@
import type {
ControllerFieldGroupingAction,
ResolvedControllerFieldGroupingBindings,
} from '../types/runtime';
import {
combo,
dpad,
face,
type ControllerScreenActionDefinition,
type ControllerScreenDefinition,
} from './controller-review';
/** The duplicate-card dialog: pick the card to keep, then the final merge preview. */
export type ControllerFieldGroupingStep = 'select' | 'preview';
export type ControllerFieldGroupingActionDefinition = ControllerScreenActionDefinition<
ControllerFieldGroupingAction,
ControllerFieldGroupingStep
>;
/** Every field grouping action, in the order Alt+C lists them. */
export const CONTROLLER_FIELD_GROUPING_ACTIONS: readonly ControllerFieldGroupingActionDefinition[] =
[
{ id: 'selectOriginal', label: 'Original card', step: 'select', repeat: false },
{ id: 'selectNew', label: 'New card', step: 'select', repeat: false },
{
id: 'toggleDeleteDuplicate',
label: 'Delete duplicate on/off',
step: 'select',
repeat: false,
},
{ id: 'continue', label: 'Continue', step: 'select', repeat: false },
{ id: 'cancel', label: 'Cancel', step: 'select', repeat: false },
{ id: 'confirmMerge', label: 'Confirm merge', step: 'preview', repeat: false },
{ id: 'back', label: 'Back', step: 'preview', repeat: false },
{ id: 'cancelMerge', label: 'Cancel', step: 'preview', repeat: false },
{ id: 'toggleView', label: 'Compact / Full', step: 'preview', repeat: false },
{ id: 'scrollUp', label: 'Scroll up', step: 'preview', repeat: true },
{ id: 'scrollDown', label: 'Scroll down', step: 'preview', repeat: true },
];
/**
* Default layout: D-pad picks the card, A goes forward, B goes back (B on the preview
* returns to the card choice, B there cancels). Cancel on the preview is unbound, so a
* double B press is the way out unless the user binds it.
*/
const DEFAULT_CONTROLLER_FIELD_GROUPING_BINDINGS: ResolvedControllerFieldGroupingBindings = {
selectOriginal: combo(dpad('left')),
selectNew: combo(dpad('right')),
toggleDeleteDuplicate: combo(face('x')),
continue: combo(face('a')),
cancel: combo(face('b')),
confirmMerge: combo(face('a')),
back: combo(face('b')),
cancelMerge: { kind: 'none' },
toggleView: combo(face('y')),
scrollUp: combo(dpad('up')),
scrollDown: combo(dpad('down')),
};
export const CONTROLLER_FIELD_GROUPING_SCREEN: ControllerScreenDefinition<
ControllerFieldGroupingAction,
ControllerFieldGroupingStep
> = {
id: 'fieldGrouping',
title: 'Duplicate card (field grouping)',
configKey: 'fieldGroupingBindings',
describe: 'Duplicate card dialog',
steps: [
{ id: 'select', label: 'Choosing the card' },
{ id: 'preview', label: 'Merge preview' },
],
actions: CONTROLLER_FIELD_GROUPING_ACTIONS,
defaults: DEFAULT_CONTROLLER_FIELD_GROUPING_BINDINGS,
};
export function getDefaultControllerFieldGroupingBindings(): ResolvedControllerFieldGroupingBindings {
return structuredClone(DEFAULT_CONTROLLER_FIELD_GROUPING_BINDINGS);
}
+150
View File
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CORE_DEFAULT_CONFIG } from '../config/definitions/defaults-core';
import {
controllerReviewInputSourceKey,
formatControllerReviewBinding,
formatControllerReviewInput,
parseControllerReviewBinding,
resolveControllerReviewInput,
type ControllerReviewInputContext,
} from './controller-review';
import { parseControllerConfigUpdate } from './ipc/validators';
const buttonIndices = CORE_DEFAULT_CONFIG.controller.buttonIndices;
const standardXbox: ControllerReviewInputContext = {
mapping: 'standard',
faceButtonLabels: 'xbox',
buttonIndices,
};
test('review bindings parse none, single presses and combos; anything else is rejected', () => {
assert.deepEqual(parseControllerReviewBinding({ kind: 'none' }), { kind: 'none' });
assert.deepEqual(parseControllerReviewBinding({ press: { kind: 'face', letter: 'a' } }), {
kind: 'combo',
press: { kind: 'face', letter: 'a' },
hold: null,
});
assert.deepEqual(
parseControllerReviewBinding({
kind: 'combo',
press: { kind: 'dpad', direction: 'up' },
hold: { kind: 'button', buttonIndex: 4 },
}),
{
kind: 'combo',
press: { kind: 'dpad', direction: 'up' },
hold: { kind: 'button', buttonIndex: 4 },
},
);
for (const bad of [
null,
{},
{ kind: 'button', buttonIndex: 1 },
{ press: { kind: 'face', letter: 'z' } },
{ press: { kind: 'button', buttonIndex: -1 } },
{ press: { kind: 'axis', axisIndex: 2 } },
{ press: { kind: 'dpad', direction: 'left' }, hold: { kind: 'dpad' } },
]) {
assert.equal(parseControllerReviewBinding(bad), null, JSON.stringify(bad));
}
});
test('the same physical input resolves to the same key across layouts', () => {
const key = (input: Parameters<typeof resolveControllerReviewInput>[0], context = standardXbox) =>
controllerReviewInputSourceKey(resolveControllerReviewInput(input, context));
// Letters follow faceButtonLabels.
assert.equal(key({ kind: 'face', letter: 'a' }), 'button:0');
assert.equal(
key({ kind: 'face', letter: 'a' }, { ...standardXbox, faceButtonLabels: 'nintendo' }),
'button:1',
);
// A learned standard D-pad button and the default D-pad entry are one input.
assert.equal(key({ kind: 'button', buttonIndex: 14 }), 'dpad:left');
assert.equal(key({ kind: 'dpad', direction: 'left' }), 'dpad:left');
// Non-standard pads report the D-pad on axes 6 and 7.
const raw = { ...standardXbox, mapping: '' };
assert.equal(key({ kind: 'axis', axisIndex: 7, direction: 'positive' }, raw), 'dpad:down');
assert.equal(key({ kind: 'button', buttonIndex: 14 }, raw), 'button:14');
assert.equal(key({ kind: 'axis', axisIndex: 2, direction: 'positive' }, raw), 'axis:2:positive');
});
test('review inputs are named as printed and by layout', () => {
const name = (input: Parameters<typeof formatControllerReviewInput>[0], context = standardXbox) =>
formatControllerReviewInput(input, context, { dpadPrefix: true });
const nintendo = { ...standardXbox, faceButtonLabels: 'nintendo' as const };
const raw = { ...standardXbox, mapping: '' };
assert.equal(name({ kind: 'face', letter: 'x' }, nintendo), 'X');
assert.equal(name({ kind: 'button', buttonIndex: 0 }, nintendo), 'B');
assert.equal(name({ kind: 'button', buttonIndex: 6 }), 'L2');
assert.equal(name({ kind: 'button', buttonIndex: 6 }, raw), 'Back');
assert.equal(name({ kind: 'button', buttonIndex: 10 }), 'L3');
assert.equal(name({ kind: 'button', buttonIndex: 15 }), 'D-pad ▶');
assert.equal(name({ kind: 'axis', axisIndex: 1, direction: 'negative' }), 'LS ▲');
assert.equal(name({ kind: 'axis', axisIndex: 5, direction: 'positive' }, raw), 'R2');
assert.equal(name({ kind: 'axis', axisIndex: 9, direction: 'positive' }), 'Axis 9 +');
assert.equal(
formatControllerReviewBinding(
{
kind: 'combo',
press: { kind: 'dpad', direction: 'up' },
hold: { kind: 'button', buttonIndex: 5 },
},
standardXbox,
),
'R1 + ▲',
);
});
test('controller config updates from Alt+C accept review bindings and reject bad ones', () => {
// The validator builds prototype-less objects; compare plain copies.
const plain = (value: unknown) => JSON.parse(JSON.stringify(value));
assert.deepEqual(
plain(
parseControllerConfigUpdate({
profiles: {
'pad-1': {
reviewBindings: {
confirm: { kind: 'combo', press: { kind: 'button', buttonIndex: 1 } },
},
},
},
}),
),
{
profiles: {
'pad-1': {
reviewBindings: {
confirm: { kind: 'combo', press: { kind: 'button', buttonIndex: 1 }, hold: null },
},
},
},
},
);
assert.equal(parseControllerConfigUpdate({ reviewBindings: { nope: { kind: 'none' } } }), null);
assert.equal(
parseControllerConfigUpdate({ reviewBindings: { confirm: { press: { kind: 'face' } } } }),
null,
);
});
test('Alt+C updates accept field grouping bindings, checked against that dialog', () => {
const plain = (value: unknown) => JSON.parse(JSON.stringify(value));
assert.deepEqual(
plain(
parseControllerConfigUpdate({
profiles: { 'pad-1': { fieldGroupingBindings: { back: { kind: 'none' } } } },
}),
),
{ profiles: { 'pad-1': { fieldGroupingBindings: { back: { kind: 'none' } } } } },
);
// Each dialog only accepts its own actions.
assert.equal(
parseControllerConfigUpdate({ fieldGroupingBindings: { confirm: { kind: 'none' } } }),
null,
);
assert.equal(parseControllerConfigUpdate({ reviewBindings: { back: { kind: 'none' } } }), null);
});
+518
View File
@@ -0,0 +1,518 @@
import type {
ControllerAxisDirection,
ControllerButtonIndicesConfig,
ControllerDpadDirection,
ControllerFaceButtonLabels,
ControllerFaceLetter,
ControllerReviewAction,
ControllerReviewInput,
ResolvedControllerReviewBinding,
ResolvedControllerReviewBindings,
} from '../types/runtime';
/**
* A dialog with its own controller layout (media timing review, field grouping). Each
* action presses one on-screen control on one step of the dialog; bindings are
* press-or-combo, remappable in Alt+C, and stored under `controller.<configKey>`.
*/
export type ControllerScreenDefinition<A extends string = string, S extends string = string> = {
id: 'review' | 'fieldGrouping';
/** Alt+C group title. */
title: string;
/** Config key on `controller` and on each profile. */
configKey: 'reviewBindings' | 'fieldGroupingBindings';
/** Prefix for generated config descriptions, e.g. `Review dialog`. */
describe: string;
steps: readonly { id: S; label: string }[];
actions: readonly ControllerScreenActionDefinition<A, S>[];
defaults: Readonly<Record<A, ResolvedControllerReviewBinding>>;
};
export type ControllerScreenActionDefinition<
A extends string = string,
S extends string = string,
> = {
id: A;
label: string;
step: S;
/** Repeats while held, like a held arrow key. */
repeat: boolean;
};
export type ControllerScreenBindings<A extends string = string> = Record<
A,
ResolvedControllerReviewBinding
>;
/** The review shows either the timing editor or the "Stop reviewing?" step. */
export type ControllerReviewStep = 'editor' | 'cancel';
export type ControllerReviewActionDefinition = ControllerScreenActionDefinition<
ControllerReviewAction,
ControllerReviewStep
>;
/** Every review action, in the order Alt+C lists them. */
export const CONTROLLER_REVIEW_ACTIONS: readonly ControllerReviewActionDefinition[] = [
{ id: 'confirm', label: 'Use this timing', step: 'editor', repeat: false },
{ id: 'cancel', label: 'Cancel', step: 'editor', repeat: false },
{ id: 'play', label: 'Play selection', step: 'editor', repeat: false },
{ id: 'resetTiming', label: 'Reset timing', step: 'editor', repeat: false },
{ id: 'showEarlier', label: 'Earlier −2s', step: 'editor', repeat: true },
{ id: 'showLater', label: 'Later +2s', step: 'editor', repeat: true },
{ id: 'framePrevious', label: 'Previous screenshot frame', step: 'editor', repeat: true },
{ id: 'frameNext', label: 'Next screenshot frame', step: 'editor', repeat: true },
{ id: 'frameReset', label: 'Reset screenshot frame', step: 'editor', repeat: false },
{ id: 'startBack', label: 'Start −0.1s', step: 'editor', repeat: true },
{ id: 'startForward', label: 'Start +0.1s', step: 'editor', repeat: true },
{ id: 'previousLineAdd', label: 'Add previous line', step: 'editor', repeat: true },
{ id: 'previousLineRemove', label: 'Remove previous line', step: 'editor', repeat: true },
{ id: 'endBack', label: 'End −0.1s', step: 'editor', repeat: true },
{ id: 'endForward', label: 'End +0.1s', step: 'editor', repeat: true },
{ id: 'nextLineAdd', label: 'Add next line', step: 'editor', repeat: true },
{ id: 'nextLineRemove', label: 'Remove next line', step: 'editor', repeat: true },
{ id: 'useOriginal', label: 'Use original timing', step: 'cancel', repeat: false },
{ id: 'keepEditing', label: 'Keep editing', step: 'cancel', repeat: false },
{ id: 'skipMedia', label: 'Without media', step: 'cancel', repeat: false },
{ id: 'discard', label: 'Discard card', step: 'cancel', repeat: false },
];
const ACTION_BY_ID = new Map(CONTROLLER_REVIEW_ACTIONS.map((action) => [action.id, action]));
export function getControllerReviewAction(
id: ControllerReviewAction,
): ControllerReviewActionDefinition {
return ACTION_BY_ID.get(id)!;
}
export function isControllerReviewAction(value: string): value is ControllerReviewAction {
return ACTION_BY_ID.has(value as ControllerReviewAction);
}
export function getControllerScreenAction<A extends string, S extends string>(
screen: ControllerScreenDefinition<A, S>,
id: A,
): ControllerScreenActionDefinition<A, S> {
return screen.actions.find((action) => action.id === id)!;
}
export function isControllerScreenAction<A extends string>(
screen: ControllerScreenDefinition<A>,
value: string,
): value is A {
return screen.actions.some((action) => action.id === value);
}
export function getDefaultControllerScreenBindings<A extends string>(
screen: ControllerScreenDefinition<A>,
): ControllerScreenBindings<A> {
return structuredClone(screen.defaults) as ControllerScreenBindings<A>;
}
export const face = (letter: ControllerFaceLetter): ControllerReviewInput => ({
kind: 'face',
letter,
});
export const dpad = (direction: ControllerDpadDirection): ControllerReviewInput => ({
kind: 'dpad',
direction,
});
const LEFT_SHOULDER: ControllerReviewInput = { kind: 'button', buttonIndex: 4 };
const RIGHT_SHOULDER: ControllerReviewInput = { kind: 'button', buttonIndex: 5 };
export const combo = (
press: ControllerReviewInput,
hold: ControllerReviewInput | null = null,
): ResolvedControllerReviewBinding => ({ kind: 'combo', press, hold });
/**
* Default layout. L1 holds everything about the clip start (and lines before it), R1
* everything about the clip end (and lines after it). Face buttons follow the printed
* letter and D-pad entries accept any D-pad report, so these work on every layout.
*/
const DEFAULT_CONTROLLER_REVIEW_BINDINGS: ResolvedControllerReviewBindings = {
confirm: combo(face('a')),
cancel: combo(face('b')),
play: combo(face('x')),
resetTiming: combo(face('y')),
showEarlier: combo(dpad('left')),
showLater: combo(dpad('right')),
framePrevious: combo(dpad('up')),
frameNext: combo(dpad('down')),
frameReset: combo(RIGHT_SHOULDER, LEFT_SHOULDER),
startBack: combo(dpad('left'), LEFT_SHOULDER),
startForward: combo(dpad('right'), LEFT_SHOULDER),
previousLineAdd: combo(dpad('up'), LEFT_SHOULDER),
previousLineRemove: combo(dpad('down'), LEFT_SHOULDER),
endBack: combo(dpad('left'), RIGHT_SHOULDER),
endForward: combo(dpad('right'), RIGHT_SHOULDER),
nextLineAdd: combo(dpad('up'), RIGHT_SHOULDER),
nextLineRemove: combo(dpad('down'), RIGHT_SHOULDER),
useOriginal: combo(face('a')),
keepEditing: combo(face('b')),
skipMedia: combo(face('x')),
discard: combo(face('y')),
};
export const CONTROLLER_REVIEW_SCREEN: ControllerScreenDefinition<
ControllerReviewAction,
ControllerReviewStep
> = {
id: 'review',
title: 'Review media timing',
configKey: 'reviewBindings',
describe: 'Review dialog',
steps: [
{ id: 'editor', label: 'While editing' },
{ id: 'cancel', label: 'After Cancel' },
],
actions: CONTROLLER_REVIEW_ACTIONS,
defaults: DEFAULT_CONTROLLER_REVIEW_BINDINGS,
};
export function getDefaultControllerReviewBindings(): ResolvedControllerReviewBindings {
return structuredClone(DEFAULT_CONTROLLER_REVIEW_BINDINGS);
}
export function getDefaultControllerReviewBinding(
action: ControllerReviewAction,
): ResolvedControllerReviewBinding {
return structuredClone(DEFAULT_CONTROLLER_REVIEW_BINDINGS[action]);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isIndex(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
}
export function parseControllerReviewInput(value: unknown): ControllerReviewInput | null {
if (!isRecord(value)) return null;
switch (value.kind) {
case 'button':
return isIndex(value.buttonIndex) ? { kind: 'button', buttonIndex: value.buttonIndex } : null;
case 'axis':
return isIndex(value.axisIndex) &&
(value.direction === 'negative' || value.direction === 'positive')
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
: null;
case 'face':
return value.letter === 'a' ||
value.letter === 'b' ||
value.letter === 'x' ||
value.letter === 'y'
? { kind: 'face', letter: value.letter }
: null;
case 'dpad':
return value.direction === 'left' ||
value.direction === 'right' ||
value.direction === 'up' ||
value.direction === 'down'
? { kind: 'dpad', direction: value.direction }
: null;
default:
return null;
}
}
/** Accepts `{ "kind": "none" }` or `{ "press": …, "hold"?: … }` (`kind: "combo"` optional). */
export function parseControllerReviewBinding(
value: unknown,
): ResolvedControllerReviewBinding | null {
if (!isRecord(value)) return null;
if (value.kind === 'none') return { kind: 'none' };
if (value.kind !== undefined && value.kind !== 'combo') return null;
const press = parseControllerReviewInput(value.press);
if (!press) return null;
if (value.hold === undefined || value.hold === null) return combo(press);
const hold = parseControllerReviewInput(value.hold);
return hold ? combo(press, hold) : null;
}
/** What a review input needs to know about the controller it runs on. */
export type ControllerReviewInputContext = {
/** Gamepad API `mapping` ("standard" or ""); unknown before the pad is seen. */
mapping: string | null | undefined;
faceButtonLabels: ControllerFaceButtonLabels;
buttonIndices: Required<ControllerButtonIndicesConfig>;
};
/** A physical input after resolving letters and D-pad reports; equal sources are the same input. */
export type ControllerReviewInputSource =
| { kind: 'button'; buttonIndex: number }
| { kind: 'axis'; axisIndex: number; direction: ControllerAxisDirection }
| { kind: 'dpad'; direction: ControllerDpadDirection };
type FacePosition = 'south' | 'east' | 'west' | 'north';
/** Xbox (and PlayStation) pads put A at the bottom; Nintendo-style pads put it on the right. */
const FACE_POSITIONS: Record<
ControllerFaceButtonLabels,
Record<ControllerFaceLetter, FacePosition>
> = {
xbox: { a: 'south', b: 'east', x: 'west', y: 'north' },
nintendo: { a: 'east', b: 'south', x: 'north', y: 'west' },
};
const POSITION_INDEX_KEYS = {
south: 'buttonSouth',
east: 'buttonEast',
west: 'buttonWest',
north: 'buttonNorth',
} as const satisfies Record<FacePosition, keyof ControllerButtonIndicesConfig>;
/** Unknown (pad not seen yet) counts as standard, the layout most pads report. */
function isStandardMapping(mapping: string | null | undefined): boolean {
return mapping === 'standard' || mapping === null || mapping === undefined;
}
const STANDARD_DPAD_BUTTONS: Record<number, ControllerDpadDirection> = {
12: 'up',
13: 'down',
14: 'left',
15: 'right',
};
export function resolveControllerReviewInput(
input: ControllerReviewInput,
context: ControllerReviewInputContext,
): ControllerReviewInputSource {
if (input.kind === 'dpad') return input;
if (input.kind === 'face') {
const positions = FACE_POSITIONS[context.faceButtonLabels] ?? FACE_POSITIONS.xbox;
const buttonIndex = context.buttonIndices[POSITION_INDEX_KEYS[positions[input.letter]]];
return { kind: 'button', buttonIndex };
}
if (input.kind === 'button') {
const direction = isStandardMapping(context.mapping)
? STANDARD_DPAD_BUTTONS[input.buttonIndex]
: undefined;
return direction ? { kind: 'dpad', direction } : input;
}
// Non-standard pads usually report the D-pad as axes 6 (left/right) and 7 (up/down).
if (!isStandardMapping(context.mapping) && (input.axisIndex === 6 || input.axisIndex === 7)) {
const negative = input.axisIndex === 6 ? 'left' : 'up';
const positive = input.axisIndex === 6 ? 'right' : 'down';
return { kind: 'dpad', direction: input.direction === 'negative' ? negative : positive };
}
return input;
}
export function controllerReviewInputSourceKey(source: ControllerReviewInputSource): string {
if (source.kind === 'dpad') return `dpad:${source.direction}`;
if (source.kind === 'button') return `button:${source.buttonIndex}`;
return `axis:${source.axisIndex}:${source.direction}`;
}
export type CompiledControllerScreenBinding<A extends string = string> = {
action: A;
repeat: boolean;
press: ControllerReviewInputSource;
pressKey: string;
hold: ControllerReviewInputSource | null;
holdKey: string | null;
};
export type CompiledControllerReviewBinding =
CompiledControllerScreenBinding<ControllerReviewAction>;
/** The bindings active on one step of a screen, resolved to physical inputs, in Alt+C order. */
export function compileControllerScreenBindings<A extends string, S extends string>(
screen: ControllerScreenDefinition<A, S>,
bindings: Partial<ControllerScreenBindings<A>>,
step: S,
context: ControllerReviewInputContext,
): CompiledControllerScreenBinding<A>[] {
const compiled: CompiledControllerScreenBinding<A>[] = [];
for (const action of screen.actions) {
if (action.step !== step) continue;
const binding = bindings[action.id];
if (!binding || binding.kind !== 'combo') continue;
const press = resolveControllerReviewInput(binding.press, context);
const hold = binding.hold ? resolveControllerReviewInput(binding.hold, context) : null;
compiled.push({
action: action.id,
repeat: action.repeat,
press,
pressKey: controllerReviewInputSourceKey(press),
hold,
holdKey: hold ? controllerReviewInputSourceKey(hold) : null,
});
}
return compiled;
}
/** The review bindings active on one step, resolved to physical inputs, in Alt+C order. */
export function compileControllerReviewBindings(
bindings: ResolvedControllerReviewBindings,
step: ControllerReviewStep,
context: ControllerReviewInputContext,
): CompiledControllerReviewBinding[] {
return compileControllerScreenBindings(CONTROLLER_REVIEW_SCREEN, bindings, step, context);
}
const DPAD_GLYPHS: Record<ControllerDpadDirection, string> = {
left: '◀',
right: '▶',
up: '▲',
down: '▼',
};
const LETTER_AT_POSITION: Record<ControllerFaceButtonLabels, Record<FacePosition, string>> = {
xbox: { south: 'A', east: 'B', west: 'X', north: 'Y' },
nintendo: { south: 'B', east: 'A', west: 'Y', north: 'X' },
};
const FACE_POSITION_BY_INDEX: Record<number, FacePosition> = {
0: 'south',
1: 'east',
2: 'west',
3: 'north',
};
const STANDARD_BUTTON_NAMES: Record<number, string> = {
4: 'L1',
5: 'R1',
6: 'L2',
7: 'R2',
8: 'Select',
9: 'Start',
10: 'L3',
11: 'R3',
16: 'Home',
};
const RAW_BUTTON_NAMES: Record<number, string> = {
4: 'L1',
5: 'R1',
6: 'Back',
7: 'Start',
8: 'L3',
9: 'R3',
};
type AxisName = { name: string; horizontal: boolean } | { name: string; trigger: true };
const STANDARD_AXIS_NAMES: Record<number, AxisName> = {
0: { name: 'LS', horizontal: true },
1: { name: 'LS', horizontal: false },
2: { name: 'RS', horizontal: true },
3: { name: 'RS', horizontal: false },
};
const RAW_AXIS_NAMES: Record<number, AxisName> = {
0: { name: 'LS', horizontal: true },
1: { name: 'LS', horizontal: false },
2: { name: 'L2', trigger: true },
3: { name: 'RS', horizontal: true },
4: { name: 'RS', horizontal: false },
5: { name: 'R2', trigger: true },
};
function formatSource(
source: ControllerReviewInputSource,
context: ControllerReviewInputContext,
dpadPrefix: boolean,
): string {
if (source.kind === 'dpad') {
return `${dpadPrefix ? 'D-pad ' : ''}${DPAD_GLYPHS[source.direction]}`;
}
const standard = isStandardMapping(context.mapping);
if (source.kind === 'button') {
const position = FACE_POSITION_BY_INDEX[source.buttonIndex];
if (position) {
return (LETTER_AT_POSITION[context.faceButtonLabels] ?? LETTER_AT_POSITION.xbox)[position];
}
const names = standard ? STANDARD_BUTTON_NAMES : RAW_BUTTON_NAMES;
return names[source.buttonIndex] ?? `Button ${source.buttonIndex}`;
}
const axis = (standard ? STANDARD_AXIS_NAMES : RAW_AXIS_NAMES)[source.axisIndex];
const sign = source.direction === 'negative' ? '−' : '+';
if (!axis) return `Axis ${source.axisIndex} ${sign}`;
if ('trigger' in axis) return axis.name;
const glyph = axis.horizontal
? source.direction === 'negative'
? DPAD_GLYPHS.left
: DPAD_GLYPHS.right
: source.direction === 'negative'
? DPAD_GLYPHS.up
: DPAD_GLYPHS.down;
return `${axis.name} ${glyph}`;
}
/** Short name for one input, e.g. `A`, `L1`, `◀` (or `D-pad ◀`), `LS ▲`. */
export function formatControllerReviewInput(
input: ControllerReviewInput,
context: ControllerReviewInputContext,
options: { dpadPrefix?: boolean } = {},
): string {
// Letters are shown as printed, whatever position they resolve to.
if (input.kind === 'face') return input.letter.toUpperCase();
return formatSource(
resolveControllerReviewInput(input, context),
context,
options.dpadPrefix ?? false,
);
}
/** `None`, `A`, or `L1 + D-pad ▲` style label for a whole binding. */
export function formatControllerReviewBinding(
binding: ResolvedControllerReviewBinding,
context: ControllerReviewInputContext,
options: { dpadPrefix?: boolean } = {},
): string {
if (binding.kind === 'none') return 'None';
const press = formatControllerReviewInput(binding.press, context, options);
return binding.hold
? `${formatControllerReviewInput(binding.hold, context, options)} + ${press}`
: press;
}
/**
* Problems Alt+C should point out, per action: two actions on the same input in one step,
* or a button used alone that other actions on that step use as their hold button.
*/
export function findControllerScreenBindingIssues<A extends string, S extends string>(
screen: ControllerScreenDefinition<A, S>,
bindings: Partial<ControllerScreenBindings<A>>,
context: ControllerReviewInputContext,
): Partial<Record<A, string>> {
const issues: Partial<Record<A, string>> = {};
const label = (id: A) => getControllerScreenAction(screen, id).label;
for (const step of screen.steps) {
const compiled = compileControllerScreenBindings(screen, bindings, step.id, context);
const holdKeys = new Map<string, ControllerReviewInputSource>();
for (const entry of compiled) {
if (entry.hold && entry.holdKey) holdKeys.set(entry.holdKey, entry.hold);
}
const firstByInput = new Map<string, A>();
for (const entry of compiled) {
if (entry.holdKey === entry.pressKey) {
issues[entry.action] = 'Hold and press are the same button';
continue;
}
const inputKey = `${entry.holdKey ?? ''}|${entry.pressKey}`;
const first = firstByInput.get(inputKey);
if (first) {
issues[entry.action] = `Same as ${label(first)}`;
issues[first] ??= `Same as ${label(entry.action)}`;
continue;
}
firstByInput.set(inputKey, entry.action);
const holdSource = entry.holdKey === null ? holdKeys.get(entry.pressKey) : undefined;
if (holdSource) {
issues[entry.action] =
`${formatSource(holdSource, context, true)} is also a hold button on this step`;
}
}
}
return issues;
}
export function findControllerReviewBindingIssues(
bindings: ResolvedControllerReviewBindings,
context: ControllerReviewInputContext,
): Partial<Record<ControllerReviewAction, string>> {
return findControllerScreenBindingIssues(CONTROLLER_REVIEW_SCREEN, bindings, context);
}
+40
View File
@@ -0,0 +1,40 @@
import { CONTROLLER_FIELD_GROUPING_SCREEN } from './controller-field-grouping';
import {
CONTROLLER_REVIEW_SCREEN,
isControllerScreenAction,
parseControllerReviewBinding,
type ControllerScreenBindings,
type ControllerScreenDefinition,
} from './controller-review';
/** Every dialog with its own remappable controller layout, in Alt+C order. */
export const CONTROLLER_SCREENS: readonly ControllerScreenDefinition[] = [
CONTROLLER_REVIEW_SCREEN as unknown as ControllerScreenDefinition,
CONTROLLER_FIELD_GROUPING_SCREEN as unknown as ControllerScreenDefinition,
];
export type ControllerScreenId = ControllerScreenDefinition['id'];
export type ControllerScreenBindingsKey = ControllerScreenDefinition['configKey'];
export function getControllerScreen(id: ControllerScreenId): ControllerScreenDefinition {
return CONTROLLER_SCREENS.find((screen) => screen.id === id)!;
}
/**
* Parses a per-action patch for one screen (`{ "continue": { … } }`). Returns null when any
* key is not an action of that screen or any binding is malformed.
*/
export function parseControllerScreenBindingsPatch(
screen: ControllerScreenDefinition,
value: unknown,
): Partial<ControllerScreenBindings> | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
const bindings: Partial<ControllerScreenBindings> = {};
for (const [action, rawBinding] of Object.entries(value)) {
if (!isControllerScreenAction(screen, action)) return null;
const parsed = parseControllerReviewBinding(rawBinding);
if (!parsed) return null;
bindings[action] = parsed;
}
return bindings;
}
+1
View File
@@ -158,6 +158,7 @@ export const IPC_CHANNELS = {
playlistBrowserOpen: 'playlist-browser:open',
sessionNumericSelectionStart: 'session:numeric-selection-start',
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
controllerSuspendedChanged: 'controller:suspended-changed',
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
sessionHelpOpen: 'session-help:open',
changelogOpen: 'changelog:open',
+17
View File
@@ -17,6 +17,7 @@ import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-op
import type { SessionActionId, SessionActionPayload } from '../../types/session-bindings';
import type { SubtitlePosition } from '../../types/subtitle';
import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts';
import { CONTROLLER_SCREENS, parseControllerScreenBindingsPatch } from '../controller-screens';
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
@@ -242,6 +243,20 @@ function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindin
return bindings;
}
/** Copies each screen's per-action patch (`reviewBindings`, `fieldGroupingBindings`). */
function parseControllerScreenBindingPatches(
source: Record<string, unknown>,
target: Pick<ControllerConfigUpdate, 'reviewBindings' | 'fieldGroupingBindings'>,
): boolean {
for (const screen of CONTROLLER_SCREENS) {
if (source[screen.configKey] === undefined) continue;
const parsed = parseControllerScreenBindingsPatch(screen, source[screen.configKey]);
if (!parsed) return false;
(target as Record<string, unknown>)[screen.configKey] = parsed;
}
return true;
}
export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpdate | null {
if (!isObject(value)) return null;
const update: ControllerConfigUpdate = {};
@@ -269,6 +284,7 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
if (!parsed) return null;
update.bindings = parsed;
}
if (!parseControllerScreenBindingPatches(value, update)) return null;
if (value.profiles !== undefined) {
if (!isObject(value.profiles)) return null;
@@ -291,6 +307,7 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
if (!parsed) return null;
profile.bindings = parsed;
}
if (!parseControllerScreenBindingPatches(rawProfile, profile)) return null;
profiles[profileId] = profile;
}
update.profiles = profiles;
+6
View File
@@ -26,8 +26,11 @@ import type {
ControllerConfig,
ResolvedControllerProfileConfig,
ControllerTriggerInputMode,
ControllerFaceButtonLabels,
Keybinding,
ResolvedControllerBindingsConfig,
ResolvedControllerFieldGroupingBindings,
ResolvedControllerReviewBindings,
} from './runtime';
import type {
FrequencyDictionaryMatchMode,
@@ -221,11 +224,14 @@ export interface ResolvedConfig {
horizontalJumpPixels: number;
stickDeadzone: number;
triggerInputMode: ControllerTriggerInputMode;
faceButtonLabels: ControllerFaceButtonLabels;
triggerDeadzone: number;
repeatDelayMs: number;
repeatIntervalMs: number;
buttonIndices: Required<ControllerButtonIndicesConfig>;
bindings: Required<ResolvedControllerBindingsConfig>;
reviewBindings: ResolvedControllerReviewBindings;
fieldGroupingBindings: ResolvedControllerFieldGroupingBindings;
profiles: Record<string, ResolvedControllerProfileConfig>;
};
ankiConnect: AnkiConnectConfig & {
+87
View File
@@ -176,6 +176,8 @@ export type ControllerButtonBinding =
export type ControllerAxisBinding = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY';
export type ControllerTriggerInputMode = 'auto' | 'digital' | 'analog';
/** Letters printed on the face buttons: Xbox-style (A at the bottom) or Nintendo-style (A on the right). */
export type ControllerFaceButtonLabels = 'xbox' | 'nintendo';
export type ControllerAxisDirection = 'negative' | 'positive';
export type ControllerDpadFallback = 'none' | 'horizontal' | 'vertical';
@@ -276,16 +278,97 @@ export interface ControllerButtonIndicesConfig {
rightTrigger?: number;
}
/** Every button on the media timing review dialog, named by what it does. */
export type ControllerReviewAction =
| 'confirm'
| 'cancel'
| 'play'
| 'resetTiming'
| 'showEarlier'
| 'showLater'
| 'framePrevious'
| 'frameNext'
| 'frameReset'
| 'startBack'
| 'startForward'
| 'previousLineAdd'
| 'previousLineRemove'
| 'endBack'
| 'endForward'
| 'nextLineAdd'
| 'nextLineRemove'
| 'useOriginal'
| 'keepEditing'
| 'skipMedia'
| 'discard';
/** Every button on the duplicate-card field grouping dialog, named by what it does. */
export type ControllerFieldGroupingAction =
| 'selectOriginal'
| 'selectNew'
| 'toggleDeleteDuplicate'
| 'continue'
| 'cancel'
| 'confirmMerge'
| 'back'
| 'cancelMerge'
| 'toggleView'
| 'scrollUp'
| 'scrollDown';
export type ControllerFaceLetter = 'a' | 'b' | 'x' | 'y';
export type ControllerDpadDirection = 'left' | 'right' | 'up' | 'down';
/**
* One input in a review binding. `button` and `axis` are raw indices recorded by Learn;
* `face` (printed letter, via `faceButtonLabels`) and `dpad` (any D-pad report) keep the
* defaults working on every controller layout.
*/
export type ControllerReviewInput =
| { kind: 'button'; buttonIndex: number }
| { kind: 'axis'; axisIndex: number; direction: ControllerAxisDirection }
| { kind: 'face'; letter: ControllerFaceLetter }
| { kind: 'dpad'; direction: ControllerDpadDirection };
/** Press `press`, optionally while holding `hold`. */
export type ControllerReviewBindingConfig =
| { kind: 'none' }
| { kind?: 'combo'; press: ControllerReviewInput; hold?: ControllerReviewInput | null };
export type ResolvedControllerReviewBinding =
| { kind: 'none' }
| { kind: 'combo'; press: ControllerReviewInput; hold: ControllerReviewInput | null };
export type ControllerReviewBindingsConfig = Partial<
Record<ControllerReviewAction, ControllerReviewBindingConfig>
>;
export type ResolvedControllerReviewBindings = Record<
ControllerReviewAction,
ResolvedControllerReviewBinding
>;
export type ControllerFieldGroupingBindingsConfig = Partial<
Record<ControllerFieldGroupingAction, ControllerReviewBindingConfig>
>;
export type ResolvedControllerFieldGroupingBindings = Record<
ControllerFieldGroupingAction,
ResolvedControllerReviewBinding
>;
export interface ControllerProfileConfig {
label?: string;
buttonIndices?: ControllerButtonIndicesConfig;
bindings?: ControllerBindingsConfig;
reviewBindings?: ControllerReviewBindingsConfig;
fieldGroupingBindings?: ControllerFieldGroupingBindingsConfig;
}
export interface ResolvedControllerProfileConfig {
label: string;
buttonIndices: Required<ControllerButtonIndicesConfig>;
bindings: Required<ResolvedControllerBindingsConfig>;
reviewBindings: ResolvedControllerReviewBindings;
fieldGroupingBindings: ResolvedControllerFieldGroupingBindings;
}
export interface ControllerConfig {
@@ -297,11 +380,14 @@ export interface ControllerConfig {
horizontalJumpPixels?: number;
stickDeadzone?: number;
triggerInputMode?: ControllerTriggerInputMode;
faceButtonLabels?: ControllerFaceButtonLabels;
triggerDeadzone?: number;
repeatDelayMs?: number;
repeatIntervalMs?: number;
buttonIndices?: ControllerButtonIndicesConfig;
bindings?: ControllerBindingsConfig;
reviewBindings?: ControllerReviewBindingsConfig;
fieldGroupingBindings?: ControllerFieldGroupingBindingsConfig;
profiles?: Record<string, ControllerProfileConfig>;
}
@@ -586,6 +672,7 @@ export interface ElectronAPI {
callback: (payload: SessionNumericSelectionStartPayload) => void,
) => void;
onKeyboardModeToggleRequested: (callback: () => void) => void;
onControllerSuspendedChanged: (callback: (suspended: boolean) => void) => void;
onLookupWindowToggleRequested: (callback: () => void) => void;
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (path: string) => Promise<PlaylistBrowserMutationResult>;