From 76f99e65182218e45de0e395933dd3a5d97207bd Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 1 Jun 2026 02:12:16 -0700 Subject: [PATCH] fix(overlay): correct Hyprland fullscreen overlay alignment on Linux (#107) --- ...06-01-hyprland-fullscreen-overlay-align.md | 4 + .../hyprland-window-placement.test.ts | 105 ++++++++++++- .../services/hyprland-window-placement.ts | 140 ++++++++++++++++-- .../services/overlay-window-bounds.test.ts | 16 ++ src/core/services/overlay-window-bounds.ts | 45 ++++++ src/core/services/overlay-window.test.ts | 36 ++++- src/core/services/overlay-window.ts | 7 +- src/core/services/stats-window-runtime.ts | 19 +++ src/core/services/stats-window.test.ts | 37 +++++ src/core/services/stats-window.ts | 22 +++ src/main.ts | 23 ++- src/main/overlay-runtime.test.ts | 1 + src/main/overlay-runtime.ts | 23 +++ .../runtime/overlay-window-layout.test.ts | 31 ++++ src/main/runtime/overlay-window-layout.ts | 15 +- 15 files changed, 501 insertions(+), 23 deletions(-) create mode 100644 changes/2026-06-01-hyprland-fullscreen-overlay-align.md diff --git a/changes/2026-06-01-hyprland-fullscreen-overlay-align.md b/changes/2026-06-01-hyprland-fullscreen-overlay-align.md new file mode 100644 index 00000000..08308b6e --- /dev/null +++ b/changes/2026-06-01-hyprland-fullscreen-overlay-align.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed Hyprland fullscreen overlay alignment by verifying compositor client bounds, preventing modal, stats, and sidebar content from shifting below the mpv window. diff --git a/src/core/services/hyprland-window-placement.test.ts b/src/core/services/hyprland-window-placement.test.ts index 95809099..538e7487 100644 --- a/src/core/services/hyprland-window-placement.test.ts +++ b/src/core/services/hyprland-window-placement.test.ts @@ -4,6 +4,7 @@ import { buildHyprlandPlacementDispatches, ensureHyprlandWindowFloatingByTitle, findHyprlandWindowForPlacement, + hasHyprlandWindowPlacementBoundsMismatch, shouldAttemptHyprlandWindowPlacement, } from './hyprland-window-placement'; @@ -83,8 +84,8 @@ test('buildHyprlandPlacementDispatches force-aligns floating overlay windows to }, ), [ - ['dispatch', 'movewindowpixel', 'exact 0 0,address:0xabc'], ['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xabc'], + ['dispatch', 'movewindowpixel', 'exact 0 0,address:0xabc'], ['dispatch', 'setprop', 'address:0xabc rounding 0'], ['dispatch', 'setprop', 'address:0xabc border_size 0'], ['dispatch', 'setprop', 'address:0xabc no_shadow 1'], @@ -116,8 +117,8 @@ test('buildHyprlandPlacementDispatches emits Lua dispatchers for Lua-config Hypr [ ['dispatch', 'hl.dsp.window.float({ action = "on", window = "address:0xabc" })'], ['dispatch', 'hl.dsp.window.pin({ action = "off", window = "address:0xabc" })'], - ['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xabc" })'], ['dispatch', 'hl.dsp.window.resize({ x = 1920, y = 1080, window = "address:0xabc" })'], + ['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xabc" })'], [ 'dispatch', 'hl.dsp.window.set_prop({ prop = "rounding", value = "0", window = "address:0xabc" })', @@ -177,8 +178,8 @@ test('buildHyprlandPlacementDispatches can update placement without raising z-or { promote: false }, ), [ - ['dispatch', 'movewindowpixel', 'exact 0 0,address:0xabc'], ['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xabc'], + ['dispatch', 'movewindowpixel', 'exact 0 0,address:0xabc'], ['dispatch', 'setprop', 'address:0xabc rounding 0'], ['dispatch', 'setprop', 'address:0xabc border_size 0'], ['dispatch', 'setprop', 'address:0xabc no_shadow 1'], @@ -286,8 +287,8 @@ test('ensureHyprlandWindowFloatingByTitle dispatches exact Hyprland geometry whe [ ['-j', 'clients'], ['-j', 'status'], - ['dispatch', 'movewindowpixel', 'exact 0 0,address:0xmatch'], ['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xmatch'], + ['dispatch', 'movewindowpixel', 'exact 0 0,address:0xmatch'], ['dispatch', 'setprop', 'address:0xmatch rounding 0'], ['dispatch', 'setprop', 'address:0xmatch border_size 0'], ['dispatch', 'setprop', 'address:0xmatch no_shadow 1'], @@ -340,8 +341,8 @@ test('ensureHyprlandWindowFloatingByTitle dispatches Lua syntax for Lua-config H [ ['-j', 'clients'], ['-j', 'status'], - ['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xmatch" })'], ['dispatch', 'hl.dsp.window.resize({ x = 1920, y = 1080, window = "address:0xmatch" })'], + ['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xmatch" })'], [ 'dispatch', 'hl.dsp.window.set_prop({ prop = "rounding", value = "0", window = "address:0xmatch" })', @@ -366,3 +367,97 @@ test('ensureHyprlandWindowFloatingByTitle dispatches Lua syntax for Lua-config H ], ); }); + +test('hasHyprlandWindowPlacementBoundsMismatch compares compositor client bounds', () => { + const mismatch = hasHyprlandWindowPlacementBoundsMismatch({ + title: 'SubMiner Overlay', + platform: 'linux', + env: { + HYPRLAND_INSTANCE_SIGNATURE: 'abc', + }, + pid: 456, + bounds: { + x: 0, + y: 0, + width: 3440, + height: 1440, + }, + execFileSync: ((command: string, args: string[]) => { + assert.equal(command, 'hyprctl'); + assert.deepEqual(args, ['-j', 'clients']); + return JSON.stringify([ + { + address: '0xmatch', + pid: 456, + title: 'SubMiner Overlay', + mapped: true, + floating: true, + at: [0, 14], + size: [3440, 1426], + }, + ]); + }) as never, + }); + + assert.equal(mismatch, true); +}); + +test('ensureHyprlandWindowFloatingByTitle retries when compositor bounds stay misaligned', () => { + let clientReads = 0; + const calls: unknown[][] = []; + const placed = ensureHyprlandWindowFloatingByTitle({ + title: 'SubMiner Overlay', + platform: 'linux', + env: { + HYPRLAND_INSTANCE_SIGNATURE: 'abc', + }, + pid: 456, + bounds: { + x: 0, + y: 0, + width: 3440, + height: 1440, + }, + execFileSync: ((command: string, args: string[], options: unknown) => { + calls.push([command, args, options]); + if (args.join(' ') === '-j clients') { + clientReads += 1; + return JSON.stringify([ + { + address: '0xmatch', + pid: 456, + title: 'SubMiner Overlay', + mapped: true, + floating: true, + pinned: false, + at: clientReads === 1 ? [10, 58] : [0, 14], + size: clientReads === 1 ? [3420, 1372] : [3440, 1426], + }, + ]); + } + if (args.join(' ') === '-j status') { + return JSON.stringify({ configProvider: 'hyprlang' }); + } + return ''; + }) as never, + }); + + assert.equal(placed, true); + assert.equal(clientReads, 2); + assert.deepEqual( + calls + .map(([, args]) => args) + .filter( + (args) => + Array.isArray(args) && + args[0] === 'dispatch' && + (args[1] === 'resizewindowpixel' || args[1] === 'movewindowpixel'), + ), + [ + ['dispatch', 'resizewindowpixel', 'exact 3440 1440,address:0xmatch'], + ['dispatch', 'movewindowpixel', 'exact 0 0,address:0xmatch'], + ['dispatch', 'resizewindowpixel', 'exact 3440 1440,address:0xmatch'], + ['dispatch', 'movewindowpixel', 'exact 0 0,address:0xmatch'], + ], + ); +}); diff --git a/src/core/services/hyprland-window-placement.ts b/src/core/services/hyprland-window-placement.ts index ad871185..fe8c4c0e 100644 --- a/src/core/services/hyprland-window-placement.ts +++ b/src/core/services/hyprland-window-placement.ts @@ -2,12 +2,14 @@ import { execFileSync } from 'node:child_process'; export interface HyprlandPlacementClient { address?: string; + at?: [number, number]; floating?: boolean; hidden?: boolean; initialTitle?: string; mapped?: boolean; pid?: number; pinned?: boolean; + size?: [number, number]; title?: string; } @@ -43,6 +45,10 @@ function parseHyprlandClients(output: string): HyprlandPlacementClient[] { return Array.isArray(parsed) ? (parsed as HyprlandPlacementClient[]) : []; } +function readHyprlandPlacementClients(run: ExecFileSync): HyprlandPlacementClient[] { + return parseHyprlandClients(String(run('hyprctl', ['-j', 'clients'], { encoding: 'utf-8' }))); +} + export function findHyprlandWindowForPlacement( clients: HyprlandPlacementClient[], options: { @@ -96,18 +102,18 @@ export function buildHyprlandPlacementDispatches( const roundedBounds = roundPlacementBounds(bounds); if (roundedBounds) { if (configProvider === 'lua') { - dispatches.push( - luaWindowDispatch('move', windowAddress, [ - `x = ${roundedBounds.x}`, - `y = ${roundedBounds.y}`, - ]), - ); dispatches.push( luaWindowDispatch('resize', windowAddress, [ `x = ${roundedBounds.width}`, `y = ${roundedBounds.height}`, ]), ); + dispatches.push( + luaWindowDispatch('move', windowAddress, [ + `x = ${roundedBounds.x}`, + `y = ${roundedBounds.y}`, + ]), + ); dispatches.push(luaWindowSetProp(windowAddress, 'rounding', '0')); dispatches.push(luaWindowSetProp(windowAddress, 'border_size', '0')); dispatches.push(luaWindowSetProp(windowAddress, 'no_shadow', '1')); @@ -116,13 +122,13 @@ export function buildHyprlandPlacementDispatches( } else { dispatches.push([ 'dispatch', - 'movewindowpixel', - `exact ${roundedBounds.x} ${roundedBounds.y},${windowAddress}`, + 'resizewindowpixel', + `exact ${roundedBounds.width} ${roundedBounds.height},${windowAddress}`, ]); dispatches.push([ 'dispatch', - 'resizewindowpixel', - `exact ${roundedBounds.width} ${roundedBounds.height},${windowAddress}`, + 'movewindowpixel', + `exact ${roundedBounds.x} ${roundedBounds.y},${windowAddress}`, ]); dispatches.push(['dispatch', 'setprop', `${windowAddress} rounding 0`]); dispatches.push(['dispatch', 'setprop', `${windowAddress} border_size 0`]); @@ -181,6 +187,91 @@ function roundPlacementBounds( : null; } +function isFiniteTuple(value: unknown): value is [number, number] { + return ( + Array.isArray(value) && + value.length >= 2 && + typeof value[0] === 'number' && + typeof value[1] === 'number' && + Number.isFinite(value[0]) && + Number.isFinite(value[1]) + ); +} + +export function getHyprlandClientPlacementBounds( + client: HyprlandPlacementClient, +): HyprlandPlacementBounds | null { + if (!isFiniteTuple(client.at) || !isFiniteTuple(client.size)) { + return null; + } + + return roundPlacementBounds({ + x: client.at[0], + y: client.at[1], + width: client.size[0], + height: client.size[1], + }); +} + +export function hyprlandPlacementBoundsMatch( + actual: HyprlandPlacementBounds | null, + target: HyprlandPlacementBounds | null, + tolerancePx = 1, +): boolean { + const roundedActual = roundPlacementBounds(actual); + const roundedTarget = roundPlacementBounds(target); + if (!roundedActual || !roundedTarget) { + return false; + } + + return ( + Math.abs(roundedActual.x - roundedTarget.x) <= tolerancePx && + Math.abs(roundedActual.y - roundedTarget.y) <= tolerancePx && + Math.abs(roundedActual.width - roundedTarget.width) <= tolerancePx && + Math.abs(roundedActual.height - roundedTarget.height) <= tolerancePx + ); +} + +function clientMatchesPlacementBounds( + client: HyprlandPlacementClient, + bounds: HyprlandPlacementBounds, +): boolean | null { + const actual = getHyprlandClientPlacementBounds(client); + return actual ? hyprlandPlacementBoundsMatch(actual, bounds) : null; +} + +export function hasHyprlandWindowPlacementBoundsMismatch(options: { + title: string; + bounds?: HyprlandPlacementBounds | null; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + pid?: number; + execFileSync?: ExecFileSync; +}): boolean { + if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) { + return false; + } + + const targetBounds = roundPlacementBounds(options.bounds); + if (!targetBounds) { + return false; + } + + const run = options.execFileSync ?? execFileSync; + try { + const client = findHyprlandWindowForPlacement(readHyprlandPlacementClients(run), { + pid: options.pid ?? process.pid, + title: options.title, + }); + if (!client) { + return false; + } + return clientMatchesPlacementBounds(client, targetBounds) === false; + } catch { + return false; + } +} + export function ensureHyprlandWindowFloatingByTitle(options: { title: string; bounds?: HyprlandPlacementBounds | null; @@ -196,9 +287,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: { const run = options.execFileSync ?? execFileSync; try { - const clients = parseHyprlandClients( - String(run('hyprctl', ['-j', 'clients'], { encoding: 'utf-8' })), - ); + const clients = readHyprlandPlacementClients(run); const client = findHyprlandWindowForPlacement(clients, { pid: options.pid ?? process.pid, title: options.title, @@ -208,6 +297,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: { } const configProvider = detectHyprlandConfigProvider(run); + const targetBounds = roundPlacementBounds(options.bounds); + const shouldVerifyBounds = + targetBounds !== null && clientMatchesPlacementBounds(client, targetBounds) === false; const dispatches = buildHyprlandPlacementDispatches(client, options.bounds, { configProvider, promote: options.promote, @@ -215,6 +307,28 @@ export function ensureHyprlandWindowFloatingByTitle(options: { for (const args of dispatches) { run('hyprctl', args, { stdio: 'ignore' }); } + if (shouldVerifyBounds) { + try { + const refreshedClient = findHyprlandWindowForPlacement(readHyprlandPlacementClients(run), { + pid: options.pid ?? process.pid, + title: options.title, + }); + if ( + refreshedClient && + targetBounds && + clientMatchesPlacementBounds(refreshedClient, targetBounds) === false + ) { + for (const args of buildHyprlandPlacementDispatches(refreshedClient, targetBounds, { + configProvider, + promote: options.promote, + })) { + run('hyprctl', args, { stdio: 'ignore' }); + } + } + } catch { + // Best-effort reconciliation: the initial placement dispatches already ran. + } + } return dispatches.length > 0; } catch { return false; diff --git a/src/core/services/overlay-window-bounds.test.ts b/src/core/services/overlay-window-bounds.test.ts index 9e55666f..81490f97 100644 --- a/src/core/services/overlay-window-bounds.test.ts +++ b/src/core/services/overlay-window-bounds.test.ts @@ -7,6 +7,22 @@ test('normalizeOverlayWindowBoundsForPlatform returns original geometry outside assert.deepEqual(normalizeOverlayWindowBoundsForPlatform(geometry, 'linux', null), geometry); }); +test('normalizeOverlayWindowBoundsForPlatform compensates Linux content insets', () => { + assert.deepEqual( + normalizeOverlayWindowBoundsForPlatform( + { x: 0, y: 0, width: 3440, height: 1440 }, + 'linux', + null, + { + isDestroyed: () => false, + getBounds: () => ({ x: 0, y: 0, width: 3440, height: 1440 }), + getContentBounds: () => ({ x: 0, y: 14, width: 3440, height: 1426 }), + }, + ), + { x: 0, y: -14, width: 3440, height: 1454 }, + ); +}); + test('normalizeOverlayWindowBoundsForPlatform returns original geometry on Windows when screen is unavailable', () => { const geometry = { x: 150, y: 90, width: 1200, height: 675 }; assert.deepEqual(normalizeOverlayWindowBoundsForPlatform(geometry, 'win32', null), geometry); diff --git a/src/core/services/overlay-window-bounds.ts b/src/core/services/overlay-window-bounds.ts index de5e2bc0..144e9ac1 100644 --- a/src/core/services/overlay-window-bounds.ts +++ b/src/core/services/overlay-window-bounds.ts @@ -7,11 +7,56 @@ type ScreenDipConverter = { ) => Electron.Rectangle; }; +type ContentBoundsWindow = { + isDestroyed: () => boolean; + getBounds: () => Electron.Rectangle; + getContentBounds: () => Electron.Rectangle; +}; + +function resolveContentAlignedBounds( + geometry: WindowGeometry, + window?: ContentBoundsWindow | null, +): WindowGeometry { + if (!window || window.isDestroyed()) { + return geometry; + } + + let outer: Electron.Rectangle; + let content: Electron.Rectangle; + try { + outer = window.getBounds(); + content = window.getContentBounds(); + } catch { + return geometry; + } + + const leftInset = content.x - outer.x; + const topInset = content.y - outer.y; + const rightInset = outer.x + outer.width - (content.x + content.width); + const bottomInset = outer.y + outer.height - (content.y + content.height); + const insets = [leftInset, topInset, rightInset, bottomInset]; + if (insets.some((inset) => !Number.isFinite(inset) || inset < 0)) { + return geometry; + } + + return { + x: geometry.x - leftInset, + y: geometry.y - topInset, + width: geometry.width + leftInset + rightInset, + height: geometry.height + topInset + bottomInset, + }; +} + export function normalizeOverlayWindowBoundsForPlatform( geometry: WindowGeometry, platform: NodeJS.Platform, screen: ScreenDipConverter | null, + window?: ContentBoundsWindow | null, ): WindowGeometry { + if (platform === 'linux') { + return resolveContentAlignedBounds(geometry, window); + } + if (platform !== 'win32' || !screen) { return geometry; } diff --git a/src/core/services/overlay-window.test.ts b/src/core/services/overlay-window.test.ts index 2c94fed4..2458f017 100644 --- a/src/core/services/overlay-window.test.ts +++ b/src/core/services/overlay-window.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { ensureOverlayWindowLevel } from './overlay-window'; +import { ensureOverlayWindowLevel, updateOverlayWindowBounds } from './overlay-window'; import { handleOverlayWindowBeforeInputEvent, handleOverlayWindowBlurred, @@ -288,3 +288,37 @@ test('ensureOverlayWindowLevel promotes Linux overlay above fullscreen mpv witho 'move-top', ]); }); + +test('updateOverlayWindowBounds aligns Linux overlay content bounds to mpv geometry', () => { + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + const originalHyprlandSignature = process.env.HYPRLAND_INSTANCE_SIGNATURE; + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'linux', + }); + delete process.env.HYPRLAND_INSTANCE_SIGNATURE; + + const calls: Array<{ x: number; y: number; width: number; height: number }> = []; + try { + updateOverlayWindowBounds({ x: 0, y: 0, width: 3440, height: 1440 }, { + isDestroyed: () => false, + getTitle: () => 'SubMiner Overlay', + getBounds: () => ({ x: 0, y: 0, width: 3440, height: 1440 }), + getContentBounds: () => ({ x: 0, y: 14, width: 3440, height: 1426 }), + setBounds: (bounds: { x: number; y: number; width: number; height: number }) => { + calls.push(bounds); + }, + } as never); + } finally { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor); + } + if (originalHyprlandSignature === undefined) { + delete process.env.HYPRLAND_INSTANCE_SIGNATURE; + } else { + process.env.HYPRLAND_INSTANCE_SIGNATURE = originalHyprlandSignature; + } + } + + assert.deepEqual(calls, [{ x: 0, y: -14, width: 3440, height: 1454 }]); +}); diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index bbb89c84..6124563c 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -56,7 +56,12 @@ export function updateOverlayWindowBounds( } = {}, ): void { if (!geometry || !window || window.isDestroyed()) return; - const bounds = normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen); + const bounds = normalizeOverlayWindowBoundsForPlatform( + geometry, + process.platform, + screen, + window, + ); window.setBounds(bounds); ensureHyprlandWindowFloatingByTitle({ title: window.getTitle(), diff --git a/src/core/services/stats-window-runtime.ts b/src/core/services/stats-window-runtime.ts index e3195f9e..bc2d6c8e 100644 --- a/src/core/services/stats-window-runtime.ts +++ b/src/core/services/stats-window-runtime.ts @@ -8,6 +8,7 @@ import type { WindowGeometry } from '../../types'; const DEFAULT_STATS_WINDOW_WIDTH = 900; const DEFAULT_STATS_WINDOW_HEIGHT = 700; export const STATS_WINDOW_TITLE = 'SubMiner Stats'; +const STATS_POST_SHOW_RECONCILE_DELAYS_MS = [50, 150, 300, 600] as const; type StatsWindowLevelController = Pick & Partial>; @@ -26,6 +27,14 @@ type StatsNativeConfirmDialogPresenter = { type StatsWindowBoundsController = Pick; type StatsWindowPresentationController = Pick & Partial>; +type StatsWindowReconcileScheduler = { + setTimeout: ( + callback: () => void, + delayMs: number, + ) => { + unref?: () => void; + }; +}; function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean { return ( @@ -189,6 +198,16 @@ export function presentStatsWindow( window.focus(); } +export function scheduleStatsWindowPostShowReconciles( + reconcile: () => void, + scheduler: StatsWindowReconcileScheduler = globalThis, +): void { + for (const delayMs of STATS_POST_SHOW_RECONCILE_DELAYS_MS) { + const timeout = scheduler.setTimeout(reconcile, delayMs); + timeout.unref?.(); + } +} + export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): { query: Record; } { diff --git a/src/core/services/stats-window.test.ts b/src/core/services/stats-window.test.ts index be4d3de3..748926a1 100644 --- a/src/core/services/stats-window.test.ts +++ b/src/core/services/stats-window.test.ts @@ -9,6 +9,7 @@ import { promoteVisibleStatsWindowAboveOverlay, promoteStatsWindowLevel, resolveStatsWindowOuterBoundsForContent, + scheduleStatsWindowPostShowReconciles, showStatsNativeConfirmDialog, shouldHideStatsWindowForInput, } from './stats-window-runtime'; @@ -402,3 +403,39 @@ test('presentStatsWindow shows and focuses on non-macOS platforms', () => { assert.deepEqual(calls, ['show', 'focus']); }); + +test('scheduleStatsWindowPostShowReconciles retries placement after a reused hidden window is remapped', () => { + const calls: string[] = []; + + scheduleStatsWindowPostShowReconciles( + () => { + calls.push('reconcile'); + }, + { + setTimeout: (callback, delayMs) => { + calls.push(`timer:${delayMs}`); + callback(); + return { + unref: () => { + calls.push(`unref:${delayMs}`); + }, + }; + }, + }, + ); + + assert.deepEqual(calls, [ + 'timer:50', + 'reconcile', + 'unref:50', + 'timer:150', + 'reconcile', + 'unref:150', + 'timer:300', + 'reconcile', + 'unref:300', + 'timer:600', + 'reconcile', + 'unref:600', + ]); +}); diff --git a/src/core/services/stats-window.ts b/src/core/services/stats-window.ts index e99f20d3..3024222d 100644 --- a/src/core/services/stats-window.ts +++ b/src/core/services/stats-window.ts @@ -10,6 +10,7 @@ import { promoteStatsWindowLevel, promoteVisibleStatsWindowAboveOverlay, resolveStatsWindowOuterBoundsForContent, + scheduleStatsWindowPostShowReconciles, showStatsNativeConfirmDialog, shouldHideStatsWindowForInput, STATS_WINDOW_TITLE, @@ -58,6 +59,25 @@ function syncStatsWindowBounds( return outerBounds; } +function reconcileStatsWindowBounds(window: BrowserWindow, options: StatsWindowOptions): void { + if (window.isDestroyed() || !window.isVisible()) { + return; + } + const placementBounds = syncStatsWindowBounds(window, options.resolveBounds()); + if (placementBounds) { + ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds }); + } +} + +function scheduleStatsWindowBoundsReconcile( + window: BrowserWindow, + options: StatsWindowOptions, +): void { + scheduleStatsWindowPostShowReconciles(() => { + reconcileStatsWindowBounds(window, options); + }); +} + function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): void { const bounds = options.resolveBounds(); let placementBounds = syncStatsWindowBounds(window, bounds); @@ -71,6 +91,8 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo } options.onVisibilityChanged?.(true); promoteStatsOverlayAbovePlayback(); + reconcileStatsWindowBounds(window, options); + scheduleStatsWindowBoundsReconcile(window, options); } export function promoteStatsOverlayAbovePlayback(): boolean { diff --git a/src/main.ts b/src/main.ts index d3f681a0..c761ee86 100644 --- a/src/main.ts +++ b/src/main.ts @@ -398,6 +398,8 @@ import { acquireYoutubeSubtitleTrack, acquireYoutubeSubtitleTracks, } from './core/services/youtube/generate'; +import { hasHyprlandWindowPlacementBoundsMismatch } from './core/services/hyprland-window-placement'; +import { normalizeOverlayWindowBoundsForPlatform } from './core/services/overlay-window-bounds'; import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve'; import { probeYoutubeTracks } from './core/services/youtube/track-probe'; import { startStatsServer } from './core/services/stats-server'; @@ -5460,16 +5462,33 @@ function maybeExitLinuxFullscreenOverrideForTrackedGeometry(geometry: WindowGeom syncLinuxVisibleOverlayMpvFullscreenMode(false); } +function hasHyprlandOverlayWindowPlacementMismatch(geometry: WindowGeometry): boolean { + if (process.platform !== 'linux') { + return false; + } + + return [overlayManager.getMainWindow(), overlayManager.getModalWindow()].some((window) => { + if (!window || window.isDestroyed()) { + return false; + } + return hasHyprlandWindowPlacementBoundsMismatch({ + title: window.getTitle(), + bounds: normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen, window), + }); + }); +} + const buildUpdateVisibleOverlayBoundsMainDepsHandler = createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ getCurrentOverlayWindowBounds: () => lastOverlayWindowGeometry, shouldRefreshUnchangedGeometry: (geometry) => shouldExitLinuxFullscreenOverrideForGeometry(geometry) || (process.platform === 'linux' && - hasLiveOverlayWindowBoundsMismatch( + (hasLiveOverlayWindowBoundsMismatch( [overlayManager.getMainWindow(), overlayManager.getModalWindow()], geometry, - )), + ) || + hasHyprlandOverlayWindowPlacementMismatch(geometry))), setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry), afterSetOverlayWindowBounds: () => { if (!overlayManager.getVisibleOverlayVisible()) { diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index 9841a849..13d530ec 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -230,6 +230,7 @@ test('sendToActiveOverlayWindow targets modal window with full geometry and trac runtime.notifyOverlayModalOpened('runtime-options'); assert.equal(window.getShowCount(), 1); assert.equal(window.isFocused(), true); + assert.deepEqual(calls, ['bounds:10,20,300,200', 'bounds:10,20,300,200']); assert.deepEqual(window.alwaysOnTopCalls, ['top:true:screen-saver:3']); assert.deepEqual(window.sent, [['runtime-options:open']]); }); diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 35978804..45b45ead 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -4,6 +4,7 @@ import type { WindowGeometry } from '../types'; import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags'; const MODAL_REVEAL_FALLBACK_DELAY_MS = 250; +const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS = 50; function requestOverlayApplicationFocus(): void { try { @@ -144,6 +145,24 @@ export function createOverlayModalRuntimeService( window.moveTop(); }; + const reconcileModalWindowBounds = (window: BrowserWindow): void => { + const modalWindow = deps.getModalWindow(); + if (!modalWindow || modalWindow !== window || window.isDestroyed()) { + return; + } + deps.setModalWindowBounds(deps.getModalGeometry()); + }; + + const scheduleModalWindowBoundsReconcile = (window: BrowserWindow): void => { + const timeout = setTimeout(() => { + if (window.isDestroyed() || !window.isVisible()) { + return; + } + reconcileModalWindowBounds(window); + }, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS); + timeout.unref?.(); + }; + const sendOrQueueForWindow = ( window: BrowserWindow, sendNow: (window: BrowserWindow) => void, @@ -187,6 +206,8 @@ export function createOverlayModalRuntimeService( if (!window.webContents.isFocused()) { window.webContents.focus(); } + reconcileModalWindowBounds(window); + scheduleModalWindowBoundsReconcile(window); }; const ensureModalWindowInteractive = (window: BrowserWindow): void => { @@ -198,6 +219,8 @@ export function createOverlayModalRuntimeService( if (window.isVisible()) { window.focus(); window.webContents.focus(); + reconcileModalWindowBounds(window); + scheduleModalWindowBoundsReconcile(window); return; } diff --git a/src/main/runtime/overlay-window-layout.test.ts b/src/main/runtime/overlay-window-layout.test.ts index 352dff9f..fe8d3ac1 100644 --- a/src/main/runtime/overlay-window-layout.test.ts +++ b/src/main/runtime/overlay-window-layout.test.ts @@ -99,6 +99,37 @@ test('live overlay bounds mismatch forces refresh after window manager restore d ); }); +test('live overlay bounds mismatch compares content bounds when compositor adds insets', () => { + const geometry = { x: 0, y: 0, width: 3440, height: 1440 }; + + assert.equal( + hasLiveOverlayWindowBoundsMismatch( + [ + { + isDestroyed: () => false, + getBounds: () => ({ ...geometry }), + getContentBounds: () => ({ x: 0, y: 14, width: 3440, height: 1426 }), + }, + ], + geometry, + ), + true, + ); + assert.equal( + hasLiveOverlayWindowBoundsMismatch( + [ + { + isDestroyed: () => false, + getBounds: () => ({ x: 0, y: -14, width: 3440, height: 1454 }), + getContentBounds: () => ({ ...geometry }), + }, + ], + geometry, + ), + false, + ); +}); + test('ensure overlay window level handler delegates to core', () => { const calls: string[] = []; const ensureLevel = createEnsureOverlayWindowLevelHandler({ diff --git a/src/main/runtime/overlay-window-layout.ts b/src/main/runtime/overlay-window-layout.ts index 8f9d3ac0..647b631e 100644 --- a/src/main/runtime/overlay-window-layout.ts +++ b/src/main/runtime/overlay-window-layout.ts @@ -3,12 +3,25 @@ import type { WindowGeometry } from '../../types'; type OverlayBoundsWindow = { isDestroyed: () => boolean; getBounds: () => WindowGeometry; + getContentBounds?: () => WindowGeometry; }; function sameGeometry(a: WindowGeometry | null | undefined, b: WindowGeometry): boolean { return a?.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; } +function getWindowAlignmentBounds(window: OverlayBoundsWindow): WindowGeometry | null { + try { + return window.getContentBounds?.() ?? window.getBounds(); + } catch { + try { + return window.getBounds(); + } catch { + return null; + } + } +} + export function hasLiveOverlayWindowBoundsMismatch( windows: Array, geometry: WindowGeometry, @@ -17,7 +30,7 @@ export function hasLiveOverlayWindowBoundsMismatch( if (!window || window.isDestroyed()) { return false; } - return !sameGeometry(window.getBounds(), geometry); + return !sameGeometry(getWindowAlignmentBounds(window), geometry); }); }