import { parseKeybindingShortcut } from "@t3tools/shared/keybindings";import { readKdlNodes, type KdlNode } from "./captureConfigKdl.ts";import { niriCaptureBinding } from "./linuxCaptureSession.ts"; export type CaptureConfigFormat = "niri" | "hyprland" | "hyprland-lua"; export function captureConfigKeys(input = "Ctrl+Shift+2") { const keys = parseKeybindingShortcut(input.replace(/super/gi, "meta")); if ( !keys || keys.modKey || !/^(?:[a-z0-9]|f(?:[1-9]|1[0-9]|2[0-4]))$/.test(keys.key) || !(keys.ctrlKey || keys.altKey || keys.metaKey) ) throw new Error("Choose a letter, number, or function key with Ctrl, Alt, or Super."); const modifiers = [ keys.ctrlKey && "Ctrl", keys.altKey && "Alt", keys.shiftKey && "Shift", keys.metaKey && "Super", ].filter(Boolean); return { label: [...modifiers, keys.key.toUpperCase()].join("+"), modifiers: modifiers.map((key) => String(key).toUpperCase()).join(" "), key: keys.key.toUpperCase(), mask: (keys.shiftKey ? 1 : 0) | (keys.ctrlKey ? 4 : 0) | (keys.altKey ? 8 : 0) | (keys.metaKey ? 64 : 0), };} function sameKeys(left: string, right: string) { try { return captureConfigKeys(left).label === captureConfigKeys(right).label; } catch { return false; }} export function captureConfigBinding(format: CaptureConfigFormat, appId: string, keys: string) { if (!/^[A-Za-z0-9_.-]+$/.test(appId)) throw new Error("Invalid capture application ID."); const chord = captureConfigKeys(keys); if (format === "niri") return niriCaptureBinding(appId).replace("Ctrl+Shift+2", chord.label); const action = `${appId}:capture-window`; return format === "hyprland-lua" ? `hl.bind("${chord.label.toUpperCase().replaceAll("+", " + ")}", hl.dsp.global("${action}"))` : `bind = ${chord.modifiers}, ${chord.key}, global, ${action}`;} function niriBinds(source: string) { const blocks = readKdlNodes(source).filter((node) => node.name === "binds"); if (blocks.length > 1 || blocks.some((node) => node.close === undefined)) throw new Error("This Niri config has an unexpected binds section. Check it in Advanced."); return blocks[0];} function niriCaptureNode(node: KdlNode, appId: string) { const expected = readKdlNodes(captureConfigBinding("niri", appId, "Ctrl+Shift+2"))[0]! .children[0]!; const action = node.children[0]; return ( node.children.length === 1 && action?.children.length === 0 && JSON.stringify(action.header.map((token) => token.value)) === JSON.stringify(expected.header.map((token) => token.value)) );} function hyprlandBinds(source: string, lua: boolean) { const bindings: { start: number; end: number; keys: string; action: string }[] = []; let start = 0; for (const line of source.split(/(?<=\n)/)) { const legacy = !lua && /^\s*bind[a-z]*\s*=\s*([^,]*),\s*([^,]*),\s*([^,]*),\s*(.*?)\s*(?:#.*)?$/.exec( line.trimEnd(), ); const modern = lua && /^\s*hl\.bind\(\s*(["'])(.*?)\1\s*,\s*hl\.dsp\.global\(\s*(["'])(.*?)\3\s*\)\s*\)\s*;?\s*(?:--.*)?$/.exec( line.trimEnd(), ); if (legacy) bindings.push({ start, end: start + line.length, keys: `${legacy[1]!.trim().replace(/\s+/g, "+")}+${legacy[2]!.trim()}`, action: legacy[3]!.trim() === "global" ? legacy[4]!.trim() : "", }); if (modern) bindings.push({ start, end: start + line.length, keys: modern[2]!, action: modern[4]! }); start += line.length; } return bindings;} export function niriConfigIncludes(source: string) { return readKdlNodes(source) .filter((node) => node.name === "include") .map((node) => { const path = node.header.find( (token, index) => index > 0 && token.quoted && node.header[index - 1]?.value !== "=", )?.value; if (!path) throw new Error("Couldn't resolve a Niri include. Choose the config in Advanced."); const optional = node.header.some( (token, index) => token.value === "optional" && node.header[index + 1]?.value === "=" && node.header[index + 2]?.value === "true", ); return { path, optional }; });} export function niriConfigConflict(source: string, appId: string, keys: string) { return ( niriBinds(source)?.children.some( (node) => sameKeys(node.name, keys) && !niriCaptureNode(node, appId), ) ?? false );} function removeNodes(source: string, ranges: { start: number; end: number }[]) { let result = source; for (const range of [...ranges].sort((a, b) => b.start - a.start)) { const lineStart = source.lastIndexOf("\n", range.start - 1) + 1; const nextLine = source.indexOf("\n", range.end); const lineEnd = nextLine < 0 ? source.length : nextLine; const wholeLine = !source.slice(lineStart, range.start).trim() && !source.slice(range.end, lineEnd).trim(); result = result.slice(0, wholeLine ? lineStart : range.start) + result.slice(wholeLine ? Math.min(lineEnd + 1, source.length) : range.end); } return result;} export function editCaptureConfig( source: string, format: CaptureConfigFormat, appId: string, operation: "install" | "remove", requestedKeys?: string,) { if (format === "hyprland-lua" && (/^\s*return\b/m.test(source) || /\[=*\[/.test(source))) throw new Error( "This Lua config needs a manual edit. Choose your bindings file or use manual setup in Advanced.", ); const niri = format === "niri"; const existing = niri ? (niriBinds(source)?.children ?? []) .filter((node) => niriCaptureNode(node, appId)) .map((node) => ({ ...node, keys: node.name })) : hyprlandBinds(source, format === "hyprland-lua").filter( (bind) => bind.action === `${appId}:capture-window`, ); const keys = captureConfigKeys(requestedKeys ?? existing[0]?.keys).label; if (operation === "install" && existing.length === 1 && sameKeys(existing[0]!.keys, keys)) return { after: source, shortcut: keys }; let after = removeNodes(source, existing); if (operation === "remove") return { after, shortcut: keys }; const conflict = niri ? niriConfigConflict(after, appId, keys) : hyprlandBinds(after, false).some((bind) => sameKeys(bind.keys, keys)); if (conflict) throw new Error(`${keys} is already used in this config. Choose another shortcut.`); const newline = source.includes("\r\n") ? "\r\n" : "\n"; const binding = captureConfigBinding(format, appId, keys); if (niri) { const block = niriBinds(after); if (block?.close !== undefined) { const lineStart = after.lastIndexOf("\n", block.close - 1) + 1; const indent = after.slice(lineStart, block.close); if (!indent.trim()) after = after.slice(0, lineStart) + indent + " " + binding + newline + after.slice(lineStart); else after = after.slice(0, block.close) + newline + " " + binding + newline + after.slice(block.close); } else after += `${after && !after.endsWith("\n") ? newline : ""}${newline}binds {${newline} ${binding}${newline}}${newline}`; readKdlNodes(after); } else { // Do not append executable Lua inside a block/string, or after an early return. // The normal Omarchy binding file is a sequence of top-level hl.bind calls. after += `${after && !after.endsWith("\n") ? newline : ""}${newline}${binding}${newline}`; } return { after, shortcut: keys };}
import { parseKeybindingShortcut } from "@t3tools/shared/keybindings";
import { readKdlNodes, type KdlNode } from "./captureConfigKdl.ts";
import { niriCaptureBinding } from "./linuxCaptureSession.ts";
export type CaptureConfigFormat = "niri" | "hyprland" | "hyprland-lua";
export function captureConfigKeys(input = "Ctrl+Shift+2") {
const keys = parseKeybindingShortcut(input.replace(/super/gi, "meta"));
if (
!keys ||
keys.modKey ||
!/^(?:[a-z0-9]|f(?:[1-9]|1[0-9]|2[0-4]))$/.test(keys.key) ||
!(keys.ctrlKey || keys.altKey || keys.metaKey)
)
throw new Error("Choose a letter, number, or function key with Ctrl, Alt, or Super.");
const modifiers = [
keys.ctrlKey && "Ctrl",
keys.altKey && "Alt",
keys.shiftKey && "Shift",
keys.metaKey && "Super",
].filter(Boolean);
return {
label: [...modifiers, keys.key.toUpperCase()].join("+"),
modifiers: modifiers.map((key) => String(key).toUpperCase()).join(" "),
key: keys.key.toUpperCase(),
mask:
(keys.shiftKey ? 1 : 0) |
(keys.ctrlKey ? 4 : 0) |
(keys.altKey ? 8 : 0) |
(keys.metaKey ? 64 : 0),
};
}
function sameKeys(left: string, right: string) {
try {
return captureConfigKeys(left).label === captureConfigKeys(right).label;
} catch {
return false;
}
}
export function captureConfigBinding(format: CaptureConfigFormat, appId: string, keys: string) {
if (!/^[A-Za-z0-9_.-]+$/.test(appId)) throw new Error("Invalid capture application ID.");
const chord = captureConfigKeys(keys);
if (format === "niri") return niriCaptureBinding(appId).replace("Ctrl+Shift+2", chord.label);
const action = `${appId}:capture-window`;
return format === "hyprland-lua"
? `hl.bind("${chord.label.toUpperCase().replaceAll("+", " + ")}", hl.dsp.global("${action}"))`
: `bind = ${chord.modifiers}, ${chord.key}, global, ${action}`;
}
function niriBinds(source: string) {
const blocks = readKdlNodes(source).filter((node) => node.name === "binds");
if (blocks.length > 1 || blocks.some((node) => node.close === undefined))
throw new Error("This Niri config has an unexpected binds section. Check it in Advanced.");
return blocks[0];
}
function niriCaptureNode(node: KdlNode, appId: string) {
const expected = readKdlNodes(captureConfigBinding("niri", appId, "Ctrl+Shift+2"))[0]!
.children[0]!;
const action = node.children[0];
return (
node.children.length === 1 &&
action?.children.length === 0 &&
JSON.stringify(action.header.map((token) => token.value)) ===
JSON.stringify(expected.header.map((token) => token.value))
);
}
function hyprlandBinds(source: string, lua: boolean) {
const bindings: { start: number; end: number; keys: string; action: string }[] = [];
let start = 0;
for (const line of source.split(/(?<=\n)/)) {
const legacy =
!lua &&
/^\s*bind[a-z]*\s*=\s*([^,]*),\s*([^,]*),\s*([^,]*),\s*(.*?)\s*(?:#.*)?$/.exec(
line.trimEnd(),
);
const modern =
lua &&
/^\s*hl\.bind\(\s*(["'])(.*?)\1\s*,\s*hl\.dsp\.global\(\s*(["'])(.*?)\3\s*\)\s*\)\s*;?\s*(?:--.*)?$/.exec(
line.trimEnd(),
);
if (legacy)
bindings.push({
start,
end: start + line.length,
keys: `${legacy[1]!.trim().replace(/\s+/g, "+")}+${legacy[2]!.trim()}`,
action: legacy[3]!.trim() === "global" ? legacy[4]!.trim() : "",
});
if (modern)
bindings.push({ start, end: start + line.length, keys: modern[2]!, action: modern[4]! });
start += line.length;
}
return bindings;
}
export function niriConfigIncludes(source: string) {
return readKdlNodes(source)
.filter((node) => node.name === "include")
.map((node) => {
const path = node.header.find(
(token, index) => index > 0 && token.quoted && node.header[index - 1]?.value !== "=",
)?.value;
if (!path) throw new Error("Couldn't resolve a Niri include. Choose the config in Advanced.");
const optional = node.header.some(
(token, index) =>
token.value === "optional" &&
node.header[index + 1]?.value === "=" &&
node.header[index + 2]?.value === "true",
);
return { path, optional };
});
}
export function niriConfigConflict(source: string, appId: string, keys: string) {
return (
niriBinds(source)?.children.some(
(node) => sameKeys(node.name, keys) && !niriCaptureNode(node, appId),
) ?? false
);
}
function removeNodes(source: string, ranges: { start: number; end: number }[]) {
let result = source;
for (const range of [...ranges].sort((a, b) => b.start - a.start)) {
const lineStart = source.lastIndexOf("\n", range.start - 1) + 1;
const nextLine = source.indexOf("\n", range.end);
const lineEnd = nextLine < 0 ? source.length : nextLine;
const wholeLine =
!source.slice(lineStart, range.start).trim() && !source.slice(range.end, lineEnd).trim();
result =
result.slice(0, wholeLine ? lineStart : range.start) +
result.slice(wholeLine ? Math.min(lineEnd + 1, source.length) : range.end);
}
return result;
}
export function editCaptureConfig(
source: string,
format: CaptureConfigFormat,
appId: string,
operation: "install" | "remove",
requestedKeys?: string,
) {
if (format === "hyprland-lua" && (/^\s*return\b/m.test(source) || /\[=*\[/.test(source)))
throw new Error(
"This Lua config needs a manual edit. Choose your bindings file or use manual setup in Advanced.",
);
const niri = format === "niri";
const existing = niri
? (niriBinds(source)?.children ?? [])
.filter((node) => niriCaptureNode(node, appId))
.map((node) => ({ ...node, keys: node.name }))
: hyprlandBinds(source, format === "hyprland-lua").filter(
(bind) => bind.action === `${appId}:capture-window`,
);
const keys = captureConfigKeys(requestedKeys ?? existing[0]?.keys).label;
if (operation === "install" && existing.length === 1 && sameKeys(existing[0]!.keys, keys))
return { after: source, shortcut: keys };
let after = removeNodes(source, existing);
if (operation === "remove") return { after, shortcut: keys };
const conflict = niri
? niriConfigConflict(after, appId, keys)
: hyprlandBinds(after, false).some((bind) => sameKeys(bind.keys, keys));
if (conflict) throw new Error(`${keys} is already used in this config. Choose another shortcut.`);
const newline = source.includes("\r\n") ? "\r\n" : "\n";
const binding = captureConfigBinding(format, appId, keys);
if (niri) {
const block = niriBinds(after);
if (block?.close !== undefined) {
const lineStart = after.lastIndexOf("\n", block.close - 1) + 1;
const indent = after.slice(lineStart, block.close);
if (!indent.trim())
after =
after.slice(0, lineStart) + indent + " " + binding + newline + after.slice(lineStart);
else
after =
after.slice(0, block.close) +
newline +
" " +
binding +
newline +
after.slice(block.close);
} else
after += `${after && !after.endsWith("\n") ? newline : ""}${newline}binds {${newline} ${binding}${newline}}${newline}`;
readKdlNodes(after);
} else {
// Do not append executable Lua inside a block/string, or after an early return.
// The normal Omarchy binding file is a sequence of top-level hl.bind calls.
after += `${after && !after.endsWith("\n") ? newline : ""}${newline}${binding}${newline}`;
}
return { after, shortcut: keys };
}