fix(overlay): keep Hyprland recovery dialogs above overlays (#245)

This commit is contained in:
2026-09-11 01:52:01 -07:00
committed by GitHub
parent 0c37c665a2
commit 6d69a56574
4 changed files with 161 additions and 4 deletions
@@ -156,6 +156,107 @@ test('buildHyprlandPlacementDispatches does not pin already floating overlay win
);
});
test('Hyprland placement keeps a recovery dialog above the input-catching overlay', () => {
for (const configProvider of ['hyprlang', 'lua']) {
for (const retryBounds of [false, true]) {
// Bottom to top, as when Hyprland opens its recovery dialog over playback.
const stack = ['0xmpv', '0xoverlay', '0xdialog'];
const clients = [
{
address: '0xoverlay',
pid: 456,
title: 'SubMiner Overlay',
floating: true,
workspace: { id: 1 },
at: [10, 20],
size: [100, 100],
},
{
address: '0xdialog',
class: 'hyprland-dialog',
mapped: true,
hidden: false,
workspace: { id: 1 },
},
];
let clientReads = 0;
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay',
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
pid: 456,
bounds: retryBounds ? { x: 0, y: 0, width: 1280, height: 720 } : undefined,
execFileSync: (_command, args) => {
if (args.join(' ') === '-j clients') {
clientReads += 1;
return JSON.stringify(clients);
}
if (args.join(' ') === '-j status') return JSON.stringify({ configProvider });
if (args.join(' ').match(/alterzorder|alter_zorder/)) {
const address = args.join(' ').match(/address:(0x\w+)/)?.[1];
assert.ok(address);
stack.splice(stack.indexOf(address), 1);
stack.push(address);
}
return '';
},
});
assert.equal(status.dispatched, true);
assert.equal(clientReads, retryBounds ? 2 : 1);
assert.deepEqual(stack, ['0xmpv', '0xoverlay', '0xdialog'], configProvider);
}
}
});
test('Hyprland placement only promotes mapped dialogs on the placed window workspace', () => {
const calls: string[] = [];
const dialog = {
class: 'hyprland-dialog',
mapped: true,
hidden: false,
workspace: { id: 1 },
};
const clients = [
{
address: '0xoverlay',
pid: 456,
title: 'SubMiner Overlay',
floating: true,
workspace: { id: 1 },
},
{ ...dialog, address: '0xhidden', hidden: true },
{ ...dialog, address: '0xunmapped', mapped: false },
{ ...dialog, address: '0xother', workspace: { id: 2 } },
{ ...dialog, address: '0xordinary', class: 'terminal' },
{ ...dialog, address: '0xinitial', class: '', initialClass: 'hyprland-dialog' },
];
for (const promote of [true, false]) {
calls.length = 0;
ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay',
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
pid: 456,
promote,
execFileSync: (_command, args) => {
if (args.join(' ') === '-j clients') return JSON.stringify(clients);
if (args.join(' ') === '-j status') return JSON.stringify({ configProvider: 'hyprlang' });
calls.push(args.join(' '));
return '';
},
});
assert.deepEqual(
calls,
promote
? [
'dispatch alterzorder top,address:0xoverlay',
'dispatch alterzorder top,address:0xinitial',
]
: [],
);
}
});
test('buildHyprlandPlacementDispatches can update placement without raising z-order', () => {
const buildDispatches = buildHyprlandPlacementDispatches as (
client: Parameters<typeof buildHyprlandPlacementDispatches>[0],
+52 -4
View File
@@ -3,14 +3,17 @@ import { execFileSync } from 'node:child_process';
export interface HyprlandPlacementClient {
address?: string;
at?: [number, number];
class?: string;
floating?: boolean;
hidden?: boolean;
initialClass?: string;
initialTitle?: string;
mapped?: boolean;
pid?: number;
pinned?: boolean;
size?: [number, number];
title?: string;
workspace?: { id: number };
}
export interface HyprlandPlacementBounds {
@@ -25,7 +28,11 @@ export interface HyprlandPlacementDispatchOptions {
promote?: boolean;
}
type ExecFileSync = typeof execFileSync;
type ExecFileSync = (
file: string,
args: string[],
options: NonNullable<Parameters<typeof execFileSync>[2]>,
) => ReturnType<typeof execFileSync>;
export type HyprlandConfigProvider = 'hyprlang' | 'lua';
export function shouldAttemptHyprlandWindowPlacement(
@@ -154,6 +161,33 @@ function luaWindowDispatch(name: string, windowAddress: string, fields: string[]
];
}
// Compositor recovery dialogs must remain clickable even when an overlay still owns input.
function buildHyprlandDialogPromotionDispatches(
clients: HyprlandPlacementClient[],
placedClient: HyprlandPlacementClient,
configProvider: HyprlandConfigProvider,
): string[][] {
if (typeof placedClient.workspace?.id !== 'number') return [];
return clients.flatMap((client) => {
if (
!client.address ||
client.address === placedClient.address ||
client.mapped === false ||
client.hidden === true ||
client.workspace?.id !== placedClient.workspace?.id ||
(client.class !== 'hyprland-dialog' && client.initialClass !== 'hyprland-dialog')
) {
return [];
}
const windowAddress = `address:${client.address}`;
return [
configProvider === 'lua'
? luaWindowDispatch('alter_zorder', windowAddress, ['mode = "top"'])
: ['dispatch', 'alterzorder', `top,${windowAddress}`],
];
});
}
function luaWindowSetProp(windowAddress: string, prop: string, value: string): string[] {
return luaWindowDispatch('set_prop', windowAddress, [
`prop = ${luaString(prop)}`,
@@ -331,12 +365,16 @@ export function ensureHyprlandWindowFloatingByTitleWithStatus(options: {
configProvider,
promote: options.promote,
});
if (options.promote !== false) {
dispatches.push(...buildHyprlandDialogPromotionDispatches(clients, client, configProvider));
}
for (const args of dispatches) {
run('hyprctl', args, { stdio: 'ignore' });
}
if (shouldVerifyBounds) {
try {
const refreshedClient = findHyprlandWindowForPlacement(readHyprlandPlacementClients(run), {
const refreshedClients = readHyprlandPlacementClients(run);
const refreshedClient = findHyprlandWindowForPlacement(refreshedClients, {
pid: options.pid ?? process.pid,
title: options.title,
});
@@ -345,10 +383,20 @@ export function ensureHyprlandWindowFloatingByTitleWithStatus(options: {
targetBounds &&
clientMatchesPlacementBounds(refreshedClient, targetBounds) === false
) {
for (const args of buildHyprlandPlacementDispatches(refreshedClient, targetBounds, {
const retryDispatches = buildHyprlandPlacementDispatches(refreshedClient, targetBounds, {
configProvider,
promote: options.promote,
})) {
});
if (options.promote !== false) {
retryDispatches.push(
...buildHyprlandDialogPromotionDispatches(
refreshedClients,
refreshedClient,
configProvider,
),
);
}
for (const args of retryDispatches) {
run('hyprctl', args, { stdio: 'ignore' });
}
}