From 0ff77eebc39b01876b8e491e4133a6077daf8bac Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 12 Jun 2026 23:03:43 -0700 Subject: [PATCH] fix(overlay): restore macOS Yomitan popup focus without breaking click-a - On macOS, skip blur-triggered focus reclaim so click-away closes popup cleanly - Reclaim focus on popup shown event instead (card mining / reload case) - Route focusMainWindow through focusMacOSOverlayWindow on darwin - Refactor blur/focus test into shared harness; add macOS-specific cases --- changes/macos-yomitan-popup-focus.md | 4 + src/main.ts | 9 ++ src/renderer/handlers/mouse.test.ts | 143 ++++++++++++++++++++------- src/renderer/handlers/mouse.ts | 10 +- 4 files changed, 127 insertions(+), 39 deletions(-) create mode 100644 changes/macos-yomitan-popup-focus.md diff --git a/changes/macos-yomitan-popup-focus.md b/changes/macos-yomitan-popup-focus.md new file mode 100644 index 00000000..0a4d9ae5 --- /dev/null +++ b/changes/macos-yomitan-popup-focus.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed macOS Yomitan popup focus after card mining or popup reload while still allowing click-away to close the popup without a hide/reappear cycle. diff --git a/src/main.ts b/src/main.ts index 638ec8d6..11829231 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5307,6 +5307,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ focusMainWindow: () => { const mainWindow = overlayManager.getMainWindow(); if (!mainWindow || mainWindow.isDestroyed()) return; + if (process.platform === 'darwin') { + focusMacOSOverlayWindow({ + platform: process.platform, + getOverlayWindow: () => mainWindow, + stealAppFocus: () => app.focus({ steal: true }), + warn: (message, details) => logger.warn(message, details), + }); + return; + } if (!mainWindow.isFocused()) { mainWindow.focus(); } diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index b8369225..0f7a7d51 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -842,7 +842,12 @@ test('nested popup close reasserts interactive state and focus when another popu } }); -test('window blur reclaims overlay focus while a yomitan popup remains visible on Windows', async () => { +function setupYomitanPopupFocusHarness( + options: { + isMacOSPlatform?: boolean; + visiblePopupHost?: boolean; + } = {}, +) { const ctx = createMouseTestContext(); const previousWindow = (globalThis as { window?: unknown }).window; const previousDocument = (globalThis as { document?: unknown }).document; @@ -855,6 +860,7 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o let overlayFocusCalls = 0; ctx.platform.shouldToggleMouseIgnore = true; + ctx.platform.isMacOSPlatform = options.isMacOSPlatform === true; (ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => { overlayFocusCalls += 1; }; @@ -902,8 +908,8 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o querySelector: () => null, querySelectorAll: (selector: string) => { if ( - selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR || - selector === YOMITAN_POPUP_HOST_SELECTOR + (options.visiblePopupHost === true && selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) || + (options.visiblePopupHost === true && selector === YOMITAN_POPUP_HOST_SELECTOR) ) { return [visiblePopupHost]; } @@ -927,46 +933,111 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o }, }); + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: () => {}, + }); + handlers.setupYomitanObserver(); + + return { + ctx, + windowListeners, + ignoreCalls, + focusMainWindowCalls: () => focusMainWindowCalls, + windowFocusCalls: () => windowFocusCalls, + overlayFocusCalls: () => overlayFocusCalls, + 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, 'Node', { configurable: true, value: previousNode }); + }, + }; +} + +test('window blur reclaims overlay focus while a yomitan popup remains visible on Windows', async () => { + const harness = setupYomitanPopupFocusHarness({ visiblePopupHost: true }); try { - const handlers = createMouseHandlers(ctx as never, { - modalStateReader: { - isAnySettingsModalOpen: () => false, - isAnyModalOpen: () => false, - }, - applyYPercent: () => {}, - getCurrentYPercent: () => 10, - persistSubtitlePositionPatch: () => {}, - getSubtitleHoverAutoPauseEnabled: () => false, - getYomitanPopupAutoPauseEnabled: () => false, - getPlaybackPaused: async () => false, - sendMpvCommand: () => {}, - }); + assert.equal(harness.ctx.state.yomitanPopupVisible, true); + assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]); + harness.ignoreCalls.length = 0; - handlers.setupYomitanObserver(); - assert.equal(ctx.state.yomitanPopupVisible, true); - assert.equal(ctx.dom.overlay.classList.contains('interactive'), true); - assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]); - ignoreCalls.length = 0; - - for (const listener of windowListeners.get('blur') ?? []) { + for (const listener of harness.windowListeners.get('blur') ?? []) { listener(); } await Promise.resolve(); - assert.equal(ctx.state.yomitanPopupVisible, true); - assert.equal(ctx.dom.overlay.classList.contains('interactive'), true); - assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]); - assert.equal(focusMainWindowCalls, 1); - assert.equal(windowFocusCalls, 1); - assert.equal(overlayFocusCalls, 1); + assert.equal(harness.ctx.state.yomitanPopupVisible, true); + assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]); + assert.equal(harness.focusMainWindowCalls(), 1); + assert.equal(harness.windowFocusCalls(), 1); + assert.equal(harness.overlayFocusCalls(), 1); } finally { - 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, 'Node', { configurable: true, value: previousNode }); + harness.restore(); + } +}); + +test('window blur on macOS keeps yomitan popup interactive without stealing click-away focus', async () => { + const harness = setupYomitanPopupFocusHarness({ + isMacOSPlatform: true, + visiblePopupHost: true, + }); + try { + assert.equal(harness.ctx.state.yomitanPopupVisible, true); + assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]); + harness.ignoreCalls.length = 0; + + for (const listener of harness.windowListeners.get('blur') ?? []) { + listener(); + } + await Promise.resolve(); + + assert.equal(harness.ctx.state.yomitanPopupVisible, true); + assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]); + assert.equal(harness.focusMainWindowCalls(), 0); + assert.equal(harness.windowFocusCalls(), 0); + assert.equal(harness.overlayFocusCalls(), 0); + } finally { + harness.restore(); + } +}); + +test('popup shown reclaims overlay focus on macOS', () => { + const harness = setupYomitanPopupFocusHarness({ isMacOSPlatform: true }); + try { + harness.ignoreCalls.length = 0; + + for (const listener of harness.windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) { + listener(); + } + + assert.equal(harness.ctx.state.yomitanPopupVisible, true); + assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]); + assert.equal(harness.focusMainWindowCalls(), 1); + assert.equal(harness.windowFocusCalls(), 1); + assert.equal(harness.overlayFocusCalls(), 1); + } finally { + harness.restore(); } }); diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index e15b4fa1..d322150b 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -67,7 +67,7 @@ export function createMouseHandlers( if (!ctx.platform.shouldToggleMouseIgnore) { return; } - if (ctx.platform.isMacOSPlatform || ctx.platform.isLinuxPlatform) { + if (ctx.platform.isLinuxPlatform) { return; } @@ -467,7 +467,11 @@ export function createMouseHandlers( reconcilePopupInteraction({ allowPause: true }); window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => { - reconcilePopupInteraction({ assumeVisible: true, allowPause: true }); + reconcilePopupInteraction({ + assumeVisible: true, + allowPause: true, + reclaimFocus: ctx.platform.isMacOSPlatform, + }); }); window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => { @@ -491,7 +495,7 @@ export function createMouseHandlers( if (typeof document === 'undefined' || document.visibilityState !== 'visible') { return; } - reconcilePopupInteraction({ reclaimFocus: true }); + reconcilePopupInteraction({ reclaimFocus: !ctx.platform.isMacOSPlatform }); }); });