mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
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:
1 parent
696462a82e
commit
6d0f37ac21
50 files changed
+4555
-46
No files matched your search
@@ -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'));
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user