feat(controller): add inline remap modal with descriptor-based bindings (#21)

This commit is contained in:
2026-03-15 15:55:45 -07:00
committed by GitHub
parent 9eed37420e
commit 478869ff28
38 changed files with 3136 additions and 1431 deletions

View File

@@ -33,6 +33,50 @@ function createFakeIpcRegistrar(): {
};
}
function createControllerConfigFixture() {
return {
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto' as const,
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: { kind: 'button' as const, buttonIndex: 0 },
closeLookup: { kind: 'button' as const, buttonIndex: 1 },
toggleKeyboardOnlyMode: { kind: 'button' as const, buttonIndex: 3 },
mineCard: { kind: 'button' as const, buttonIndex: 2 },
quitMpv: { kind: 'button' as const, buttonIndex: 6 },
previousAudio: { kind: 'button' as const, buttonIndex: 4 },
nextAudio: { kind: 'button' as const, buttonIndex: 5 },
playCurrentAudio: { kind: 'button' as const, buttonIndex: 7 },
toggleMpvPause: { kind: 'button' as const, buttonIndex: 6 },
leftStickHorizontal: { kind: 'axis' as const, axisIndex: 0, dpadFallback: 'horizontal' as const },
leftStickVertical: { kind: 'axis' as const, axisIndex: 1, dpadFallback: 'vertical' as const },
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
},
};
}
test('createIpcDepsRuntime wires AniList handlers', async () => {
const calls: string[] = [];
const deps = createIpcDepsRuntime({
@@ -53,47 +97,8 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
}),
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {},
saveControllerPreference: () => {},
getSecondarySubMode: () => 'hover',
getMpvClient: () => null,
@@ -159,47 +164,8 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
}),
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {},
saveControllerPreference: () => {},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
@@ -299,47 +265,10 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
}),
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: (update) => {
controllerSaves.push(update);
},
saveControllerPreference: (update) => {
controllerSaves.push(update);
},
@@ -400,47 +329,8 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
}),
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {},
saveControllerPreference: async (update) => {
await Promise.resolve();
controllerSaves.push(update);
@@ -486,6 +376,85 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
]);
});
test('registerIpcHandlers awaits saveControllerConfig through request-response IPC', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const controllerConfigSaves: unknown[] = [];
registerIpcHandlers(
{
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
toggleDevTools: () => {},
getVisibleOverlayVisibility: () => false,
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getPlaybackPaused: () => false,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: () => {},
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async (update) => {
await Promise.resolve();
controllerConfigSaves.push(update);
},
saveControllerPreference: async () => {},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
},
registrar,
);
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerConfig);
assert.ok(saveHandler);
await assert.rejects(
async () => {
await saveHandler!({}, { bindings: { toggleLookup: { kind: 'button', buttonIndex: -1 } } });
},
/Invalid controller config payload/,
);
await saveHandler!({}, {
preferredGamepadId: 'pad-2',
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
closeLookup: { kind: 'axis', axisIndex: 4, direction: 'negative' },
leftStickHorizontal: { kind: 'axis', axisIndex: 7, dpadFallback: 'none' },
},
});
assert.deepEqual(controllerConfigSaves, [
{
preferredGamepadId: 'pad-2',
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
closeLookup: { kind: 'axis', axisIndex: 4, direction: 'negative' },
leftStickHorizontal: { kind: 'axis', axisIndex: 7, dpadFallback: 'none' },
},
},
]);
});
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
registerIpcHandlers(
@@ -508,47 +477,8 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
}),
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {},
saveControllerPreference: async () => {},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',

View File

@@ -1,6 +1,7 @@
import electron from 'electron';
import type { IpcMainEvent } from 'electron';
import type {
ControllerConfigUpdate,
ControllerPreferenceUpdate,
ResolvedControllerConfig,
RuntimeOptionId,
@@ -12,6 +13,7 @@ import type {
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
import {
parseMpvCommand,
parseControllerConfigUpdate,
parseControllerPreferenceUpdate,
parseOptionalForwardingOptions,
parseOverlayHostedModal,
@@ -49,6 +51,7 @@ export interface IpcServiceDeps {
getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
getSecondarySubMode: () => unknown;
getCurrentSecondarySub: () => string;
@@ -114,6 +117,7 @@ export interface IpcDepsRuntimeOptions {
getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
getSecondarySubMode: () => unknown;
getMpvClient: () => MpvClientLike | null;
@@ -167,6 +171,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
getKeybindings: options.getKeybindings,
getConfiguredShortcuts: options.getConfiguredShortcuts,
getControllerConfig: options.getControllerConfig,
saveControllerConfig: options.saveControllerConfig,
saveControllerPreference: options.saveControllerPreference,
getSecondarySubMode: options.getSecondarySubMode,
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
@@ -276,6 +281,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
},
);
ipc.handle(IPC_CHANNELS.command.saveControllerConfig, async (_event: unknown, update: unknown) => {
const parsedUpdate = parseControllerConfigUpdate(update);
if (!parsedUpdate) {
throw new Error('Invalid controller config payload');
}
await deps.saveControllerConfig(parsedUpdate);
});
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
return deps.getMecabStatus();
});