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',
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' } },
|
||||
});
|
||||
});
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 — Original Card</div>
|
||||
<div id="kikuCard1Label" class="kiku-card-label">1 — 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 — New Card</div>
|
||||
<div id="kikuCard2Label" class="kiku-card-label">2 — 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/,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user