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',