diff --git a/backlog/tasks/task-336 - Fix-Hyprland-fullscreen-overlay-downward-offset.md b/backlog/tasks/task-336 - Fix-Hyprland-fullscreen-overlay-downward-offset.md new file mode 100644 index 00000000..90ddf7fa --- /dev/null +++ b/backlog/tasks/task-336 - Fix-Hyprland-fullscreen-overlay-downward-offset.md @@ -0,0 +1,55 @@ +--- +id: TASK-336 +title: Fix Hyprland fullscreen overlay downward offset +status: Done +assignee: [] +created_date: '2026-05-04 05:42' +updated_date: '2026-05-04 05:56' +labels: + - linux + - hyprland + - overlay + - bug +dependencies: [] +references: + - src/window-trackers/hyprland-tracker.ts + - src/core/services/overlay-window-bounds.ts + - src/main/runtime/linux-mpv-fullscreen-overlay-refresh.ts +priority: medium +--- + +## Description + + +SubMiner visible overlay is slightly below mpv when mpv is fullscreen on Linux Hyprland. Align overlay bounds with mpv fullscreen client/monitor bounds. + + +## Acceptance Criteria + +- [x] #1 Hyprland fullscreen mpv overlay uses top-aligned geometry instead of inheriting a downward offset. +- [x] #2 Regression coverage captures the fullscreen Hyprland geometry case. +- [x] #3 Targeted tests pass. + + +## Implementation Notes + + +Added follow-up Hyprland placement handling after the fullscreenClient geometry fix. SubMiner overlay/stats windows now get stable titles and, on Hyprland, are resolved from `hyprctl -j clients` by current PID/title, then set floating and pinned before bounds are applied. The stats overlay reapplies bounds after showing because Hyprland cannot see the hidden window before it is mapped. + + +## Final Summary + + +Summary: +- Treated Hyprland `fullscreenClient` as a fullscreen signal when resolving mpv overlay geometry. +- Added Hyprland window placement handling so SubMiner overlay/stats windows are set floating and pinned before bounds are applied. +- Gave overlay/stats windows stable titles for Hyprland client matching, and reapplied stats bounds after show. +- Added regression coverage for the 28px fullscreen geometry shape and Hyprland placement dispatches. +- Added a changelog fragment for the overlay fix. + +Verification: +- `bun test src/core/services/hyprland-window-placement.test.ts src/core/services/overlay-window-config.test.ts src/core/services/stats-window.test.ts src/core/services/overlay-window-bounds.test.ts src/window-trackers/hyprland-tracker.test.ts` +- `bun run typecheck` +- `bun run changelog:lint` +- `bun run test:fast` + diff --git a/changes/336-hyprland-fullscreen-overlay.md b/changes/336-hyprland-fullscreen-overlay.md new file mode 100644 index 00000000..668ff5b9 --- /dev/null +++ b/changes/336-hyprland-fullscreen-overlay.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Overlay: Aligned the Hyprland fullscreen overlay with mpv when mpv reports client-requested fullscreen. diff --git a/src/core/services/hyprland-window-placement.test.ts b/src/core/services/hyprland-window-placement.test.ts new file mode 100644 index 00000000..8df261d6 --- /dev/null +++ b/src/core/services/hyprland-window-placement.test.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildHyprlandPlacementDispatches, + ensureHyprlandWindowFloatingByTitle, + findHyprlandWindowForPlacement, + shouldAttemptHyprlandWindowPlacement, +} from './hyprland-window-placement'; + +test('shouldAttemptHyprlandWindowPlacement only enables on Hyprland Linux sessions', () => { + assert.equal( + shouldAttemptHyprlandWindowPlacement('linux', { + HYPRLAND_INSTANCE_SIGNATURE: 'abc', + }), + true, + ); + assert.equal( + shouldAttemptHyprlandWindowPlacement('linux', { + WAYLAND_DISPLAY: 'wayland-1', + }), + false, + ); + assert.equal( + shouldAttemptHyprlandWindowPlacement('darwin', { + HYPRLAND_INSTANCE_SIGNATURE: 'abc', + }), + false, + ); +}); + +test('findHyprlandWindowForPlacement matches current process by title', () => { + const client = findHyprlandWindowForPlacement( + [ + { + address: '0xother', + pid: 123, + title: 'SubMiner Stats', + mapped: true, + }, + { + address: '0xmatch', + pid: 456, + title: 'SubMiner Stats', + mapped: true, + }, + ], + { + pid: 456, + title: 'SubMiner Stats', + }, + ); + + assert.equal(client?.address, '0xmatch'); +}); + +test('buildHyprlandPlacementDispatches floats and pins tiled overlay windows', () => { + assert.deepEqual( + buildHyprlandPlacementDispatches({ + address: '0xabc', + floating: false, + pinned: false, + }), + [ + ['dispatch', 'setfloating', 'address:0xabc'], + ['dispatch', 'pin', 'address:0xabc'], + ], + ); +}); + +test('buildHyprlandPlacementDispatches skips already floating and pinned windows', () => { + assert.deepEqual( + buildHyprlandPlacementDispatches({ + address: '0xabc', + floating: true, + pinned: true, + }), + [], + ); +}); + +test('ensureHyprlandWindowFloatingByTitle dispatches placement for matching tiled window', () => { + const calls: unknown[][] = []; + const placed = ensureHyprlandWindowFloatingByTitle({ + title: 'SubMiner Stats', + platform: 'linux', + env: { + HYPRLAND_INSTANCE_SIGNATURE: 'abc', + }, + pid: 456, + execFileSync: ((command: string, args: string[], options: unknown) => { + calls.push([command, args, options]); + if (args.join(' ') === '-j clients') { + return JSON.stringify([ + { + address: '0xmatch', + pid: 456, + title: 'SubMiner Stats', + mapped: true, + floating: false, + pinned: false, + }, + ]); + } + return ''; + }) as never, + }); + + assert.equal(placed, true); + assert.deepEqual( + calls.map(([, args]) => args), + [ + ['-j', 'clients'], + ['dispatch', 'setfloating', 'address:0xmatch'], + ['dispatch', 'pin', 'address:0xmatch'], + ], + ); +}); diff --git a/src/core/services/hyprland-window-placement.ts b/src/core/services/hyprland-window-placement.ts new file mode 100644 index 00000000..d4a2c1fa --- /dev/null +++ b/src/core/services/hyprland-window-placement.ts @@ -0,0 +1,107 @@ +import { execFileSync } from 'node:child_process'; + +export interface HyprlandPlacementClient { + address?: string; + floating?: boolean; + hidden?: boolean; + initialTitle?: string; + mapped?: boolean; + pid?: number; + pinned?: boolean; + title?: string; +} + +type ExecFileSync = typeof execFileSync; + +export function shouldAttemptHyprlandWindowPlacement( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return platform === 'linux' && Boolean(env.HYPRLAND_INSTANCE_SIGNATURE); +} + +function parseHyprlandClients(output: string): HyprlandPlacementClient[] { + const payloadStart = output.indexOf('['); + if (payloadStart < 0) { + return []; + } + + const parsed = JSON.parse(output.slice(payloadStart)) as unknown; + return Array.isArray(parsed) ? (parsed as HyprlandPlacementClient[]) : []; +} + +export function findHyprlandWindowForPlacement( + clients: HyprlandPlacementClient[], + options: { + pid: number; + title: string; + }, +): HyprlandPlacementClient | null { + const title = options.title.trim(); + if (!title) { + return null; + } + + return ( + clients.find( + (client) => + client.pid === options.pid && + client.address && + client.mapped !== false && + client.hidden !== true && + (client.title === title || client.initialTitle === title), + ) ?? null + ); +} + +export function buildHyprlandPlacementDispatches( + client: HyprlandPlacementClient, +): string[][] { + if (!client.address) { + return []; + } + + const windowAddress = `address:${client.address}`; + const dispatches: string[][] = []; + if (client.floating !== true) { + dispatches.push(['dispatch', 'setfloating', windowAddress]); + } + if (client.pinned !== true) { + dispatches.push(['dispatch', 'pin', windowAddress]); + } + return dispatches; +} + +export function ensureHyprlandWindowFloatingByTitle(options: { + title: string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + pid?: number; + execFileSync?: ExecFileSync; +}): boolean { + if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) { + return false; + } + + const run = options.execFileSync ?? execFileSync; + try { + const clients = parseHyprlandClients( + String(run('hyprctl', ['-j', 'clients'], { encoding: 'utf-8' })), + ); + const client = findHyprlandWindowForPlacement(clients, { + pid: options.pid ?? process.pid, + title: options.title, + }); + if (!client) { + return false; + } + + const dispatches = buildHyprlandPlacementDispatches(client); + for (const args of dispatches) { + run('hyprctl', args, { stdio: 'ignore' }); + } + return dispatches.length > 0; + } catch { + return false; + } +} diff --git a/src/core/services/overlay-manager.test.ts b/src/core/services/overlay-manager.test.ts index 6f1ca42e..d557b633 100644 --- a/src/core/services/overlay-manager.test.ts +++ b/src/core/services/overlay-manager.test.ts @@ -77,6 +77,7 @@ test('overlay manager applies bounds for main and modal windows', () => { const visibleCalls: Electron.Rectangle[] = []; const visibleWindow = { isDestroyed: () => false, + getTitle: () => 'SubMiner Overlay', setBounds: (bounds: Electron.Rectangle) => { visibleCalls.push(bounds); }, @@ -84,6 +85,7 @@ test('overlay manager applies bounds for main and modal windows', () => { const modalCalls: Electron.Rectangle[] = []; const modalWindow = { isDestroyed: () => false, + getTitle: () => 'SubMiner Overlay Modal', setBounds: (bounds: Electron.Rectangle) => { modalCalls.push(bounds); }, diff --git a/src/core/services/overlay-window-config.test.ts b/src/core/services/overlay-window-config.test.ts index fda73465..33234bf9 100644 --- a/src/core/services/overlay-window-config.test.ts +++ b/src/core/services/overlay-window-config.test.ts @@ -8,6 +8,7 @@ test('overlay window config explicitly disables renderer sandbox for preload com yomitanSession: null, }); + assert.equal(options.title, 'SubMiner Overlay'); assert.equal(options.backgroundColor, '#00000000'); assert.equal(options.webPreferences?.sandbox, false); assert.equal(options.webPreferences?.backgroundThrottling, false); diff --git a/src/core/services/overlay-window-options.ts b/src/core/services/overlay-window-options.ts index 146373a3..bd69a529 100644 --- a/src/core/services/overlay-window-options.ts +++ b/src/core/services/overlay-window-options.ts @@ -2,6 +2,11 @@ import type { BrowserWindowConstructorOptions, Session } from 'electron'; import * as path from 'path'; import type { OverlayWindowKind } from './overlay-window-input'; +export const OVERLAY_WINDOW_TITLES: Record = { + visible: 'SubMiner Overlay', + modal: 'SubMiner Overlay Modal', +}; + export function buildOverlayWindowOptions( kind: OverlayWindowKind, options: { @@ -14,6 +19,7 @@ export function buildOverlayWindowOptions( return { show: false, + title: OVERLAY_WINDOW_TITLES[kind], width: 800, height: 600, x: 0, diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 05820a5e..0e2ac4fd 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -1,4 +1,5 @@ -import { BrowserWindow, screen, type Session } from 'electron'; +import electron from 'electron'; +import type { BrowserWindow, Session } from 'electron'; import * as path from 'path'; import { WindowGeometry } from '../../types'; import { createLogger } from '../../logger'; @@ -8,12 +9,14 @@ import { handleOverlayWindowBlurred, type OverlayWindowKind, } from './overlay-window-input'; +import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement'; import { buildOverlayWindowOptions } from './overlay-window-options'; import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds'; import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; const logger = createLogger('main:overlay-window'); +const { BrowserWindow: ElectronBrowserWindow, screen } = electron; const overlayWindowLayerByInstance = new WeakMap(); const overlayWindowContentReady = new WeakSet(); @@ -50,6 +53,7 @@ export function updateOverlayWindowBounds( window: BrowserWindow | null, ): void { if (!geometry || !window || window.isDestroyed()) return; + ensureHyprlandWindowFloatingByTitle({ title: window.getTitle() }); window.setBounds(normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen)); } @@ -68,6 +72,7 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void { } window.setAlwaysOnTop(true); window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + ensureHyprlandWindowFloatingByTitle({ title: window.getTitle() }); window.moveTop(); } @@ -99,7 +104,7 @@ export function createOverlayWindow( yomitanSession?: Session | null; }, ): BrowserWindow { - const window = new BrowserWindow(buildOverlayWindowOptions(kind, options)); + const window = new ElectronBrowserWindow(buildOverlayWindowOptions(kind, options)); (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ OVERLAY_WINDOW_CONTENT_READY_FLAG ] = false; diff --git a/src/core/services/stats-window-runtime.ts b/src/core/services/stats-window-runtime.ts index 55bbdec9..aca4a492 100644 --- a/src/core/services/stats-window-runtime.ts +++ b/src/core/services/stats-window-runtime.ts @@ -3,6 +3,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'; type StatsWindowLevelController = Pick & Partial>; @@ -30,6 +31,7 @@ export function buildStatsWindowOptions(options: { bounds?: WindowGeometry | null; }): BrowserWindowConstructorOptions { return { + title: STATS_WINDOW_TITLE, x: options.bounds?.x, y: options.bounds?.y, width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH, diff --git a/src/core/services/stats-window.test.ts b/src/core/services/stats-window.test.ts index 8bc631c5..87b95160 100644 --- a/src/core/services/stats-window.test.ts +++ b/src/core/services/stats-window.test.ts @@ -18,6 +18,7 @@ test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly w }, }); + assert.equal(options.title, 'SubMiner Stats'); assert.equal(options.x, 120); assert.equal(options.y, 80); assert.equal(options.width, 1440); diff --git a/src/core/services/stats-window.ts b/src/core/services/stats-window.ts index f35b11b9..fcf36724 100644 --- a/src/core/services/stats-window.ts +++ b/src/core/services/stats-window.ts @@ -7,7 +7,9 @@ import { buildStatsWindowOptions, promoteStatsWindowLevel, shouldHideStatsWindowForInput, + STATS_WINDOW_TITLE, } from './stats-window-runtime.js'; +import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js'; let statsWindow: BrowserWindow | null = null; let toggleRegistered = false; @@ -41,6 +43,9 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo syncStatsWindowBounds(window, options.resolveBounds()); promoteStatsWindowLevel(window); window.show(); + if (ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE })) { + syncStatsWindowBounds(window, options.resolveBounds()); + } window.focus(); options.onVisibilityChanged?.(true); promoteStatsWindowLevel(window); diff --git a/src/window-trackers/hyprland-tracker.test.ts b/src/window-trackers/hyprland-tracker.test.ts index 1f8c6a60..2c645b00 100644 --- a/src/window-trackers/hyprland-tracker.test.ts +++ b/src/window-trackers/hyprland-tracker.test.ts @@ -149,3 +149,23 @@ test('resolveHyprlandWindowGeometry uses monitor bounds for fullscreen clients', height: 1440, }); }); + +test('resolveHyprlandWindowGeometry uses monitor bounds for client-requested fullscreen', () => { + const geometry = resolveHyprlandWindowGeometry( + makeClient({ + at: [0, 28], + size: [1920, 1052], + monitor: 0, + fullscreen: 0, + fullscreenClient: 2, + }), + [makeMonitor({ id: 0, x: 0, y: 0, width: 1920, height: 1080 })], + ); + + assert.deepEqual(geometry, { + x: 0, + y: 0, + width: 1920, + height: 1080, + }); +}); diff --git a/src/window-trackers/hyprland-tracker.ts b/src/window-trackers/hyprland-tracker.ts index 86207fc8..290a787d 100644 --- a/src/window-trackers/hyprland-tracker.ts +++ b/src/window-trackers/hyprland-tracker.ts @@ -159,7 +159,7 @@ export function parseHyprctlMonitors(output: string): HyprlandMonitor[] | null { } function isHyprlandFullscreenClient(client: HyprlandClient): boolean { - return (client.fullscreen ?? 0) > 0; + return (client.fullscreen ?? 0) > 0 || (client.fullscreenClient ?? 0) > 0; } export function resolveHyprlandWindowGeometry(