diff --git a/changes/fix-hyprland-settings-window-z-order.md b/changes/fix-hyprland-settings-window-z-order.md new file mode 100644 index 00000000..972801f6 --- /dev/null +++ b/changes/fix-hyprland-settings-window-z-order.md @@ -0,0 +1,4 @@ +type: fixed +area: desktop + +- Fixed Hyprland settings windows opening behind the subtitle overlay by promoting SubMiner and Yomitan settings above the overlay without hiding subtitles. diff --git a/changes/fix-linux-app-command-detach.md b/changes/fix-linux-app-command-detach.md new file mode 100644 index 00000000..d0920680 --- /dev/null +++ b/changes/fix-linux-app-command-detach.md @@ -0,0 +1,4 @@ +type: fixed +area: launcher + +- Fixed `subminer app` on Linux so launching the tray app returns control to the terminal immediately instead of waiting for the tray process to exit. diff --git a/launcher/commands/app-command.ts b/launcher/commands/app-command.ts index 3073aa8f..adc58a5e 100644 --- a/launcher/commands/app-command.ts +++ b/launcher/commands/app-command.ts @@ -6,7 +6,6 @@ import { import type { LauncherCommandContext } from './context.js'; type AppCommandDeps = { - platform: () => NodeJS.Platform; runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void; launchAppBackgroundDetached: ( appPath: string, @@ -15,7 +14,6 @@ type AppCommandDeps = { }; const defaultAppCommandDeps: AppCommandDeps = { - platform: () => process.platform, runAppCommandWithInherit, launchAppBackgroundDetached, }; @@ -35,7 +33,7 @@ export function runAppPassthroughCommand( if (!args.appPassthrough) { return false; } - if (deps.platform() === 'darwin' && args.appArgs.length === 0) { + if (args.appArgs.length === 0) { deps.launchAppBackgroundDetached(appPath, args.logLevel); return true; } diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index aaab9188..0d5ae93f 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -176,7 +176,25 @@ test('app command starts default macOS background app detached from launcher', ( const calls: string[] = []; const handled = runAppPassthroughCommand(context, { - platform: () => 'darwin', + runAppCommandWithInherit: () => { + calls.push('attached'); + }, + launchAppBackgroundDetached: (appPath, logLevel) => { + calls.push(`detached:${appPath}:${logLevel}`); + }, + }); + + assert.equal(handled, true); + assert.deepEqual(calls, ['detached:/tmp/subminer.app:info']); +}); + +test('app command starts default Linux background app detached from launcher', () => { + const context = createContext(); + context.args.appPassthrough = true; + context.args.appArgs = []; + const calls: string[] = []; + + const handled = runAppPassthroughCommand(context, { runAppCommandWithInherit: () => { calls.push('attached'); }, @@ -197,7 +215,6 @@ test('app command keeps explicit passthrough args attached', () => { const detached: string[] = []; const handled = runAppPassthroughCommand(context, { - platform: () => 'darwin', runAppCommandWithInherit: (_appPath, appArgs) => { forwarded.push(appArgs); }, diff --git a/package.json b/package.json index 8b3c957c..d9c23b54 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.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/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.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/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.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/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.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/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.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/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", + "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.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/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.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/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.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/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.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/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js 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/yomitan-extension-paths.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/stats-window-lifecycle.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/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.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/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-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", diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 3b485e12..9becd0f1 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -28,6 +28,7 @@ export { shouldAutoInitializeOverlayRuntimeFromConfig, } from './startup'; export { destroyYomitanSettingsWindow, openYomitanSettingsWindow } from './yomitan-settings'; +export { promoteSettingsWindowAboveOverlay } from './settings-window-z-order'; export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer'; export { addYomitanNoteViaSearch, diff --git a/src/core/services/settings-window-z-order.test.ts b/src/core/services/settings-window-z-order.test.ts new file mode 100644 index 00000000..1516edfd --- /dev/null +++ b/src/core/services/settings-window-z-order.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + promoteSettingsWindowAboveOverlay, + shouldPromoteSettingsWindowAboveOverlay, +} from './settings-window-z-order'; + +test('settings window promotion only applies to Hyprland sessions', () => { + assert.equal( + shouldPromoteSettingsWindowAboveOverlay('linux', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }), + true, + ); + assert.equal( + shouldPromoteSettingsWindowAboveOverlay('linux', { WAYLAND_DISPLAY: 'wayland-1' }), + false, + ); + assert.equal( + shouldPromoteSettingsWindowAboveOverlay('darwin', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }), + false, + ); +}); + +test('promoteSettingsWindowAboveOverlay raises Hyprland settings windows above the overlay', () => { + const calls: string[] = []; + + const promoted = promoteSettingsWindowAboveOverlay( + { + isDestroyed: () => false, + getTitle: () => 'SubMiner Settings', + setAlwaysOnTop: (flag: boolean) => calls.push(`always-on-top:${flag}`), + moveTop: () => calls.push('move-top'), + } as never, + { + platform: 'linux', + env: { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }, + ensureHyprlandWindowFloatingByTitle: ({ title }) => { + calls.push(`hyprland-top:${title}`); + return true; + }, + }, + ); + + assert.equal(promoted, true); + assert.deepEqual(calls, ['always-on-top:true', 'move-top', 'hyprland-top:SubMiner Settings']); +}); + +test('promoteSettingsWindowAboveOverlay skips destroyed windows', () => { + const calls: string[] = []; + + const promoted = promoteSettingsWindowAboveOverlay( + { + isDestroyed: () => true, + getTitle: () => 'SubMiner Settings', + setAlwaysOnTop: () => calls.push('always-on-top'), + moveTop: () => calls.push('move-top'), + } as never, + { + platform: 'linux', + env: { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }, + }, + ); + + assert.equal(promoted, false); + assert.deepEqual(calls, []); +}); diff --git a/src/core/services/settings-window-z-order.ts b/src/core/services/settings-window-z-order.ts new file mode 100644 index 00000000..7f116edb --- /dev/null +++ b/src/core/services/settings-window-z-order.ts @@ -0,0 +1,48 @@ +import type { BrowserWindow } from 'electron'; +import { + ensureHyprlandWindowFloatingByTitle, + shouldAttemptHyprlandWindowPlacement, +} from './hyprland-window-placement'; + +type SettingsWindowLevelController = Pick< + BrowserWindow, + 'getTitle' | 'isDestroyed' | 'moveTop' | 'setAlwaysOnTop' +>; + +type PromoteSettingsWindowOptions = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + ensureHyprlandWindowFloatingByTitle?: typeof ensureHyprlandWindowFloatingByTitle; +}; + +export function shouldPromoteSettingsWindowAboveOverlay( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return shouldAttemptHyprlandWindowPlacement(platform, env); +} + +export function promoteSettingsWindowAboveOverlay( + window: SettingsWindowLevelController, + options: PromoteSettingsWindowOptions = {}, +): boolean { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + if (window.isDestroyed() || !shouldPromoteSettingsWindowAboveOverlay(platform, env)) { + return false; + } + + window.setAlwaysOnTop(true); + window.moveTop(); + + const title = window.getTitle().trim(); + if (title) { + (options.ensureHyprlandWindowFloatingByTitle ?? ensureHyprlandWindowFloatingByTitle)({ + title, + platform, + env, + }); + } + + return true; +} diff --git a/src/core/services/yomitan-settings.test.ts b/src/core/services/yomitan-settings.test.ts index 495615d3..2f047ae5 100644 --- a/src/core/services/yomitan-settings.test.ts +++ b/src/core/services/yomitan-settings.test.ts @@ -106,20 +106,32 @@ test('yomitan settings URL disables the embedded popup preview', () => { test('showYomitanSettingsWindow restores, repaints, shows, and focuses an existing window', () => { const calls: string[] = []; - showYomitanSettingsWindow({ - isDestroyed: () => false, - isMinimized: () => true, - restore: () => calls.push('restore'), - getSize: () => [1200, 800], - setSize: (width: number, height: number) => calls.push(`set-size:${width}x${height}`), - webContents: { - invalidate: () => calls.push('invalidate'), + showYomitanSettingsWindow( + { + isDestroyed: () => false, + isMinimized: () => true, + restore: () => calls.push('restore'), + getSize: () => [1200, 800], + setSize: (width: number, height: number) => calls.push(`set-size:${width}x${height}`), + webContents: { + invalidate: () => calls.push('invalidate'), + }, + show: () => calls.push('show'), + focus: () => calls.push('focus'), + } as never, + { + promoteSettingsWindowAboveOverlay: () => calls.push('promote'), }, - show: () => calls.push('show'), - focus: () => calls.push('focus'), - } as never); + ); - assert.deepEqual(calls, ['restore', 'set-size:1200x800', 'invalidate', 'show', 'focus']); + assert.deepEqual(calls, [ + 'restore', + 'set-size:1200x800', + 'invalidate', + 'show', + 'focus', + 'promote', + ]); }); test('destroyYomitanSettingsWindow destroys a live settings window before app quit', () => { diff --git a/src/core/services/yomitan-settings.ts b/src/core/services/yomitan-settings.ts index c5552182..8153c7dc 100644 --- a/src/core/services/yomitan-settings.ts +++ b/src/core/services/yomitan-settings.ts @@ -1,6 +1,7 @@ import electron from 'electron'; import type { BrowserWindow, Extension, Menu, MenuItemConstructorOptions, Session } from 'electron'; import { createLogger } from '../../logger'; +import { promoteSettingsWindowAboveOverlay } from './settings-window-z-order'; const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = electron; const logger = createLogger('main:yomitan-settings'); @@ -136,7 +137,12 @@ export function buildYomitanSettingsUrl(extensionId: string): string { return `chrome-extension://${extensionId}/settings.html?popup-preview=false`; } -export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void { +export function showYomitanSettingsWindow( + settingsWindow: BrowserWindow, + options: { + promoteSettingsWindowAboveOverlay?: (settingsWindow: BrowserWindow) => void; + } = {}, +): void { if (settingsWindow.isDestroyed()) { return; } @@ -148,6 +154,7 @@ export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void { settingsWindow.webContents.invalidate(); settingsWindow.show(); settingsWindow.focus(); + (options.promoteSettingsWindowAboveOverlay ?? promoteSettingsWindowAboveOverlay)(settingsWindow); } export function destroyYomitanSettingsWindow(settingsWindow: BrowserWindow | null): boolean { @@ -177,6 +184,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti logger.info('Creating new settings window for extension:', options.yomitanExt.id); const settingsWindow = new ElectronBrowserWindow({ + title: 'Yomitan Settings', width: 1200, height: 800, show: false, diff --git a/src/main.ts b/src/main.ts index 19920485..1a94b4a9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -332,6 +332,7 @@ import { mineSentenceCard as mineSentenceCardCore, openYomitanSettingsWindow, playNextSubtitleRuntime, + promoteSettingsWindowAboveOverlay, registerGlobalShortcuts as registerGlobalShortcutsCore, replayCurrentSubtitleRuntime, resolveJellyfinPlaybackPlanRuntime, @@ -565,6 +566,7 @@ import { createCreateJellyfinSetupWindowHandler, } from './main/runtime/setup-window-factory'; import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime'; +import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order'; import { isSameYoutubeMediaPath, isYoutubeMediaPath, @@ -2034,6 +2036,8 @@ const configSettingsRuntime = createConfigSettingsRuntime({ preloadPath: path.join(__dirname, 'preload-settings.js'), }), settingsHtmlPath: path.join(__dirname, 'settings', 'index.html'), + promoteSettingsWindowAboveOverlay: (window) => + promoteSettingsWindowAboveOverlay(window as BrowserWindow), openPath: (targetPath) => shell.openPath(targetPath), ipcMain, ipcChannels: IPC_CHANNELS.request, @@ -4927,8 +4931,17 @@ const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( const buildEnsureOverlayWindowLevelMainDepsHandler = createBuildEnsureOverlayWindowLevelMainDepsHandler({ - shouldSuppressOverlayWindowLevel: (window) => - appState.statsOverlayVisible && window === overlayManager.getMainWindow(), + shouldSuppressOverlayWindowLevel: (window) => { + const mainWindow = overlayManager.getMainWindow(); + return ( + (appState.statsOverlayVisible && window === mainWindow) || + shouldSuppressVisibleOverlayRaiseForSeparateWindow({ + window, + mainWindow, + separateWindows: [appState.configSettingsWindow, appState.yomitanSettingsWindow], + }) + ); + }, ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow), afterEnsureOverlayWindowLevel: () => { promoteStatsOverlayAbovePlayback(); diff --git a/src/main/runtime/config-settings-runtime.ts b/src/main/runtime/config-settings-runtime.ts index 2376ec1a..002b4265 100644 --- a/src/main/runtime/config-settings-runtime.ts +++ b/src/main/runtime/config-settings-runtime.ts @@ -56,6 +56,7 @@ export interface ConfigSettingsRuntimeDeps void; openPath(path: string): Promise; defaultAnkiConnectUrl: string; createAnkiClient(url: string): ConfigSettingsAnkiClient; @@ -144,6 +145,7 @@ export function createConfigSettingsRuntime false, + show: () => calls.push('show'), focus: () => calls.push('focus'), loadFile: () => calls.push('load'), on: () => {}, @@ -18,10 +19,11 @@ test('createOpenConfigSettingsWindowHandler focuses existing settings window', ( throw new Error('Should not create a second window.'); }, settingsHtmlPath: '/tmp/settings.html', + promoteSettingsWindowAboveOverlay: () => calls.push('promote'), }); assert.equal(open(), true); - assert.deepEqual(calls, ['focus']); + assert.deepEqual(calls, ['show', 'focus', 'promote']); }); test('createOpenConfigSettingsWindowHandler creates window and clears closed state', () => { @@ -29,6 +31,7 @@ test('createOpenConfigSettingsWindowHandler creates window and clears closed sta const handlers: { closed?: () => void } = {}; const created = { isDestroyed: () => false, + show: () => calls.push('show'), focus: () => calls.push('focus'), loadFile: (path: string) => calls.push(`load:${path}`), on: (event: string, handler: () => void) => { @@ -41,10 +44,11 @@ test('createOpenConfigSettingsWindowHandler creates window and clears closed sta setSettingsWindow: (window) => calls.push(window ? 'set:window' : 'set:null'), createSettingsWindow: () => created, settingsHtmlPath: '/tmp/settings.html', + promoteSettingsWindowAboveOverlay: () => calls.push('promote'), }); assert.equal(open(), true); - assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'focus']); + assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'show', 'focus', 'promote']); assert.ok(handlers.closed); handlers.closed(); assert.equal(calls.at(-1), 'set:null'); @@ -54,6 +58,7 @@ test('createOpenConfigSettingsWindowHandler clears failed load window state', as const calls: string[] = []; const created = { isDestroyed: () => false, + show: () => calls.push('show'), focus: () => calls.push('focus'), loadFile: (path: string) => { calls.push(`load:${path}`); @@ -76,6 +81,7 @@ test('createOpenConfigSettingsWindowHandler clears failed load window state', as assert.deepEqual(calls, [ 'load:/tmp/missing-settings.html', 'set:window', + 'show', 'focus', 'set:null', 'destroy', diff --git a/src/main/runtime/config-settings-window.ts b/src/main/runtime/config-settings-window.ts index a3167291..7ac20a13 100644 --- a/src/main/runtime/config-settings-window.ts +++ b/src/main/runtime/config-settings-window.ts @@ -1,5 +1,6 @@ export interface ConfigSettingsWindowLike { isDestroyed(): boolean; + show(): void; focus(): void; loadFile(path: string): unknown; on(event: 'closed', handler: () => void): unknown; @@ -11,6 +12,7 @@ export interface OpenConfigSettingsWindowDeps void; log?: (message: string) => void; } @@ -18,9 +20,15 @@ export function createOpenConfigSettingsWindowHandler, ): () => boolean { return () => { + const showAndFocus = (window: TWindow): void => { + window.show(); + window.focus(); + deps.promoteSettingsWindowAboveOverlay?.(window); + }; + const existing = deps.getSettingsWindow(); if (existing && !existing.isDestroyed()) { - existing.focus(); + showAndFocus(existing); return true; } @@ -35,7 +43,7 @@ export function createOpenConfigSettingsWindowHandler { deps.setSettingsWindow(null); }); - window.focus(); + showAndFocus(window); return true; }; } diff --git a/src/main/runtime/settings-window-z-order.test.ts b/src/main/runtime/settings-window-z-order.test.ts new file mode 100644 index 00000000..990eafd5 --- /dev/null +++ b/src/main/runtime/settings-window-z-order.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './settings-window-z-order'; + +test('separate settings windows suppress visible overlay restacking', () => { + const mainWindow = { id: 'overlay', isDestroyed: () => false }; + const settingsWindow = { id: 'settings', isDestroyed: () => false }; + + assert.equal( + shouldSuppressVisibleOverlayRaiseForSeparateWindow({ + window: mainWindow, + mainWindow, + separateWindows: [settingsWindow], + }), + true, + ); +}); + +test('separate settings windows do not suppress unrelated or closed overlay work', () => { + const mainWindow = { id: 'overlay', isDestroyed: () => false }; + const modalWindow = { id: 'modal', isDestroyed: () => false }; + const closedSettingsWindow = { id: 'settings', isDestroyed: () => true }; + + assert.equal( + shouldSuppressVisibleOverlayRaiseForSeparateWindow({ + window: modalWindow, + mainWindow, + separateWindows: [{ isDestroyed: () => false }], + }), + false, + ); + assert.equal( + shouldSuppressVisibleOverlayRaiseForSeparateWindow({ + window: mainWindow, + mainWindow, + separateWindows: [closedSettingsWindow, null], + }), + false, + ); +}); diff --git a/src/main/runtime/settings-window-z-order.ts b/src/main/runtime/settings-window-z-order.ts new file mode 100644 index 00000000..d6cbc311 --- /dev/null +++ b/src/main/runtime/settings-window-z-order.ts @@ -0,0 +1,19 @@ +type SeparateWindowLike = { + isDestroyed(): boolean; +}; + +function hasLiveSeparateWindow(windows: Array): boolean { + return windows.some((window) => Boolean(window && !window.isDestroyed())); +} + +export function shouldSuppressVisibleOverlayRaiseForSeparateWindow(options: { + window: unknown; + mainWindow: unknown; + separateWindows: Array; +}): boolean { + if (!options.mainWindow || options.window !== options.mainWindow) { + return false; + } + + return hasLiveSeparateWindow(options.separateWindows); +} diff --git a/src/main/runtime/setup-window-factory.test.ts b/src/main/runtime/setup-window-factory.test.ts index e323c3de..059b1d0c 100644 --- a/src/main/runtime/setup-window-factory.test.ts +++ b/src/main/runtime/setup-window-factory.test.ts @@ -111,7 +111,7 @@ test('createCreateConfigSettingsWindowHandler builds configuration settings wind width: 1040, height: 760, title: 'SubMiner Settings', - show: true, + show: false, autoHideMenuBar: true, resizable: true, backgroundColor: '#24273a', diff --git a/src/main/runtime/setup-window-factory.ts b/src/main/runtime/setup-window-factory.ts index 02ac42a7..08e5227a 100644 --- a/src/main/runtime/setup-window-factory.ts +++ b/src/main/runtime/setup-window-factory.ts @@ -2,6 +2,7 @@ interface SetupWindowConfig { width: number; height: number; title: string; + show?: boolean; resizable?: boolean; minimizable?: boolean; maximizable?: boolean; @@ -19,7 +20,7 @@ function createSetupWindowHandler( width: config.width, height: config.height, title: config.title, - show: true, + show: config.show ?? true, autoHideMenuBar: true, ...(config.resizable === undefined ? {} : { resizable: config.resizable }), ...(config.minimizable === undefined ? {} : { minimizable: config.minimizable }), @@ -77,6 +78,7 @@ export function createCreateConfigSettingsWindowHandler(deps: { width: 1040, height: 760, title: 'SubMiner Settings', + show: false, resizable: true, preloadPath: deps.preloadPath, backgroundColor: '#24273a',