mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-13 20:12:54 -07:00
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:
@@ -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'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,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 })[
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user