Stabilize overlay gamepad preference handling and controller UX

- Switch `saveControllerPreference` to request/response IPC and await async saves
- Fix controller modal/gamepad behavior: honor disabled controller mode, allow Alt+Shift+C over popup, prefer active pad in selector
- Preserve saved controller status during polling and harden controller config load failure handling
- Reject sub-unit positive controller tuning values that floor to zero with warnings
- Add/adjust regression tests across config, IPC, keyboard, gamepad, and controller-select flows
This commit is contained in:
2026-03-11 18:31:54 -07:00
parent 6e5bac45c9
commit 90b312ef69
16 changed files with 462 additions and 51 deletions

View File

@@ -1175,6 +1175,35 @@ test('parses controller settings with logical bindings and tuning knobs', () =>
assert.equal(config.controller.bindings.rightStickVertical, 'leftStickY'); assert.equal(config.controller.bindings.rightStickVertical, 'leftStickY');
}); });
test('controller positive-number tuning rejects sub-unit values that floor to zero', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"scrollPixelsPerSecond": 0.5,
"horizontalJumpPixels": 0.2,
"repeatDelayMs": 0.9,
"repeatIntervalMs": 0.1
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.controller.scrollPixelsPerSecond, DEFAULT_CONFIG.controller.scrollPixelsPerSecond);
assert.equal(config.controller.horizontalJumpPixels, DEFAULT_CONFIG.controller.horizontalJumpPixels);
assert.equal(config.controller.repeatDelayMs, DEFAULT_CONFIG.controller.repeatDelayMs);
assert.equal(config.controller.repeatIntervalMs, DEFAULT_CONFIG.controller.repeatIntervalMs);
assert.equal(warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatDelayMs'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'), true);
});
test('runtime options registry is centralized', () => { test('runtime options registry is centralized', () => {
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id); const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
assert.deepEqual(ids, [ assert.deepEqual(ids, [

View File

@@ -175,7 +175,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
] as const; ] as const;
for (const key of boundedNumberKeys) { for (const key of boundedNumberKeys) {
const value = asNumber(src.controller[key]); const value = asNumber(src.controller[key]);
if (value !== undefined && value > 0) { if (value !== undefined && Math.floor(value) > 0) {
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key]; resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) { } else if (src.controller[key] !== undefined) {
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected positive number.'); warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected positive number.');

View File

@@ -366,18 +366,6 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 42 }); handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 42 });
assert.deepEqual(saves, [{ yPercent: 42 }]); assert.deepEqual(saves, [{ yPercent: 42 }]);
handlers.on.get(IPC_CHANNELS.command.saveControllerPreference)!({}, { preferredGamepadId: 12 });
handlers.on.get(IPC_CHANNELS.command.saveControllerPreference)!({}, {
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'Pad 1',
});
assert.deepEqual(controllerSaves, [
{
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'Pad 1',
},
]);
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal'); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync'); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku'); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku');
@@ -388,3 +376,107 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options'); handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options');
assert.deepEqual(openedModals, ['subsync', 'runtime-options']); assert.deepEqual(openedModals, ['subsync', 'runtime-options']);
}); });
test('registerIpcHandlers awaits saveControllerPreference through request-response IPC', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const controllerSaves: 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: () => ({
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',
},
}),
saveControllerPreference: async (update) => {
await Promise.resolve();
controllerSaves.push(update);
},
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.saveControllerPreference);
assert.ok(saveHandler);
await saveHandler!({}, { preferredGamepadId: 12 });
await saveHandler!({}, {
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'Pad 1',
});
assert.deepEqual(controllerSaves, [
{
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'Pad 1',
},
]);
});

View File

