mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-04 00:41:33 -07:00
fix: align Hyprland fullscreen overlays
This commit is contained in:
@@ -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 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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<OverlayWindowKind, string> = {
|
||||
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,
|
||||
|
||||
@@ -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<BrowserWindow, OverlayWindowKind>();
|
||||
const overlayWindowContentReady = new WeakSet<BrowserWindow>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<BrowserWindow, 'setAlwaysOnTop' | 'moveTop'> &
|
||||
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user