diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 79007dd..1bbcca0 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -71,6 +71,20 @@ When a Yomitan popup is open, SubMiner also provides popup control shortcuts: | `[` | Play previous available audio (selected source) | | `]` | Play next available audio (selected source) | +## Keyboard-Driven Lookup Mode + +These shortcuts are fixed (not configurable) and require overlay focus. + +| Shortcut | Action | +| ------------------ | --------------------------------------------------------------------- | +| `Ctrl/Cmd+Shift+Y` | Toggle keyboard-driven token selection mode on/off | +| `Ctrl/Cmd+Y` | Toggle lookup popup for selected token (open when closed, close when open) | +| `ArrowLeft/Right`, `H`, or `L` | Move selected token (previous/next) | +| `ArrowUp` or `J` | Open lookup popup for selected token | +| `ArrowDown` | Close lookup popup | + +Keyboard-driven mode draws a selection outline around the active token. While keyboard-driven mode is enabled, `J` opens lookup and `H` moves to the previous token. Other popup-local keys still work (`M`, `P`, `[`, `]`). Focus is forced back to the overlay after lookup open/close so token navigation can continue without clicking subtitle text again. + ## Subtitle & Feature Shortcuts | Shortcut | Action | Config key | diff --git a/docs/usage.md b/docs/usage.md index 573b114..e23dfca 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -213,7 +213,9 @@ By default, hovering over subtitle text pauses mpv playback. Playback resumes as If you want playback to stay paused while a Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup` to `true`. When enabled, SubMiner auto-resumes on popup close only if SubMiner paused playback for that popup. -If the Yomitan popup is open, you can control it directly from the overlay: `J/K` scroll definitions, `M` mines/adds the selected term, `P` plays term audio, `[` plays the previous available audio, and `]` plays the next available audio in the selected source. +Keyboard-driven lookup mode is available with fixed shortcuts: `Ctrl/Cmd+Shift+Y` toggles token-selection mode, `ArrowLeft/Right` (or `H/L`) moves the selected token, `ArrowUp/J` opens lookup for the selected token, `ArrowDown` closes lookup, and `Ctrl/Cmd+Y` toggles lookup for that token. + +If the Yomitan popup is open, you can control it directly from the overlay: `J/K` scroll definitions, `M` mines/adds the selected term, `P` plays term audio, `[` plays the previous available audio, and `]` plays the next available audio in the selected source. In keyboard-driven lookup mode, `J` opens lookup, `H` moves to the previous token, `L` (or `ArrowRight`) moves to the next token, and `ArrowDown` closes lookup. ### Drag-and-drop Queueing diff --git a/launcher/aniskip-metadata.test.ts b/launcher/aniskip-metadata.test.ts index 9f00871..4118d0b 100644 --- a/launcher/aniskip-metadata.test.ts +++ b/launcher/aniskip-metadata.test.ts @@ -166,7 +166,9 @@ test('buildSubminerScriptOpts includes aniskip payload fields', () => { assert.match(opts, /subminer-aniskip_intro_end=62/); assert.match(opts, /subminer-aniskip_lookup_status=ready/); assert.ok(payloadMatch !== null); - const payload = JSON.parse(decodeURIComponent(payloadMatch[1])); + assert.equal(payloadMatch[1].includes('%'), false); + const payloadJson = Buffer.from(payloadMatch[1], 'base64url').toString('utf-8'); + const payload = JSON.parse(payloadJson); assert.equal(payload.found, true); const first = payload.results?.[0]; assert.equal(first.skip_type, 'op'); diff --git a/launcher/aniskip-metadata.ts b/launcher/aniskip-metadata.ts index 1382da2..0e03e4d 100644 --- a/launcher/aniskip-metadata.ts +++ b/launcher/aniskip-metadata.ts @@ -532,7 +532,9 @@ function buildLauncherAniSkipPayload(aniSkipMetadata: AniSkipMetadata): string | }, ], }; - return encodeURIComponent(JSON.stringify(payload)); + // mpv --script-opts treats `%` as an escape prefix, so URL-encoding can break parsing. + // Base64url stays script-opts-safe and is decoded by the plugin launcher payload parser. + return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url'); } export function buildSubminerScriptOpts( diff --git a/package.json b/package.json index bbd0957..776a187 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,8 @@ "test:plugin:src": "lua scripts/test-plugin-start-gate.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", - "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", + "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", + "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts new file mode 100644 index 0000000..ccbf41a --- /dev/null +++ b/src/renderer/handlers/keyboard.test.ts @@ -0,0 +1,441 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createKeyboardHandlers } from './keyboard.js'; +import { createRendererState } from '../state.js'; +import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js'; + +type CommandEventDetail = { + type?: string; + visible?: boolean; + key?: string; + code?: string; +}; + +function createClassList() { + const classes = new Set(); + return { + add: (...tokens: string[]) => { + for (const token of tokens) { + classes.add(token); + } + }, + remove: (...tokens: string[]) => { + for (const token of tokens) { + classes.delete(token); + } + }, + contains: (token: string) => classes.has(token), + }; +} + +function wait(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function installKeyboardTestGlobals() { + const previousWindow = (globalThis as { window?: unknown }).window; + const previousDocument = (globalThis as { document?: unknown }).document; + const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver; + const previousCustomEvent = (globalThis as { CustomEvent?: unknown }).CustomEvent; + const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent; + + const documentListeners = new Map void>>(); + const commandEvents: CommandEventDetail[] = []; + + let popupVisible = false; + + const popupIframe = { + tagName: 'IFRAME', + classList: { + contains: (token: string) => token === 'yomitan-popup', + }, + id: 'yomitan-popup-1', + getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), + }; + + const selection = { + removeAllRanges: () => {}, + addRange: () => {}, + }; + + const overlayFocusCalls: Array<{ preventScroll?: boolean }> = []; + let focusMainWindowCalls = 0; + let windowFocusCalls = 0; + + class TestCustomEvent extends Event { + detail: unknown; + + constructor(type: string, init?: { detail?: unknown }) { + super(type); + this.detail = init?.detail; + } + } + + class TestMouseEvent extends Event { + constructor(type: string) { + super(type); + } + } + + Object.defineProperty(globalThis, 'CustomEvent', { + configurable: true, + value: TestCustomEvent, + }); + + Object.defineProperty(globalThis, 'MouseEvent', { + configurable: true, + value: TestMouseEvent, + }); + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + addEventListener: () => {}, + dispatchEvent: (event: Event) => { + if (event.type === YOMITAN_POPUP_COMMAND_EVENT) { + const detail = (event as Event & { detail?: CommandEventDetail }).detail; + commandEvents.push(detail ?? {}); + } + return true; + }, + getComputedStyle: () => ({ + visibility: 'visible', + display: 'block', + opacity: '1', + }), + getSelection: () => selection, + focus: () => { + windowFocusCalls += 1; + }, + electronAPI: { + getKeybindings: async () => [], + sendMpvCommand: () => {}, + toggleDevTools: () => {}, + focusMainWindow: () => { + focusMainWindowCalls += 1; + return Promise.resolve(); + }, + }, + }, + }); + + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + addEventListener: (type: string, listener: (event: unknown) => void) => { + const listeners = documentListeners.get(type) ?? []; + listeners.push(listener); + documentListeners.set(type, listeners); + }, + querySelectorAll: () => { + if (popupVisible) { + return [popupIframe]; + } + return []; + }, + createRange: () => ({ + selectNodeContents: () => {}, + }), + body: {}, + }, + }); + + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: class { + observe() {} + }, + }); + + function dispatchKeydown(event: { + key: string; + code: string; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + repeat?: boolean; + }): void { + const listeners = documentListeners.get('keydown') ?? []; + const keyboardEvent = { + key: event.key, + code: event.code, + ctrlKey: event.ctrlKey ?? false, + metaKey: event.metaKey ?? false, + altKey: event.altKey ?? false, + shiftKey: event.shiftKey ?? false, + repeat: event.repeat ?? false, + preventDefault: () => {}, + target: null, + }; + for (const listener of listeners) { + listener(keyboardEvent); + } + } + + function dispatchFocusInOnPopup(): void { + const listeners = documentListeners.get('focusin') ?? []; + const focusEvent = { + target: popupIframe, + }; + for (const listener of listeners) { + listener(focusEvent); + } + } + + function restore() { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: previousMutationObserver, + }); + Object.defineProperty(globalThis, 'CustomEvent', { + configurable: true, + value: previousCustomEvent, + }); + Object.defineProperty(globalThis, 'MouseEvent', { + configurable: true, + value: previousMouseEvent, + }); + } + + const overlay = { + focus: (options?: { preventScroll?: boolean }) => { + overlayFocusCalls.push(options ?? {}); + }, + }; + + return { + commandEvents, + overlay, + overlayFocusCalls, + focusMainWindowCalls: () => focusMainWindowCalls, + windowFocusCalls: () => windowFocusCalls, + dispatchKeydown, + dispatchFocusInOnPopup, + setPopupVisible: (value: boolean) => { + popupVisible = value; + }, + restore, + }; +} + +function createKeyboardHandlerHarness() { + const testGlobals = installKeyboardTestGlobals(); + const subtitleRootClassList = createClassList(); + + const wordNodes = [ + { + classList: createClassList(), + getBoundingClientRect: () => ({ left: 10, top: 10, width: 30, height: 20 }), + dispatchEvent: () => true, + }, + { + classList: createClassList(), + getBoundingClientRect: () => ({ left: 80, top: 10, width: 30, height: 20 }), + dispatchEvent: () => true, + }, + { + classList: createClassList(), + getBoundingClientRect: () => ({ left: 150, top: 10, width: 30, height: 20 }), + dispatchEvent: () => true, + }, + ]; + + const ctx = { + dom: { + subtitleRoot: { + classList: subtitleRootClassList, + querySelectorAll: () => wordNodes, + }, + subtitleContainer: { + contains: () => false, + }, + overlay: testGlobals.overlay, + }, + platform: { + shouldToggleMouseIgnore: false, + isMacOSPlatform: false, + overlayLayer: 'always-on-top', + }, + state: createRendererState(), + }; + + const handlers = createKeyboardHandlers(ctx as never, { + handleRuntimeOptionsKeydown: () => false, + handleSubsyncKeydown: () => false, + handleKikuKeydown: () => false, + handleJimakuKeydown: () => false, + handleSessionHelpKeydown: () => false, + openSessionHelpModal: () => {}, + appendClipboardVideoToQueue: () => {}, + }); + + return { ctx, handlers, testGlobals }; +} + +test('keyboard mode: left and right move token selection while popup remains open', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + ctx.state.keyboardSelectedWordIndex = 1; + ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + + testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' }); + assert.equal(ctx.state.keyboardSelectedWordIndex, 2); + + testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' }); + assert.equal(ctx.state.keyboardSelectedWordIndex, 1); + await wait(0); + + const closeEvents = testGlobals.commandEvents.filter( + (event) => event.type === 'setVisible' && event.visible === false, + ); + assert.equal(closeEvents.length, 0); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('keyboard mode: up and j open yomitan lookup for selected token', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + testGlobals.dispatchKeydown({ key: 'ArrowUp', code: 'ArrowUp' }); + testGlobals.dispatchKeydown({ key: 'j', code: 'KeyJ' }); + + await wait(80); + + const openEvents = testGlobals.commandEvents.filter((event) => event.type === 'scanSelectedText'); + assert.equal(openEvents.length, 2); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('keyboard mode: down closes yomitan lookup window', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + + testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' }); + await wait(0); + + const closeEvents = testGlobals.commandEvents.filter( + (event) => event.type === 'setVisible' && event.visible === false, + ); + assert.equal(closeEvents.length, 1); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('keyboard mode: h moves left when popup is closed', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + ctx.state.keyboardSelectedWordIndex = 2; + ctx.state.yomitanPopupVisible = false; + testGlobals.setPopupVisible(false); + + testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' }); + assert.equal(ctx.state.keyboardSelectedWordIndex, 1); + + const closeEvents = testGlobals.commandEvents.filter( + (event) => event.type === 'setVisible' && event.visible === false, + ); + assert.equal(closeEvents.length, 0); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('keyboard mode: h moves left while popup is open and keeps lookup active', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + ctx.state.keyboardSelectedWordIndex = 2; + ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + + testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' }); + await wait(80); + + assert.equal(ctx.state.keyboardSelectedWordIndex, 1); + const openEvents = testGlobals.commandEvents.filter((event) => event.type === 'scanSelectedText'); + assert.equal(openEvents.length > 0, true); + const closeEvents = testGlobals.commandEvents.filter( + (event) => event.type === 'setVisible' && event.visible === false, + ); + assert.equal(closeEvents.length, 0); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('keyboard mode: opening lookup restores overlay keyboard focus', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + testGlobals.dispatchKeydown({ key: 'ArrowUp', code: 'ArrowUp' }); + await wait(0); + + assert.equal(testGlobals.focusMainWindowCalls() > 0, true); + assert.equal(testGlobals.windowFocusCalls() > 0, true); + assert.equal(testGlobals.overlayFocusCalls.length > 0, true); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('keyboard mode: popup iframe focusin reclaims overlay keyboard focus', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + testGlobals.setPopupVisible(true); + + const before = testGlobals.focusMainWindowCalls(); + testGlobals.dispatchFocusInOnPopup(); + await wait(260); + + assert.equal(testGlobals.focusMainWindowCalls() > before, true); + assert.equal(testGlobals.windowFocusCalls() > 0, true); + assert.equal(testGlobals.overlayFocusCalls.length > 0, true); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 7b64788..5dd4bfa 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -2,6 +2,7 @@ import type { Keybinding } from '../../types'; import type { RendererContext } from '../context'; import { YOMITAN_POPUP_HIDDEN_EVENT, + YOMITAN_POPUP_SHOWN_EVENT, YOMITAN_POPUP_COMMAND_EVENT, isYomitanPopupVisible, isYomitanPopupIframe, @@ -269,6 +270,13 @@ export function createKeyboardHandlers( const clientY = rect.top + rect.height / 2; dispatchYomitanFrontendScanSelectedText(); + if (ctx.state.keyboardDrivenModeEnabled) { + // Keep overlay as the keyboard focus owner so token navigation can continue + // while the popup is visible. + queueMicrotask(() => { + scheduleOverlayFocusReclaim(8); + }); + } // Fallback only if the explicit scan path did not open popup quickly. setTimeout(() => { if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { @@ -304,21 +312,92 @@ export function createKeyboardHandlers( ctx.dom.overlay.focus({ preventScroll: true }); } + function scheduleOverlayFocusReclaim(attempts: number = 0): void { + if (!ctx.state.keyboardDrivenModeEnabled) { + return; + } + restoreOverlayKeyboardFocus(); + if (attempts <= 0) { + return; + } + + let remaining = attempts; + const reclaim = () => { + if (!ctx.state.keyboardDrivenModeEnabled) { + return; + } + if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { + return; + } + restoreOverlayKeyboardFocus(); + remaining -= 1; + if (remaining > 0) { + setTimeout(reclaim, 25); + } + }; + + setTimeout(reclaim, 25); + } + function handleKeyboardDrivenModeNavigation(e: KeyboardEvent): boolean { if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) { return false; } const key = e.code; - if (key === 'ArrowLeft' || key === 'ArrowUp' || key === 'KeyH' || key === 'KeyK') { + if (key === 'ArrowLeft') { return moveKeyboardSelection(-1); } - if (key === 'ArrowRight' || key === 'ArrowDown' || key === 'KeyL' || key === 'KeyJ') { + if (key === 'ArrowRight' || key === 'KeyL') { return moveKeyboardSelection(1); } return false; } + function handleKeyboardDrivenModeLookupControls(e: KeyboardEvent): boolean { + if (!ctx.state.keyboardDrivenModeEnabled) { + return false; + } + if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) { + return false; + } + + const key = e.code; + const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document); + if (key === 'ArrowUp' || key === 'KeyJ') { + triggerLookupForSelectedWord(); + return true; + } + + if (key === 'ArrowDown') { + if (popupVisible) { + dispatchYomitanPopupVisibility(false); + queueMicrotask(() => { + restoreOverlayKeyboardFocus(); + }); + } + return true; + } + + if (key === 'ArrowLeft' || key === 'KeyH') { + moveKeyboardSelection(-1); + if (popupVisible) { + triggerLookupForSelectedWord(); + } + return true; + } + + if (key === 'ArrowRight' || key === 'KeyL') { + moveKeyboardSelection(1); + if (popupVisible) { + triggerLookupForSelectedWord(); + } + return true; + } + + return false; + } + function handleYomitanPopupKeybind(e: KeyboardEvent): boolean { if (e.repeat) return false; const modifierOnlyCodes = new Set([ @@ -415,6 +494,35 @@ export function createKeyboardHandlers( } restoreOverlayKeyboardFocus(); }); + window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => { + if (!ctx.state.keyboardDrivenModeEnabled) { + return; + } + queueMicrotask(() => { + scheduleOverlayFocusReclaim(8); + }); + }); + + document.addEventListener( + 'focusin', + (e: FocusEvent) => { + if (!ctx.state.keyboardDrivenModeEnabled) { + return; + } + const target = e.target; + if ( + target && + typeof target === 'object' && + 'tagName' in target && + isYomitanPopupIframe(target as Element) + ) { + queueMicrotask(() => { + scheduleOverlayFocusReclaim(8); + }); + } + }, + true, + ); document.addEventListener('keydown', (e: KeyboardEvent) => { if (isKeyboardDrivenModeToggle(e)) { @@ -429,6 +537,11 @@ export function createKeyboardHandlers( return; } + if (handleKeyboardDrivenModeLookupControls(e)) { + e.preventDefault(); + return; + } + if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { if (handleYomitanPopupKeybind(e)) { e.preventDefault();