@@ -49,7 +49,7 @@ export interface IpcServiceDeps {
getKeybindings: () => unknown; getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown; getConfiguredShortcuts: () => unknown;
getControllerConfig: () => ResolvedControllerConfig; getControllerConfig: () => ResolvedControllerConfig;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void; saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
getSecondarySubMode: () => unknown; getSecondarySubMode: () => unknown;
getCurrentSecondarySub: () => string; getCurrentSecondarySub: () => string;
focusMainWindow: () => void; focusMainWindow: () => void;
@@ -114,7 +114,7 @@ export interface IpcDepsRuntimeOptions {
getKeybindings: () => unknown; getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown; getConfiguredShortcuts: () => unknown;
getControllerConfig: () => ResolvedControllerConfig; getControllerConfig: () => ResolvedControllerConfig;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void; saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
getSecondarySubMode: () => unknown; getSecondarySubMode: () => unknown;
getMpvClient: () => MpvClientLike | null; getMpvClient: () => MpvClientLike | null;
focusMainWindow: () => void; focusMainWindow: () => void;
@@ -265,10 +265,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.saveSubtitlePosition(parsedPosition); deps.saveSubtitlePosition(parsedPosition);
}); });
ipc.on(IPC_CHANNELS.command.saveControllerPreference, (_event: unknown, update: unknown) => { ipc.handle(IPC_CHANNELS.command.saveControllerPreference, async (_event: unknown, update: unknown) => {
const parsedUpdate = parseControllerPreferenceUpdate(update); const parsedUpdate = parseControllerPreferenceUpdate(update);
if (!parsedUpdate) return; if (!parsedUpdate) return;
deps.saveControllerPreference(parsedUpdate); await deps.saveControllerPreference(parsedUpdate);
}); });
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => { ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {

View File

@@ -358,7 +358,8 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
import { registerIpcRuntimeServices } from './main/ipc-runtime'; import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies'; import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime'; import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService, type OverlayHostedModal } from './main/overlay-runtime'; import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import type { OverlayHostedModal } from './shared/ipc/contracts';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import { import {
createFrequencyDictionaryRuntimeService, createFrequencyDictionaryRuntimeService,

View File

@@ -1,16 +1,9 @@
import type { BrowserWindow } from 'electron'; import type { BrowserWindow } from 'electron';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { WindowGeometry } from '../types'; import type { WindowGeometry } from '../types';
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250; const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
type OverlayHostedModal =
| 'runtime-options'
| 'subsync'
| 'jimaku'
| 'kiku'
| 'controller-select'
| 'controller-debug';
export interface OverlayWindowResolver { export interface OverlayWindowResolver {
getMainWindow: () => BrowserWindow | null; getMainWindow: () => BrowserWindow | null;
getModalWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null;
@@ -300,5 +293,3 @@ export function createOverlayModalRuntimeService(
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose, getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
}; };
} }
export type { OverlayHostedModal };

View File

@@ -1,4 +1,4 @@
import type { OverlayHostedModal } from '../overlay-runtime'; import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue'; import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue';
export function createSetOverlayVisibleHandler(deps: { export function createSetOverlayVisibleHandler(deps: {

View File

@@ -1,5 +1,5 @@
import type { RuntimeOptionState } from '../../types'; import type { RuntimeOptionState } from '../../types';
import type { OverlayHostedModal } from '../overlay-runtime'; import type { OverlayHostedModal } from '../../shared/ipc/contracts';
type RuntimeOptionsManagerLike = { type RuntimeOptionsManagerLike = {
listOptions: () => RuntimeOptionState[]; listOptions: () => RuntimeOptionState[];

View File

@@ -210,7 +210,7 @@ const electronAPI: ElectronAPI = {
getControllerConfig: (): Promise<ResolvedControllerConfig> => getControllerConfig: (): Promise<ResolvedControllerConfig> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig), ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
saveControllerPreference: (update: ControllerPreferenceUpdate): Promise<void> => saveControllerPreference: (update: ControllerPreferenceUpdate): Promise<void> =>
Promise.resolve(ipcRenderer.send(IPC_CHANNELS.command.saveControllerPreference, update)), ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerPreference, update),
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> => getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo), ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo),

View File

@@ -177,6 +177,37 @@ test('gamepad controller allows keyboard-mode toggle while other actions stay ga
assert.deepEqual(calls, ['toggle-keyboard-mode']); assert.deepEqual(calls, ['toggle-keyboard-mode']);
}); });
test('gamepad controller does not toggle keyboard mode when controller support is disabled', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[3] = { value: 1, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () => createControllerConfig({ enabled: false }),
getKeyboardModeEnabled: () => false,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => calls.push('toggle-keyboard-mode'),
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, []);
});
test('gamepad controller maps left stick horizontal movement to token selection repeats', () => { test('gamepad controller maps left stick horizontal movement to token selection repeats', () => {
const calls: number[] = []; const calls: number[] = [];
let axes = [0.9, 0, 0, 0]; let axes = [0.9, 0, 0, 0];

View File

@@ -347,22 +347,23 @@ export function createGamepadController(options: GamepadControllerOptions) {
return; return;
} }
handleButtonEdge(
config.bindings.toggleKeyboardOnlyMode,
normalizeButtonState(
activeGamepad,
config,
config.bindings.toggleKeyboardOnlyMode,
config.triggerInputMode,
config.triggerDeadzone,
),
options.toggleKeyboardMode,
);
const interactionAllowed = const interactionAllowed =
config.enabled && config.enabled &&
options.getKeyboardModeEnabled() && options.getKeyboardModeEnabled() &&
!options.getInteractionBlocked(); !options.getInteractionBlocked();
if (config.enabled) {
handleButtonEdge(
config.bindings.toggleKeyboardOnlyMode,
normalizeButtonState(
activeGamepad,
config,
config.bindings.toggleKeyboardOnlyMode,
config.triggerInputMode,
config.triggerDeadzone,
),
options.toggleKeyboardMode,
);
}
if (!interactionAllowed) { if (!interactionAllowed) {
return; return;
} }

View File

@@ -510,6 +510,26 @@ test('keyboard mode: Alt+Shift+C opens controller debug modal', async () => {
} }
}); });
test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => {
const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.state.yomitanPopupVisible = true;
testGlobals.dispatchKeydown({
key: 'C',
code: 'KeyC',
altKey: true,
shiftKey: true,
});
assert.equal(controllerDebugOpenCount(), 1);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: controller select modal handles arrow keys before yomitan popup', async () => { test('keyboard mode: controller select modal handles arrow keys before yomitan popup', async () => {
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } = createKeyboardHandlerHarness(); const { ctx, testGlobals, handlers, controllerSelectKeydownCount } = createKeyboardHandlerHarness();

