From f23e2f019efd186228e8c826dc253fd19a1b88c8 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 11 Mar 2026 19:11:18 -0700 Subject: [PATCH] Add controller support and harden overlay input handling - Ship v0.6.0 with keyboard-overlay gamepad support, controller modals, and detection indicator - Tighten controller config/IPC validation (integer button indices, reject malformed preference payloads) - Fix blocked-input edge handling and lookup/modal error paths to avoid stale UI state --- CHANGELOG.md | 11 ++ changes/config-example-drift-check.md | 4 - changes/controller-overlay-support.md | 7 -- package.json | 2 +- src/config/config.test.ts | 31 +++++ src/config/resolve/core-domains.ts | 4 +- src/core/services/ipc.test.ts | 99 ++++++++++++++- src/core/services/ipc.ts | 4 +- .../handlers/gamepad-controller.test.ts | 49 ++++++++ src/renderer/handlers/gamepad-controller.ts | 71 +++++++++++ src/renderer/handlers/keyboard.test.ts | 28 +++++ src/renderer/handlers/keyboard.ts | 11 +- src/renderer/modals/controller-select.test.ts | 114 ++++++++++++++++++ src/renderer/modals/controller-select.ts | 14 ++- vendor/subminer-yomitan | 2 +- 15 files changed, 421 insertions(+), 30 deletions(-) delete mode 100644 changes/config-example-drift-check.md delete mode 100644 changes/controller-overlay-support.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 999efb24..c7ae0cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## v0.6.0 (2026-03-12) + +### Added +- Overlay: Added Chrome Gamepad API controller support for keyboard-only overlay mode, including configurable logical bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, d-pad fallback navigation, and slower smooth popup scrolling. +- Overlay: Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection. +- Overlay: Added a transient in-overlay controller-detected indicator when a controller is first found. +- Overlay: Fixed stale keyboard-only token highlight cleanup when keyboard-only mode turns off or the Yomitan popup closes. + +### Internal +- Config: add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently + ## v0.5.6 (2026-03-10) ### Fixed diff --git a/changes/config-example-drift-check.md b/changes/config-example-drift-check.md deleted file mode 100644 index 4a5bd566..00000000 --- a/changes/config-example-drift-check.md +++ /dev/null @@ -1,4 +0,0 @@ -type: internal -area: config - -- add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently diff --git a/changes/controller-overlay-support.md b/changes/controller-overlay-support.md deleted file mode 100644 index 894a4e05..00000000 --- a/changes/controller-overlay-support.md +++ /dev/null @@ -1,7 +0,0 @@ -type: added -area: overlay - -- Added Chrome Gamepad API controller support for keyboard-only overlay mode, including configurable logical bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, d-pad fallback navigation, and slower smooth popup scrolling. -- Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection. -- Added a transient in-overlay controller-detected indicator when a controller is first found. -- Fixed stale keyboard-only token highlight cleanup when keyboard-only mode turns off or the Yomitan popup closes. diff --git a/package.json b/package.json index 1fca685b..da4af6f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subminer", - "version": "0.5.6", + "version": "0.6.0", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "packageManager": "bun@1.3.5", "main": "dist/main-entry.js", diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 26f7228e..1e609d30 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1204,6 +1204,37 @@ test('controller positive-number tuning rejects sub-unit values that floor to ze assert.equal(warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'), true); }); +test('controller button index config rejects fractional values', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "controller": { + "buttonIndices": { + "select": 6.5, + "leftStickPress": 9.1 + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.controller.buttonIndices.select, DEFAULT_CONFIG.controller.buttonIndices.select); + assert.equal( + config.controller.buttonIndices.leftStickPress, + DEFAULT_CONFIG.controller.buttonIndices.leftStickPress, + ); + assert.equal(warnings.some((warning) => warning.path === 'controller.buttonIndices.select'), true); + assert.equal( + warnings.some((warning) => warning.path === 'controller.buttonIndices.leftStickPress'), + 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 49a9ae6a..0fa1719f 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -209,8 +209,8 @@ export function applyCoreDomainConfig(context: ResolveContext): void { for (const key of buttonIndexKeys) { const value = asNumber(src.controller.buttonIndices[key]); - if (value !== undefined && value >= 0) { - resolved.controller.buttonIndices[key] = Math.floor(value); + if (value !== undefined && value >= 0 && Number.isInteger(value)) { + resolved.controller.buttonIndices[key] = value; } else if (src.controller.buttonIndices[key] !== undefined) { warn( `controller.buttonIndices.${key}`, diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 60046e9c..37287da6 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -467,7 +467,12 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference); assert.ok(saveHandler); - await saveHandler!({}, { preferredGamepadId: 12 }); + await assert.rejects( + async () => { + await saveHandler!({}, { preferredGamepadId: 12 }); + }, + /Invalid controller preference payload/, + ); await saveHandler!({}, { preferredGamepadId: 'pad-1', preferredGamepadLabel: 'Pad 1', @@ -480,3 +485,95 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon }, ]); }); + +test('registerIpcHandlers rejects malformed controller preference payloads', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + 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 () => {}, + 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); + await assert.rejects( + async () => { + await saveHandler!({}, { preferredGamepadId: 12 }); + }, + /Invalid controller preference payload/, + ); +}); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index c532a211..9cf7fdd2 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -267,7 +267,9 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar ipc.handle(IPC_CHANNELS.command.saveControllerPreference, async (_event: unknown, update: unknown) => { const parsedUpdate = parseControllerPreferenceUpdate(update); - if (!parsedUpdate) return; + if (!parsedUpdate) { + throw new Error('Invalid controller preference payload'); + } await deps.saveControllerPreference(parsedUpdate); }); diff --git a/src/renderer/handlers/gamepad-controller.test.ts b/src/renderer/handlers/gamepad-controller.test.ts index b8496e4a..9b472df3 100644 --- a/src/renderer/handlers/gamepad-controller.test.ts +++ b/src/renderer/handlers/gamepad-controller.test.ts @@ -208,6 +208,55 @@ test('gamepad controller does not toggle keyboard mode when controller support i assert.deepEqual(calls, []); }); +test('gamepad controller does not treat blocked held inputs as fresh edges when interaction resumes', () => { + const calls: string[] = []; + const selectionCalls: number[] = []; + const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false })); + buttons[0] = { value: 1, pressed: true, touched: true }; + let axes = [0.9, 0, 0, 0]; + let keyboardModeEnabled = true; + let interactionBlocked = true; + + const controller = createGamepadController({ + getGamepads: () => [createGamepad('pad-1', { buttons, axes })], + getConfig: () => createControllerConfig(), + getKeyboardModeEnabled: () => keyboardModeEnabled, + getLookupWindowOpen: () => false, + getInteractionBlocked: () => interactionBlocked, + toggleKeyboardMode: () => {}, + toggleLookup: () => calls.push('toggle-lookup'), + closeLookup: () => {}, + moveSelection: (delta) => selectionCalls.push(delta), + mineCard: () => {}, + quitMpv: () => {}, + previousAudio: () => {}, + nextAudio: () => {}, + playCurrentAudio: () => {}, + toggleMpvPause: () => {}, + scrollPopup: () => {}, + jumpPopup: () => {}, + onState: () => {}, + }); + + controller.poll(0); + interactionBlocked = false; + controller.poll(100); + + assert.deepEqual(calls, []); + assert.deepEqual(selectionCalls, []); + + buttons[0] = { value: 0, pressed: false, touched: false }; + axes = [0, 0, 0, 0]; + controller.poll(200); + + buttons[0] = { value: 1, pressed: true, touched: true }; + axes = [0.9, 0, 0, 0]; + controller.poll(300); + + assert.deepEqual(calls, ['toggle-lookup']); + assert.deepEqual(selectionCalls, [1]); +}); + 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 684ee721..3302d83e 100644 --- a/src/renderer/handlers/gamepad-controller.ts +++ b/src/renderer/handlers/gamepad-controller.ts @@ -226,6 +226,24 @@ function resetHeldAction(state: HoldState): void { state.initialFired = false; } +function syncHeldActionBlocked( + state: HoldState, + value: number, + now: number, + activationThreshold: number, +): void { + if (Math.abs(value) < activationThreshold) { + resetHeldAction(state); + return; + } + + const direction = value > 0 ? 1 : -1; + state.repeatStarted = false; + state.direction = direction; + state.lastFireAt = now; + state.initialFired = true; +} + export function createGamepadController(options: GamepadControllerOptions) { let previousButtons = new Map(); let selectionHold = createHoldState(); @@ -331,6 +349,58 @@ export function createGamepadController(options: GamepadControllerOptions) { } } + function syncBlockedInteractionState( + activeGamepad: GamepadLike, + config: ResolvedControllerConfig, + now: number, + ): void { + const buttonBindings = new Set([ + config.bindings.toggleKeyboardOnlyMode, + config.bindings.toggleLookup, + config.bindings.closeLookup, + config.bindings.mineCard, + config.bindings.quitMpv, + config.bindings.previousAudio, + config.bindings.nextAudio, + config.bindings.playCurrentAudio, + config.bindings.toggleMpvPause, + ]); + + for (const binding of buttonBindings) { + if (binding === 'none') continue; + previousButtons.set( + binding, + normalizeButtonState( + activeGamepad, + config, + binding, + config.triggerInputMode, + config.triggerDeadzone, + ), + ); + } + + const selectionValue = (() => { + const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal); + if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) { + return axisValue; + } + return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone); + })(); + syncHeldActionBlocked(selectionHold, selectionValue, now, Math.max(config.stickDeadzone, 0.55)); + + if (options.getLookupWindowOpen()) { + syncHeldActionBlocked( + jumpHold, + resolveAxisValue(activeGamepad, config.bindings.rightStickVertical), + now, + Math.max(config.stickDeadzone, 0.55), + ); + } else { + resetHeldAction(jumpHold); + } + } + function poll(now: number): void { const elapsedMs = lastPollAt === null ? 0 : Math.max(now - lastPollAt, 0); lastPollAt = now; @@ -365,6 +435,7 @@ export function createGamepadController(options: GamepadControllerOptions) { ); } if (!interactionAllowed) { + syncBlockedInteractionState(activeGamepad, config, now); return; } diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index afedd394..86dd5d31 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -744,6 +744,34 @@ test('keyboard mode: closing lookup clears yomitan active text source so same to } }); +test('keyboard mode: lookup toggle closes popup when DOM visibility is the source of truth', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + ctx.state.keyboardSelectedWordIndex = 1; + handlers.syncKeyboardTokenSelection(); + ctx.state.yomitanPopupVisible = false; + testGlobals.setPopupVisible(true); + + handlers.handleLookupWindowToggleRequested(); + await wait(0); + + const closeCommands = testGlobals.commandEvents.filter( + (event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource', + ); + assert.deepEqual(closeCommands.slice(-2), [ + { type: 'setVisible', visible: false }, + { type: 'clearActiveTextSource' }, + ]); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => { const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index ca8cc201..b43d3f37 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -435,15 +435,8 @@ export function createKeyboardHandlers( } function handleLookupWindowToggleRequested(): void { - if (ctx.state.yomitanPopupVisible) { - dispatchYomitanPopupVisibility(false); - dispatchYomitanFrontendClearActiveTextSource(); - clearNativeSubtitleSelection(); - if (ctx.state.keyboardDrivenModeEnabled) { - queueMicrotask(() => { - restoreOverlayKeyboardFocus(); - }); - } + if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { + closeLookupWindow(); return; } triggerLookupForSelectedWord(); diff --git a/src/renderer/modals/controller-select.test.ts b/src/renderer/modals/controller-select.test.ts index 45085cf7..d6ef448c 100644 --- a/src/renderer/modals/controller-select.test.ts +++ b/src/renderer/modals/controller-select.test.ts @@ -496,6 +496,120 @@ test('controller select modal preserves saved status across polling updates', as } }); +test('controller select modal surfaces save errors without mutating saved preference', 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 () => { + throw new Error('disk write failed'); + }, + 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-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(); + await modal.handleControllerSelectKeydown({ + key: 'Enter', + preventDefault: () => {}, + } as KeyboardEvent); + + assert.match(ctx.dom.controllerSelectStatus.textContent, /Failed to save preferred controller/); + assert.equal(state.controllerConfig.preferredGamepadId, 'pad-1'); + } 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 34d4a395..72b08665 100644 --- a/src/renderer/modals/controller-select.ts +++ b/src/renderer/modals/controller-select.ts @@ -154,10 +154,16 @@ export function createControllerSelectModal( return; } - await window.electronAPI.saveControllerPreference({ - preferredGamepadId: selected.id, - preferredGamepadLabel: selected.id, - }); + try { + await window.electronAPI.saveControllerPreference({ + preferredGamepadId: selected.id, + preferredGamepadLabel: selected.id, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setStatus(`Failed to save preferred controller: ${message}`, true); + return; + } if (ctx.state.controllerConfig) { ctx.state.controllerConfig.preferredGamepadId = selected.id; diff --git a/vendor/subminer-yomitan b/vendor/subminer-yomitan index da2ba5ce..994ab578 160000 --- a/vendor/subminer-yomitan +++ b/vendor/subminer-yomitan @@ -1 +1 @@ -Subproject commit da2ba5ceb15f48372035c5086e57f7235a89a7c1 +Subproject commit 994ab578ac272b1bfb6939263c399730238a9445