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

158 lines
4.2 KiB
TypeScript

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;
}
export interface HyprlandPlacementBounds {
x: number;
y: number;
width: number;
height: number;
}
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,
bounds?: HyprlandPlacementBounds | null,
): 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]);
}
const roundedBounds = roundPlacementBounds(bounds);
if (roundedBounds) {
dispatches.push([
'dispatch',
'movewindowpixel',
`exact ${roundedBounds.x} ${roundedBounds.y},${windowAddress}`,
]);
dispatches.push([
'dispatch',
'resizewindowpixel',
`exact ${roundedBounds.width} ${roundedBounds.height},${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`]);
}
dispatches.push(['dispatch', 'alterzorder', `top,${windowAddress}`]);
return dispatches;
}
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;
}
export function ensureHyprlandWindowFloatingByTitle(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 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, options.bounds);
for (const args of dispatches) {
run('hyprctl', args, { stdio: 'ignore' });
}
return dispatches.length > 0;
} catch {
return false;
}
}