View File

@@ -177,6 +177,10 @@ export function createKeyboardHandlers(
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat; return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
} }
function isControllerModalShortcut(e: KeyboardEvent): boolean {
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
}
function getSubtitleWordNodes(): HTMLElement[] { function getSubtitleWordNodes(): HTMLElement[] {
return Array.from( return Array.from(
ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'), ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'),
@@ -790,7 +794,10 @@ export function createKeyboardHandlers(
return; return;
} }
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { if (
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
!isControllerModalShortcut(e)
) {
if (handleYomitanPopupKeybind(e)) { if (handleYomitanPopupKeybind(e)) {
e.preventDefault(); e.preventDefault();
} }
@@ -847,7 +854,7 @@ export function createKeyboardHandlers(
return; return;
} }
if (!e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC') { if (isControllerModalShortcut(e)) {
e.preventDefault(); e.preventDefault();
if (e.shiftKey) { if (e.shiftKey) {
options.openControllerDebugModal(); options.openControllerDebugModal();

View File

@@ -274,6 +274,228 @@ test('controller select modal preserves manual selection while controller pollin
} }
}); });
test('controller select modal prefers active controller over saved preferred controller', () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
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: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-2';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
assert.equal(state.controllerDeviceSelectedIndex, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal preserves saved status across polling updates', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
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: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-1';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
await modal.handleControllerSelectKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
modal.updateDevices();
assert.match(ctx.dom.controllerSelectStatus.textContent, /Saved preferred controller/);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal does not rerender unchanged device snapshots every poll', () => { test('controller select modal does not rerender unchanged device snapshots every poll', () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window; const previousWindow = globals.window;

View File

@@ -37,11 +37,17 @@ export function createControllerSelectModal(
function syncSelectedIndexToCurrentController(): void { function syncSelectedIndexToCurrentController(): void {
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? ''; const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
const nextIndex = ctx.state.connectedGamepads.findIndex( const activeIndex = ctx.state.connectedGamepads.findIndex(
(device) => device.id === ctx.state.activeGamepadId || device.id === preferredId, (device) => device.id === ctx.state.activeGamepadId,
); );
if (nextIndex >= 0) { if (activeIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = nextIndex; ctx.state.controllerDeviceSelectedIndex = activeIndex;
syncSelectedControllerId();
return;
}
const preferredIndex = ctx.state.connectedGamepads.findIndex((device) => device.id === preferredId);
if (preferredIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = preferredIndex;
syncSelectedControllerId(); syncSelectedControllerId();
return; return;
} }
@@ -132,7 +138,13 @@ export function createControllerSelectModal(
setStatus('No controllers detected.'); setStatus('No controllers detected.');
return; return;
} }
setStatus('Select a controller to save as preferred.'); const currentStatus = ctx.dom.controllerSelectStatus.textContent.trim();
if (
currentStatus !== 'No controller selected.' &&
!currentStatus.startsWith('Saved preferred controller:')
) {
setStatus('Select a controller to save as preferred.');
}
} }
async function saveSelectedController(): Promise<void> { async function saveSelectedController(): Promise<void> {

View File

@@ -540,7 +540,12 @@ async function init(): Promise<void> {
mouseHandlers.setupDragging(); mouseHandlers.setupDragging();
await keyboardHandlers.setupMpvInputForwarding(); await keyboardHandlers.setupMpvInputForwarding();
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig(); try {
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
} catch (error) {
console.error('Failed to load controller config.', error);
ctx.state.controllerConfig = null;
}
startControllerPolling(); startControllerPolling();
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle(); const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();