From 90b312ef69dbb1ea53e7334454817e652808f277 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 11 Mar 2026 18:31:54 -0700 Subject: [PATCH] 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 --- src/config/config.test.ts | 29 +++ src/config/resolve/core-domains.ts | 2 +- src/core/services/ipc.test.ts | 116 ++++++++- src/core/services/ipc.ts | 8 +- src/main.ts | 3 +- src/main/overlay-runtime.ts | 11 +- src/main/runtime/overlay-main-actions.ts | 2 +- .../runtime/overlay-runtime-main-actions.ts | 2 +- src/preload.ts | 2 +- .../handlers/gamepad-controller.test.ts | 31 +++ src/renderer/handlers/gamepad-controller.ts | 25 +- src/renderer/handlers/keyboard.test.ts | 20 ++ src/renderer/handlers/keyboard.ts | 11 +- src/renderer/modals/controller-select.test.ts | 222 ++++++++++++++++++ src/renderer/modals/controller-select.ts | 22 +- src/renderer/renderer.ts | 7 +- 16 files changed, 462 insertions(+), 51 deletions(-) diff --git a/src/config/config.test.ts b/src/config/config.test.ts index bdc13aa9..26f7228e 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1175,6 +1175,35 @@ test('parses controller settings with logical bindings and tuning knobs', () => 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', () => { const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id); assert.deepEqual(ids, [ diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 0a350bda..49a9ae6a 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -175,7 +175,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void { ] as const; for (const key of boundedNumberKeys) { 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]; } else if (src.controller[key] !== undefined) { warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected positive number.'); diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 5c3efa5b..60046e9c 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -366,18 +366,6 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { 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)!({}, 'subsync'); 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'); 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', + }, + ]); +}); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 9c614359..c532a211 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -49,7 +49,7 @@ export interface IpcServiceDeps { getKeybindings: () => unknown; getConfiguredShortcuts: () => unknown; getControllerConfig: () => ResolvedControllerConfig; - saveControllerPreference: (update: ControllerPreferenceUpdate) => void; + saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise; getSecondarySubMode: () => unknown; getCurrentSecondarySub: () => string; focusMainWindow: () => void; @@ -114,7 +114,7 @@ export interface IpcDepsRuntimeOptions { getKeybindings: () => unknown; getConfiguredShortcuts: () => unknown; getControllerConfig: () => ResolvedControllerConfig; - saveControllerPreference: (update: ControllerPreferenceUpdate) => void; + saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise; getSecondarySubMode: () => unknown; getMpvClient: () => MpvClientLike | null; focusMainWindow: () => void; @@ -265,10 +265,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar 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); if (!parsedUpdate) return; - deps.saveControllerPreference(parsedUpdate); + await deps.saveControllerPreference(parsedUpdate); }); ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => { diff --git a/src/main.ts b/src/main.ts index 2bd8fcd9..56157c96 100644 --- a/src/main.ts +++ b/src/main.ts @@ -358,7 +358,8 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command'; import { registerIpcRuntimeServices } from './main/ipc-runtime'; import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies'; 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 { createFrequencyDictionaryRuntimeService, diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 0e2d7419..366862bf 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -1,16 +1,9 @@ import type { BrowserWindow } from 'electron'; +import type { OverlayHostedModal } from '../shared/ipc/contracts'; import type { WindowGeometry } from '../types'; const MODAL_REVEAL_FALLBACK_DELAY_MS = 250; -type OverlayHostedModal = - | 'runtime-options' - | 'subsync' - | 'jimaku' - | 'kiku' - | 'controller-select' - | 'controller-debug'; - export interface OverlayWindowResolver { getMainWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null; @@ -300,5 +293,3 @@ export function createOverlayModalRuntimeService( getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose, }; } - -export type { OverlayHostedModal }; diff --git a/src/main/runtime/overlay-main-actions.ts b/src/main/runtime/overlay-main-actions.ts index e7fe1af5..fcfb0f8c 100644 --- a/src/main/runtime/overlay-main-actions.ts +++ b/src/main/runtime/overlay-main-actions.ts @@ -1,4 +1,4 @@ -import type { OverlayHostedModal } from '../overlay-runtime'; +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue'; export function createSetOverlayVisibleHandler(deps: { diff --git a/src/main/runtime/overlay-runtime-main-actions.ts b/src/main/runtime/overlay-runtime-main-actions.ts index 108921ad..f1b01528 100644 --- a/src/main/runtime/overlay-runtime-main-actions.ts +++ b/src/main/runtime/overlay-runtime-main-actions.ts @@ -1,5 +1,5 @@ import type { RuntimeOptionState } from '../../types'; -import type { OverlayHostedModal } from '../overlay-runtime'; +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; type RuntimeOptionsManagerLike = { listOptions: () => RuntimeOptionState[]; diff --git a/src/preload.ts b/src/preload.ts index 1d8b0bfd..7b0457a0 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -210,7 +210,7 @@ const electronAPI: ElectronAPI = { getControllerConfig: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig), saveControllerPreference: (update: ControllerPreferenceUpdate): Promise => - Promise.resolve(ipcRenderer.send(IPC_CHANNELS.command.saveControllerPreference, update)), + ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerPreference, update), getJimakuMediaInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo), diff --git a/src/renderer/handlers/gamepad-controller.test.ts b/src/renderer/handlers/gamepad-controller.test.ts index 588b80d1..b8496e4a 100644 --- a/src/renderer/handlers/gamepad-controller.test.ts +++ b/src/renderer/handlers/gamepad-controller.test.ts @@ -177,6 +177,37 @@ test('gamepad controller allows keyboard-mode toggle while other actions stay ga 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', () => { const calls: number[] = []; let axes = [0.9, 0, 0, 0]; diff --git a/src/renderer/handlers/gamepad-controller.ts b/src/renderer/handlers/gamepad-controller.ts index 39b05bfa..684ee721 100644 --- a/src/renderer/handlers/gamepad-controller.ts +++ b/src/renderer/handlers/gamepad-controller.ts @@ -347,22 +347,23 @@ export function createGamepadController(options: GamepadControllerOptions) { return; } - handleButtonEdge( - config.bindings.toggleKeyboardOnlyMode, - normalizeButtonState( - activeGamepad, - config, - config.bindings.toggleKeyboardOnlyMode, - config.triggerInputMode, - config.triggerDeadzone, - ), - options.toggleKeyboardMode, - ); - const interactionAllowed = config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked(); + if (config.enabled) { + handleButtonEdge( + config.bindings.toggleKeyboardOnlyMode, + normalizeButtonState( + activeGamepad, + config, + config.bindings.toggleKeyboardOnlyMode, + config.triggerInputMode, + config.triggerDeadzone, + ), + options.toggleKeyboardMode, + ); + } if (!interactionAllowed) { return; } diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 0e8e32cc..afedd394 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -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 () => { const { ctx, testGlobals, handlers, controllerSelectKeydownCount } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index c7ef7b6c..ca8cc201 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -177,6 +177,10 @@ export function createKeyboardHandlers( 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[] { return Array.from( ctx.dom.subtitleRoot.querySelectorAll('.word[data-token-index]'), @@ -790,7 +794,10 @@ export function createKeyboardHandlers( return; } - if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { + if ( + (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) && + !isControllerModalShortcut(e) + ) { if (handleYomitanPopupKeybind(e)) { e.preventDefault(); } @@ -847,7 +854,7 @@ export function createKeyboardHandlers( return; } - if (!e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC') { + if (isControllerModalShortcut(e)) { e.preventDefault(); if (e.shiftKey) { options.openControllerDebugModal(); diff --git a/src/renderer/modals/controller-select.test.ts b/src/renderer/modals/controller-select.test.ts index b3bff6b2..45085cf7 100644 --- a/src/renderer/modals/controller-select.test.ts +++ b/src/renderer/modals/controller-select.test.ts @@ -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', () => { const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; const previousWindow = globals.window; diff --git a/src/renderer/modals/controller-select.ts b/src/renderer/modals/controller-select.ts index 7724283a..34d4a395 100644 --- a/src/renderer/modals/controller-select.ts +++ b/src/renderer/modals/controller-select.ts @@ -37,11 +37,17 @@ export function createControllerSelectModal( function syncSelectedIndexToCurrentController(): void { const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? ''; - const nextIndex = ctx.state.connectedGamepads.findIndex( - (device) => device.id === ctx.state.activeGamepadId || device.id === preferredId, + const activeIndex = ctx.state.connectedGamepads.findIndex( + (device) => device.id === ctx.state.activeGamepadId, ); - if (nextIndex >= 0) { - ctx.state.controllerDeviceSelectedIndex = nextIndex; + if (activeIndex >= 0) { + ctx.state.controllerDeviceSelectedIndex = activeIndex; + syncSelectedControllerId(); + return; + } + const preferredIndex = ctx.state.connectedGamepads.findIndex((device) => device.id === preferredId); + if (preferredIndex >= 0) { + ctx.state.controllerDeviceSelectedIndex = preferredIndex; syncSelectedControllerId(); return; } @@ -132,7 +138,13 @@ export function createControllerSelectModal( setStatus('No controllers detected.'); 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 { diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index c8d0a733..dd21674a 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -540,7 +540,12 @@ async function init(): Promise { mouseHandlers.setupDragging(); 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(); const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();