Files
SubMiner/src/core/services/hyprland-window-placement.ts
T

361 lines
10 KiB
TypeScript

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;
}
export interface HyprlandPlacementBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface HyprlandPlacementDispatchOptions {
configProvider?: HyprlandConfigProvider;
promote?: boolean;
}
type ExecFileSync = typeof execFileSync;
export type HyprlandConfigProvider = 'hyprlang' | 'lua';
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[]) : [];
}
function readHyprlandPlacementClients(run: ExecFileSync): HyprlandPlacementClient[] {
return parseHyprlandClients(String(run('hyprctl', ['-j', 'clients'], { encoding: 'utf-8' })));
}
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,
bounds?: HyprlandPlacementBounds | null,
options: HyprlandPlacementDispatchOptions = {},
): string[][] {
if (!client.address) {
return [];
}
const windowAddress = `address:${client.address}`;
const configProvider = options.configProvider ?? 'hyprlang';
const dispatches: string[][] = [];
if (client.floating !== true) {
dispatches.push(
configProvider === 'lua'
? luaWindowDispatch('float', windowAddress, ['action = "on"'])
: ['dispatch', 'setfloating', windowAddress],
);
}
if (client.pinned === true) {
dispatches.push(
configProvider === 'lua'
? luaWindowDispatch('pin', windowAddress, ['action = "off"'])
: ['dispatch', 'pin', windowAddress],
);
}
const roundedBounds = roundPlacementBounds(bounds);
if (roundedBounds) {
if (configProvider === 'lua') {
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'));
dispatches.push(luaWindowSetProp(windowAddress, 'no_blur', '1'));
dispatches.push(luaWindowSetProp(windowAddress, 'decorate', '0'));
} else {
dispatches.push([
'dispatch',
'resizewindowpixel',
`exact ${roundedBounds.width} ${roundedBounds.height},${windowAddress}`,
]);
dispatches.push([
'dispatch',
'movewindowpixel',
`exact ${roundedBounds.x} ${roundedBounds.y},${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`]);
}
}
if (options.promote !== false) {
dispatches.push(
configProvider === 'lua'
? luaWindowDispatch('alter_zorder', windowAddress, ['mode = "top"'])
: ['dispatch', 'alterzorder', `top,${windowAddress}`],
);
}
return dispatches;
}
function luaWindowDispatch(name: string, windowAddress: string, fields: string[]): string[] {
return [
'dispatch',
`hl.dsp.window.${name}({ ${[...fields, `window = ${luaString(windowAddress)}`].join(', ')} })`,
];
}
function luaWindowSetProp(windowAddress: string, prop: string, value: string): string[] {
return luaWindowDispatch('set_prop', windowAddress, [
`prop = ${luaString(prop)}`,
`value = ${luaString(value)}`,
]);
}
function luaString(value: string): string {
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
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;
}
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;
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
pid?: number;
promote?: boolean;
execFileSync?: ExecFileSync;
}): boolean {
if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) {
return false;
}
const run = options.execFileSync ?? execFileSync;
try {
const clients = readHyprlandPlacementClients(run);
const client = findHyprlandWindowForPlacement(clients, {
pid: options.pid ?? process.pid,
title: options.title,
});
if (!client) {
return false;
}
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,
});
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;
}
}
function detectHyprlandConfigProvider(run: ExecFileSync): HyprlandConfigProvider {
try {
return parseHyprlandConfigProvider(
String(run('hyprctl', ['-j', 'status'], { encoding: 'utf-8' })),
);
} catch {
return 'hyprlang';
}
}
function parseHyprlandConfigProvider(output: string): HyprlandConfigProvider {
const payloadStart = output.indexOf('{');
if (payloadStart < 0) {
return 'hyprlang';
}
const parsed = JSON.parse(output.slice(payloadStart)) as unknown;
return isHyprlandStatusPayload(parsed) && parsed.configProvider === 'lua' ? 'lua' : 'hyprlang';
}
function isHyprlandStatusPayload(value: unknown): value is { configProvider?: unknown } {
return Boolean(value) && typeof value === 'object';
}