From ae7e6f82a87382be84b34a653bc55f7f5ee764ea Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 14 Jun 2026 16:46:13 -0700 Subject: [PATCH] fix(overlay): restore macOS Yomitan popup focus without breaking click-away (#125) --- changes/macos-yomitan-popup-focus.md | 5 + src/main.ts | 9 + src/renderer/handlers/mouse.test.ts | 217 ++++++++++++++++++---- src/renderer/handlers/mouse.ts | 18 +- src/renderer/overlay-mouse-ignore.test.ts | 105 +++++++++++ src/renderer/overlay-mouse-ignore.ts | 1 + src/renderer/state.ts | 2 + 7 files changed, 317 insertions(+), 40 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..b5aa84d5 --- /dev/null +++ b/changes/macos-yomitan-popup-focus.md @@ -0,0 +1,5 @@ +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. +- Fixed macOS Yomitan popups staying open when clicking transparent overlay space; click-away is captured for popup close, then passthrough returns to mpv. 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..b9290f35 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -6,6 +6,8 @@ import { createMouseHandlers } from './mouse.js'; import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_HOST_SELECTOR, + YOMITAN_POPUP_MOUSE_ENTER_EVENT, + YOMITAN_POPUP_MOUSE_LEAVE_EVENT, YOMITAN_POPUP_SHOWN_EVENT, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR, } from '../yomitan-popup.js'; @@ -89,6 +91,7 @@ function createMouseTestContext() { state: { isOverSubtitle: false, isOverSubtitleSidebar: false, + isOverYomitanPopup: false, yomitanPopupVisible: false, subtitleSidebarModalOpen: false, subtitleSidebarConfig: null as SubtitleSidebarConfig | null, @@ -842,7 +845,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; @@ -853,8 +861,10 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o let focusMainWindowCalls = 0; let windowFocusCalls = 0; let overlayFocusCalls = 0; + let visiblePopupHostPresent = options.visiblePopupHost === true; ctx.platform.shouldToggleMouseIgnore = true; + ctx.platform.isMacOSPlatform = options.isMacOSPlatform === true; (ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => { overlayFocusCalls += 1; }; @@ -902,8 +912,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 + (visiblePopupHostPresent && selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) || + (visiblePopupHostPresent && selector === YOMITAN_POPUP_HOST_SELECTOR) ) { return [visiblePopupHost]; } @@ -927,46 +937,181 @@ 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, + setVisiblePopupHost: (visible: boolean) => { + visiblePopupHostPresent = visible; + }, + 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 and captures click-away', () => { + 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(); + } +}); + +test('popup mouse enter marks macOS Yomitan popup hover interactive', () => { + const harness = setupYomitanPopupFocusHarness({ + isMacOSPlatform: true, + visiblePopupHost: true, + }); + try { + harness.ignoreCalls.length = 0; + + for (const listener of harness.windowListeners.get(YOMITAN_POPUP_MOUSE_ENTER_EVENT) ?? []) { + listener(); + } + + assert.equal(harness.ctx.state.yomitanPopupVisible, true); + assert.equal(harness.ctx.state.isOverYomitanPopup, true); + assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]); + } finally { + harness.restore(); + } +}); + +test('popup mouse leave on macOS keeps click-away captured while popup remains visible', () => { + const harness = setupYomitanPopupFocusHarness({ + isMacOSPlatform: true, + visiblePopupHost: true, + }); + try { + for (const listener of harness.windowListeners.get(YOMITAN_POPUP_MOUSE_ENTER_EVENT) ?? []) { + listener(); + } + harness.ignoreCalls.length = 0; + + for (const listener of harness.windowListeners.get(YOMITAN_POPUP_MOUSE_LEAVE_EVENT) ?? []) { + listener(); + } + + assert.equal(harness.ctx.state.yomitanPopupVisible, true); + assert.equal(harness.ctx.state.isOverYomitanPopup, false); + assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]); + } finally { + harness.restore(); + } +}); + +test('popup hidden on macOS releases click-away capture back to mpv', () => { + const harness = setupYomitanPopupFocusHarness({ + isMacOSPlatform: true, + visiblePopupHost: true, + }); + try { + assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true); + harness.ignoreCalls.length = 0; + harness.setVisiblePopupHost(false); + + for (const listener of harness.windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) { + listener(); + } + + assert.equal(harness.ctx.state.yomitanPopupVisible, false); + assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), false); + assert.deepEqual(harness.ignoreCalls.at(-1), { ignore: true, forward: true }); + } finally { + harness.restore(); } }); diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index e15b4fa1..36959d0e 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; } @@ -305,6 +305,7 @@ export function createMouseHandlers( yomitanPopupVisible = false; ctx.state.yomitanPopupVisible = false; + ctx.state.isOverYomitanPopup = false; syncPrimaryVisibleOnYomitanPopupClass(false); popupPauseRequestId += 1; maybeResumeYomitanPopupPause(); @@ -378,7 +379,10 @@ export function createMouseHandlers( } hoverPauseRequestId += 1; maybeResumeHoverPause(); - if (yomitanPopupVisible) return; + if (yomitanPopupVisible) { + syncOverlayMouseIgnoreState(ctx); + return; + } disablePopupInteractionIfIdle(); } @@ -467,7 +471,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, () => { @@ -475,10 +483,12 @@ export function createMouseHandlers( }); window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => { + ctx.state.isOverYomitanPopup = true; reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true }); }); window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => { + ctx.state.isOverYomitanPopup = false; reconcilePopupInteraction({ assumeVisible: true }); }); @@ -491,7 +501,7 @@ export function createMouseHandlers( if (typeof document === 'undefined' || document.visibilityState !== 'visible') { return; } - reconcilePopupInteraction({ reclaimFocus: true }); + reconcilePopupInteraction({ reclaimFocus: !ctx.platform.isMacOSPlatform }); }); }); diff --git a/src/renderer/overlay-mouse-ignore.test.ts b/src/renderer/overlay-mouse-ignore.test.ts index 763247b6..8e67ef15 100644 --- a/src/renderer/overlay-mouse-ignore.test.ts +++ b/src/renderer/overlay-mouse-ignore.test.ts @@ -176,6 +176,111 @@ test('visible yomitan popup host keeps overlay interactive even when cached popu } }); +test('visible yomitan popup host on macOS keeps overlay interactive so click-away reaches popup', () => { + const classList = createClassList(); + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + + const restoreWindow = replaceGlobalProperty('window', { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + getComputedStyle: () => ({ + visibility: 'visible', + display: 'block', + opacity: '1', + }), + }); + const restoreDocument = replaceGlobalProperty('document', { + querySelectorAll: (selector: string) => + selector === + '[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]' + ? [{ getAttribute: () => 'true' }] + : [], + }); + + try { + syncOverlayMouseIgnoreState({ + dom: { + overlay: { classList }, + }, + platform: { + isMacOSPlatform: true, + shouldToggleMouseIgnore: true, + }, + state: { + isOverSubtitle: false, + isOverSubtitleSidebar: false, + isOverYomitanPopup: false, + yomitanPopupVisible: false, + controllerSelectModalOpen: false, + controllerDebugModalOpen: false, + jimakuModalOpen: false, + youtubePickerModalOpen: false, + kikuModalOpen: false, + runtimeOptionsModalOpen: false, + subsyncModalOpen: false, + sessionHelpModalOpen: false, + subtitleSidebarModalOpen: false, + subtitleSidebarConfig: null, + }, + } as never); + + assert.equal(classList.contains('interactive'), true); + assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]); + } finally { + restoreDocument(); + restoreWindow(); + } +}); + +test('macOS pointer over a visible yomitan popup keeps the overlay interactive', () => { + const classList = createClassList(); + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + + const restoreWindow = replaceGlobalProperty('window', { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + }); + + try { + syncOverlayMouseIgnoreState({ + dom: { + overlay: { classList }, + }, + platform: { + isMacOSPlatform: true, + shouldToggleMouseIgnore: true, + }, + state: { + isOverSubtitle: false, + isOverSubtitleSidebar: false, + isOverYomitanPopup: true, + yomitanPopupVisible: true, + controllerSelectModalOpen: false, + controllerDebugModalOpen: false, + jimakuModalOpen: false, + youtubePickerModalOpen: false, + kikuModalOpen: false, + runtimeOptionsModalOpen: false, + subsyncModalOpen: false, + sessionHelpModalOpen: false, + subtitleSidebarModalOpen: false, + subtitleSidebarConfig: null, + }, + } as never); + + assert.equal(classList.contains('interactive'), true); + assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]); + } finally { + restoreWindow(); + } +}); + test('Linux subtitle hover keeps root passive and does not report whole-window interactive hint', () => { const classList = createClassList(); const interactiveHints: boolean[] = []; diff --git a/src/renderer/overlay-mouse-ignore.ts b/src/renderer/overlay-mouse-ignore.ts index f06584ba..5a5d3b4b 100644 --- a/src/renderer/overlay-mouse-ignore.ts +++ b/src/renderer/overlay-mouse-ignore.ts @@ -31,6 +31,7 @@ export function syncOverlayMouseIgnoreState(ctx: RendererContext): void { const shouldStayInteractive = ctx.state.isOverSubtitle || ctx.state.isOverSubtitleSidebar || + ctx.state.isOverYomitanPopup || ctx.state.isOverOverlayNotification || ctx.state.isOverNotificationHistory || shouldKeepWindowInteractive; diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 34bd575b..2c4a9871 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -139,6 +139,7 @@ export type RendererState = { keyboardSelectionVisible: boolean; keyboardSelectedWordIndex: number | null; yomitanPopupVisible: boolean; + isOverYomitanPopup: boolean; primarySubtitleMode: PrimarySubMode; }; @@ -254,6 +255,7 @@ export function createRendererState(): RendererState { keyboardSelectionVisible: false, keyboardSelectedWordIndex: null, yomitanPopupVisible: false, + isOverYomitanPopup: false, primarySubtitleMode: 'visible', }; }