fix: align Hyprland overlay windows to mpv and stop pinning them

- Force-apply exact Hyprland move/resize/setprop dispatches when bounds are provided
- Stop pinning overlay windows; toggle pin off when Hyprland reports pinned=true
- Compensate stats overlay outer placement for Electron/Wayland content insets
- Make stats overlay window and page opaque so mpv cannot show through transparent insets
- Constrain stats app to h-screen with internal scroll so content covers mpv from y=0
- Lock overlay/stats window titles against page-title-updated events
- Add regression coverage for placement dispatches, inset compensation, and CSS overlay mode
This commit is contained in:
2026-05-03 23:56:52 -07:00
parent 8f43f8825d
commit 2b60c20711
15 changed files with 398 additions and 85 deletions
@@ -53,32 +53,67 @@ test('findHyprlandWindowForPlacement matches current process by title', () => {
assert.equal(client?.address, '0xmatch');
});
test('buildHyprlandPlacementDispatches floats and pins tiled overlay windows', () => {
test('buildHyprlandPlacementDispatches floats tiled overlay windows without pinning them', () => {
assert.deepEqual(
buildHyprlandPlacementDispatches({
address: '0xabc',
floating: false,
pinned: false,
}),
[['dispatch', 'setfloating', 'address:0xabc']],
);
});
test('buildHyprlandPlacementDispatches force-aligns floating overlay windows to target bounds', () => {
assert.deepEqual(
buildHyprlandPlacementDispatches(
{
address: '0xabc',
floating: true,
pinned: false,
},
{
x: 0,
y: 0,
width: 1920,
height: 1080,
},
),
[
['dispatch', 'setfloating', 'address:0xabc'],
['dispatch', 'pin', 'address:0xabc'],
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xabc'],
['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xabc'],
['dispatch', 'setprop', 'address:0xabc rounding 0'],
['dispatch', 'setprop', 'address:0xabc border_size 0'],
['dispatch', 'setprop', 'address:0xabc no_shadow 1'],
['dispatch', 'setprop', 'address:0xabc no_blur 1'],
['dispatch', 'setprop', 'address:0xabc decorate 0'],
],
);
});
test('buildHyprlandPlacementDispatches skips already floating and pinned windows', () => {
test('buildHyprlandPlacementDispatches does not pin already floating overlay windows', () => {
assert.deepEqual(
buildHyprlandPlacementDispatches({
address: '0xabc',
floating: true,
pinned: false,
}),
[],
);
});
test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows', () => {
assert.deepEqual(
buildHyprlandPlacementDispatches({
address: '0xabc',
floating: true,
pinned: true,
}),
[],
[['dispatch', 'pin', 'address:0xabc']],
);
});
test('ensureHyprlandWindowFloatingByTitle dispatches placement for matching tiled window', () => {
test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for matching tiled window', () => {
const calls: unknown[][] = [];
const placed = ensureHyprlandWindowFloatingByTitle({
title: 'SubMiner Stats',
@@ -111,7 +146,55 @@ test('ensureHyprlandWindowFloatingByTitle dispatches placement for matching tile
[
['-j', 'clients'],
['dispatch', 'setfloating', 'address:0xmatch'],
['dispatch', 'pin', 'address:0xmatch'],
],
);
});
test('ensureHyprlandWindowFloatingByTitle dispatches exact Hyprland geometry when bounds are provided', () => {
const calls: unknown[][] = [];
const placed = ensureHyprlandWindowFloatingByTitle({
title: 'SubMiner Stats',
platform: 'linux',
env: {
HYPRLAND_INSTANCE_SIGNATURE: 'abc',
},
pid: 456,
bounds: {
x: 0,
y: 0,
width: 1920,
height: 1080,
},
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: true,
pinned: false,
},
]);
}
return '';
}) as never,
});
assert.equal(placed, true);
assert.deepEqual(
calls.map(([, args]) => args),
[
['-j', 'clients'],
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xmatch'],
['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xmatch'],
['dispatch', 'setprop', 'address:0xmatch rounding 0'],
['dispatch', 'setprop', 'address:0xmatch border_size 0'],
['dispatch', 'setprop', 'address:0xmatch no_shadow 1'],
['dispatch', 'setprop', 'address:0xmatch no_blur 1'],
['dispatch', 'setprop', 'address:0xmatch decorate 0'],
],
);
});
+51 -2
View File
@@ -11,6 +11,13 @@ export interface HyprlandPlacementClient {
title?: string;
}
export interface HyprlandPlacementBounds {
x: number;
y: number;
width: number;
height: number;
}
type ExecFileSync = typeof execFileSync;
export function shouldAttemptHyprlandWindowPlacement(
@@ -56,6 +63,7 @@ export function findHyprlandWindowForPlacement(
export function buildHyprlandPlacementDispatches(
client: HyprlandPlacementClient,
bounds?: HyprlandPlacementBounds | null,
): string[][] {
if (!client.address) {
return [];
@@ -66,14 +74,55 @@ export function buildHyprlandPlacementDispatches(
if (client.floating !== true) {
dispatches.push(['dispatch', 'setfloating', windowAddress]);
}
if (client.pinned !== true) {
if (client.pinned === true) {
dispatches.push(['dispatch', 'pin', windowAddress]);
}
const roundedBounds = roundPlacementBounds(bounds);
if (roundedBounds) {
dispatches.push([
'dispatch',
'movewindowpixel',
`exact ${roundedBounds.x} ${roundedBounds.y},${windowAddress}`,
]);
dispatches.push([
'dispatch',
'resizewindowpixel',
`exact ${roundedBounds.width} ${roundedBounds.height},${windowAddress}`,
]);
dispatches.push(['dispatch', 'setprop', `${windowAddress} rounding 0`]);
dispatches.push(['dispatch', 'setprop', `${windowAddress} border_size 0`]);
dispatches.push(['dispatch', 'setprop', `${windowAddress} no_shadow 1`]);
dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]);
dispatches.push(['dispatch', 'setprop', `${windowAddress} decorate 0`]);
}
return dispatches;
}
function roundPlacementBounds(
bounds?: HyprlandPlacementBounds | null,
): HyprlandPlacementBounds | null {
if (!bounds) {
return null;
}
const rounded = {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.round(bounds.width),
height: Math.round(bounds.height),
};
return Number.isFinite(rounded.x) &&
Number.isFinite(rounded.y) &&
Number.isFinite(rounded.width) &&
Number.isFinite(rounded.height) &&
rounded.width > 0 &&
rounded.height > 0
? rounded
: null;
}
export function ensureHyprlandWindowFloatingByTitle(options: {
title: string;
bounds?: HyprlandPlacementBounds | null;
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
pid?: number;
@@ -96,7 +145,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
return false;
}
const dispatches = buildHyprlandPlacementDispatches(client);
const dispatches = buildHyprlandPlacementDispatches(client, options.bounds);
for (const args of dispatches) {
run('hyprctl', args, { stdio: 'ignore' });
}
+10 -3
View File
@@ -10,7 +10,7 @@ import {
type OverlayWindowKind,
} from './overlay-window-input';
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement';
import { buildOverlayWindowOptions } from './overlay-window-options';
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } 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';
@@ -53,8 +53,9 @@ export function updateOverlayWindowBounds(
window: BrowserWindow | null,
): void {
if (!geometry || !window || window.isDestroyed()) return;
ensureHyprlandWindowFloatingByTitle({ title: window.getTitle() });
window.setBounds(normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen));
const bounds = normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen);
window.setBounds(bounds);
ensureHyprlandWindowFloatingByTitle({ title: window.getTitle(), bounds });
}
export function ensureOverlayWindowLevel(window: BrowserWindow): void {
@@ -119,9 +120,15 @@ export function createOverlayWindow(
});
window.webContents.on('did-finish-load', () => {
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
options.onRuntimeOptionsChanged();
});
window.webContents.on('page-title-updated', (event) => {
event.preventDefault();
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
});
window.once('ready-to-show', () => {
overlayWindowContentReady.add(window);
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
+28 -2
View File
@@ -8,6 +8,8 @@ export const STATS_WINDOW_TITLE = 'SubMiner Stats';
type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTop'> &
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean {
return (
input.type === 'keyDown' &&
@@ -37,7 +39,7 @@ export function buildStatsWindowOptions(options: {
width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH,
height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT,
frame: false,
transparent: true,
transparent: false,
alwaysOnTop: true,
resizable: false,
skipTaskbar: true,
@@ -45,7 +47,7 @@ export function buildStatsWindowOptions(options: {
focusable: true,
acceptFirstMouse: true,
fullscreenable: false,
backgroundColor: '#1e1e2e',
backgroundColor: '#24273a',
show: false,
webPreferences: {
nodeIntegration: false,
@@ -56,6 +58,30 @@ export function buildStatsWindowOptions(options: {
};
}
export function resolveStatsWindowOuterBoundsForContent(
window: StatsWindowBoundsController,
target: WindowGeometry,
): WindowGeometry {
const outer = window.getBounds();
const content = window.getContentBounds();
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 target;
}
return {
x: target.x - leftInset,
y: target.y - topInset,
width: target.width + leftInset + rightInset,
height: target.height + topInset + bottomInset,
};
}
export function promoteStatsWindowLevel(
window: StatsWindowLevelController,
platform: NodeJS.Platform = process.platform,
+30 -1
View File
@@ -4,6 +4,7 @@ import {
buildStatsWindowLoadFileOptions,
buildStatsWindowOptions,
promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent,
shouldHideStatsWindowForInput,
} from './stats-window-runtime';
@@ -24,7 +25,8 @@ test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly w
assert.equal(options.width, 1440);
assert.equal(options.height, 900);
assert.equal(options.frame, false);
assert.equal(options.transparent, true);
assert.equal(options.transparent, false);
assert.equal(options.backgroundColor, '#24273a');
assert.equal(options.resizable, false);
assert.equal(options.webPreferences?.preload, '/tmp/preload-stats.js');
assert.equal(options.webPreferences?.contextIsolation, true);
@@ -152,6 +154,33 @@ test('buildStatsWindowLoadFileOptions includes provided stats API base URL', ()
});
});
test('resolveStatsWindowOuterBoundsForContent compensates for Wayland content insets', () => {
assert.deepEqual(
resolveStatsWindowOuterBoundsForContent(
{
getBounds: () => ({ x: 0, y: 0, width: 3440, height: 1440 }),
getContentBounds: () => ({ x: 0, y: 14, width: 3440, height: 1426 }),
},
{ x: 0, y: 0, width: 3440, height: 1440 },
),
{ x: 0, y: -14, width: 3440, height: 1454 },
);
});
test('resolveStatsWindowOuterBoundsForContent ignores invalid inset geometry', () => {
const target = { x: 0, y: 0, width: 3440, height: 1440 };
assert.deepEqual(
resolveStatsWindowOuterBoundsForContent(
{
getBounds: () => ({ x: 0, y: 0, width: 3440, height: 1440 }),
getContentBounds: () => ({ x: -1, y: 0, width: 3440, height: 1440 }),
},
target,
),
target,
);
});
test('promoteStatsWindowLevel raises stats above overlay level on macOS', () => {
const calls: string[] = [];
promoteStatsWindowLevel(
+23 -10
View File
@@ -6,6 +6,7 @@ import {
buildStatsWindowLoadFileOptions,
buildStatsWindowOptions,
promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent,
shouldHideStatsWindowForInput,
STATS_WINDOW_TITLE,
} from './stats-window-runtime.js';
@@ -29,22 +30,29 @@ export interface StatsWindowOptions {
onVisibilityChanged?: (visible: boolean) => void;
}
function syncStatsWindowBounds(window: BrowserWindow, bounds: WindowGeometry | null): void {
if (!bounds || window.isDestroyed()) return;
function syncStatsWindowBounds(
window: BrowserWindow,
bounds: WindowGeometry | null,
): WindowGeometry | null {
if (!bounds || window.isDestroyed()) return null;
const outerBounds = resolveStatsWindowOuterBoundsForContent(window, bounds);
window.setBounds({
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
x: outerBounds.x,
y: outerBounds.y,
width: outerBounds.width,
height: outerBounds.height,
});
return outerBounds;
}
function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): void {
syncStatsWindowBounds(window, options.resolveBounds());
const bounds = options.resolveBounds();
let placementBounds = syncStatsWindowBounds(window, bounds);
promoteStatsWindowLevel(window);
window.show();
if (ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE })) {
syncStatsWindowBounds(window, options.resolveBounds());
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
if (!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })) {
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
}
window.focus();
options.onVisibilityChanged?.(true);
@@ -64,6 +72,12 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
}),
);
statsWindow.setTitle(STATS_WINDOW_TITLE);
statsWindow.webContents.on('page-title-updated', (event) => {
event.preventDefault();
statsWindow?.setTitle(STATS_WINDOW_TITLE);
});
const indexPath = path.join(options.staticDir, 'index.html');
statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(options.getApiBaseUrl?.()));
@@ -79,7 +93,6 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
options.onVisibilityChanged?.(false);
}
});
statsWindow.once('ready-to-show', () => {
if (!statsWindow) return;
showStatsWindow(statsWindow, options);