mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 03:13:32 -07:00
fix(overlay): correct Hyprland fullscreen overlay alignment on Linux (#107)
This commit is contained in:
@@ -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.
|
||||
@@ -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'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<BrowserWindow, 'setAlwaysOnTop' | 'moveTop'> &
|
||||
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
|
||||
@@ -26,6 +27,14 @@ type StatsNativeConfirmDialogPresenter<WindowT> = {
|
||||
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
|
||||
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
|
||||
Partial<Pick<BrowserWindow, 'showInactive'>>;
|
||||
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<string, string>;
|
||||
} {
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+21
-2
@@ -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()) {
|
||||
|
||||
@@ -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']]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<OverlayBoundsWindow | null | undefined>,
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user