fix: align Hyprland fullscreen overlays

This commit is contained in:
2026-05-03 23:02:07 -07:00
parent 95277f30bd
commit 745996c72d
13 changed files with 328 additions and 3 deletions
@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
SubMiner visible overlay is slightly below mpv when mpv is fullscreen on Linux Hyprland. Align overlay bounds with mpv fullscreen client/monitor bounds.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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`
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Overlay: Aligned the Hyprland fullscreen overlay with mpv when mpv reports client-requested fullscreen.
@@ -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'],
],
);
});
@@ -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;
}
}
@@ -77,6 +77,7 @@ test('overlay manager applies bounds for main and modal windows', () => {
const visibleCalls: Electron.Rectangle[] = []; const visibleCalls: Electron.Rectangle[] = [];
const visibleWindow = { const visibleWindow = {
isDestroyed: () => false, isDestroyed: () => false,
getTitle: () => 'SubMiner Overlay',
setBounds: (bounds: Electron.Rectangle) => { setBounds: (bounds: Electron.Rectangle) => {
visibleCalls.push(bounds); visibleCalls.push(bounds);
}, },
@@ -84,6 +85,7 @@ test('overlay manager applies bounds for main and modal windows', () => {
const modalCalls: Electron.Rectangle[] = []; const modalCalls: Electron.Rectangle[] = [];
const modalWindow = { const modalWindow = {
isDestroyed: () => false, isDestroyed: () => false,
getTitle: () => 'SubMiner Overlay Modal',
setBounds: (bounds: Electron.Rectangle) => { setBounds: (bounds: Electron.Rectangle) => {
modalCalls.push(bounds); modalCalls.push(bounds);
}, },
@@ -8,6 +8,7 @@ test('overlay window config explicitly disables renderer sandbox for preload com
yomitanSession: null, yomitanSession: null,
}); });
assert.equal(options.title, 'SubMiner Overlay');
assert.equal(options.backgroundColor, '#00000000'); assert.equal(options.backgroundColor, '#00000000');
assert.equal(options.webPreferences?.sandbox, false); assert.equal(options.webPreferences?.sandbox, false);
assert.equal(options.webPreferences?.backgroundThrottling, false); assert.equal(options.webPreferences?.backgroundThrottling, false);
@@ -2,6 +2,11 @@ import type { BrowserWindowConstructorOptions, Session } from 'electron';
import * as path from 'path'; import * as path from 'path';
import type { OverlayWindowKind } from './overlay-window-input'; import type { OverlayWindowKind } from './overlay-window-input';
export const OVERLAY_WINDOW_TITLES: Record<OverlayWindowKind, string> = {
visible: 'SubMiner Overlay',
modal: 'SubMiner Overlay Modal',
};
export function buildOverlayWindowOptions( export function buildOverlayWindowOptions(
kind: OverlayWindowKind, kind: OverlayWindowKind,
options: { options: {
@@ -14,6 +19,7 @@ export function buildOverlayWindowOptions(
return { return {
show: false, show: false,
title: OVERLAY_WINDOW_TITLES[kind],
width: 800, width: 800,
height: 600, height: 600,
x: 0, x: 0,
+7 -2
View File
@@ -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 * as path from 'path';
import { WindowGeometry } from '../../types'; import { WindowGeometry } from '../../types';
import { createLogger } from '../../logger'; import { createLogger } from '../../logger';
@@ -8,12 +9,14 @@ import {
handleOverlayWindowBlurred, handleOverlayWindowBlurred,
type OverlayWindowKind, type OverlayWindowKind,
} from './overlay-window-input'; } from './overlay-window-input';
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement';
import { buildOverlayWindowOptions } from './overlay-window-options'; import { buildOverlayWindowOptions } from './overlay-window-options';
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds'; import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
export { 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 logger = createLogger('main:overlay-window');
const { BrowserWindow: ElectronBrowserWindow, screen } = electron;
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>(); const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
const overlayWindowContentReady = new WeakSet<BrowserWindow>(); const overlayWindowContentReady = new WeakSet<BrowserWindow>();
@@ -50,6 +53,7 @@ export function updateOverlayWindowBounds(
window: BrowserWindow | null, window: BrowserWindow | null,
): void { ): void {
if (!geometry || !window || window.isDestroyed()) return; if (!geometry || !window || window.isDestroyed()) return;
ensureHyprlandWindowFloatingByTitle({ title: window.getTitle() });
window.setBounds(normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen)); window.setBounds(normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen));
} }
@@ -68,6 +72,7 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void {
} }
window.setAlwaysOnTop(true); window.setAlwaysOnTop(true);
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
ensureHyprlandWindowFloatingByTitle({ title: window.getTitle() });
window.moveTop(); window.moveTop();
} }
@@ -99,7 +104,7 @@ export function createOverlayWindow(
yomitanSession?: Session | null; yomitanSession?: Session | null;
}, },
): BrowserWindow { ): BrowserWindow {
const window = new BrowserWindow(buildOverlayWindowOptions(kind, options)); const window = new ElectronBrowserWindow(buildOverlayWindowOptions(kind, options));
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG OVERLAY_WINDOW_CONTENT_READY_FLAG
] = false; ] = false;
@@ -3,6 +3,7 @@ import type { WindowGeometry } from '../../types';
const DEFAULT_STATS_WINDOW_WIDTH = 900; const DEFAULT_STATS_WINDOW_WIDTH = 900;
const DEFAULT_STATS_WINDOW_HEIGHT = 700; const DEFAULT_STATS_WINDOW_HEIGHT = 700;
export const STATS_WINDOW_TITLE = 'SubMiner Stats';
type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTop'> & type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTop'> &
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>; Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
@@ -30,6 +31,7 @@ export function buildStatsWindowOptions(options: {
bounds?: WindowGeometry | null; bounds?: WindowGeometry | null;
}): BrowserWindowConstructorOptions { }): BrowserWindowConstructorOptions {
return { return {
title: STATS_WINDOW_TITLE,
x: options.bounds?.x, x: options.bounds?.x,
y: options.bounds?.y, y: options.bounds?.y,
width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH, width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH,
+1
View File
@@ -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.x, 120);
assert.equal(options.y, 80); assert.equal(options.y, 80);
assert.equal(options.width, 1440); assert.equal(options.width, 1440);
+5
View File
@@ -7,7 +7,9 @@ import {
buildStatsWindowOptions, buildStatsWindowOptions,
promoteStatsWindowLevel, promoteStatsWindowLevel,
shouldHideStatsWindowForInput, shouldHideStatsWindowForInput,
STATS_WINDOW_TITLE,
} from './stats-window-runtime.js'; } from './stats-window-runtime.js';
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js';
let statsWindow: BrowserWindow | null = null; let statsWindow: BrowserWindow | null = null;
let toggleRegistered = false; let toggleRegistered = false;
@@ -41,6 +43,9 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
syncStatsWindowBounds(window, options.resolveBounds()); syncStatsWindowBounds(window, options.resolveBounds());
promoteStatsWindowLevel(window); promoteStatsWindowLevel(window);
window.show(); window.show();
if (ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE })) {
syncStatsWindowBounds(window, options.resolveBounds());
}
window.focus(); window.focus();
options.onVisibilityChanged?.(true); options.onVisibilityChanged?.(true);
promoteStatsWindowLevel(window); promoteStatsWindowLevel(window);
@@ -149,3 +149,23 @@ test('resolveHyprlandWindowGeometry uses monitor bounds for fullscreen clients',
height: 1440, 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,
});
});
+1 -1
View File
@@ -159,7 +159,7 @@ export function parseHyprctlMonitors(output: string): HyprlandMonitor[] | null {
} }
function isHyprlandFullscreenClient(client: HyprlandClient): boolean { function isHyprlandFullscreenClient(client: HyprlandClient): boolean {
return (client.fullscreen ?? 0) > 0; return (client.fullscreen ?? 0) > 0 || (client.fullscreenClient ?? 0) > 0;
} }
export function resolveHyprlandWindowGeometry( export function resolveHyprlandWindowGeometry(