mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-12 05:16:19 -07:00
fix(overlay): keep Hyprland recovery dialogs above overlays (#245)
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user