mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-13 03:13:32 -07:00
361 lines
10 KiB
TypeScript
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';
|
|
}
|