1import { parseKeybindingShortcut } from "@t3tools/shared/keybindings";
2import { readKdlNodes, type KdlNode } from "./captureConfigKdl.ts";
3import { niriCaptureBinding } from "./linuxCaptureSession.ts";
4
5export type CaptureConfigFormat = "niri" | "hyprland" | "hyprland-lua";
6
7export function captureConfigKeys(input = "Ctrl+Shift+2") {
8 const keys = parseKeybindingShortcut(input.replace(/super/gi, "meta"));
9 if (
10 !keys ||
11 keys.modKey ||
12 !/^(?:[a-z0-9]|f(?:[1-9]|1[0-9]|2[0-4]))$/.test(keys.key) ||
13 !(keys.ctrlKey || keys.altKey || keys.metaKey)
14 )
15 throw new Error("Choose a letter, number, or function key with Ctrl, Alt, or Super.");
16 const modifiers = [
17 keys.ctrlKey && "Ctrl",
18 keys.altKey && "Alt",
19 keys.shiftKey && "Shift",
20 keys.metaKey && "Super",
21 ].filter(Boolean);
22 return {
23 label: [...modifiers, keys.key.toUpperCase()].join("+"),
24 modifiers: modifiers.map((key) => String(key).toUpperCase()).join(" "),
25 key: keys.key.toUpperCase(),
26 mask:
27 (keys.shiftKey ? 1 : 0) |
28 (keys.ctrlKey ? 4 : 0) |
29 (keys.altKey ? 8 : 0) |
30 (keys.metaKey ? 64 : 0),
31 };
32}
33
34function sameKeys(left: string, right: string) {
35 try {
36 return captureConfigKeys(left).label === captureConfigKeys(right).label;
37 } catch {
38 return false;
39 }
40}
41
42export function captureConfigBinding(format: CaptureConfigFormat, appId: string, keys: string) {
43 if (!/^[A-Za-z0-9_.-]+$/.test(appId)) throw new Error("Invalid capture application ID.");
44 const chord = captureConfigKeys(keys);
45 if (format === "niri") return niriCaptureBinding(appId).replace("Ctrl+Shift+2", chord.label);
46 const action = `${appId}:capture-window`;
47 return format === "hyprland-lua"
48 ? `hl.bind("${chord.label.toUpperCase().replaceAll("+", " + ")}", hl.dsp.global("${action}"))`
49 : `bind = ${chord.modifiers}, ${chord.key}, global, ${action}`;
50}
51
52function niriBinds(source: string) {
53 const blocks = readKdlNodes(source).filter((node) => node.name === "binds");
54 if (blocks.length > 1 || blocks.some((node) => node.close === undefined))
55 throw new Error("This Niri config has an unexpected binds section. Check it in Advanced.");
56 return blocks[0];
57}
58
59function niriCaptureNode(node: KdlNode, appId: string) {
60 const expected = readKdlNodes(captureConfigBinding("niri", appId, "Ctrl+Shift+2"))[0]!
61 .children[0]!;
62 const action = node.children[0];
63 return (
64 node.children.length === 1 &&
65 action?.children.length === 0 &&
66 JSON.stringify(action.header.map((token) => token.value)) ===
67 JSON.stringify(expected.header.map((token) => token.value))
68 );
69}
70
71function hyprlandBinds(source: string, lua: boolean) {
72 const bindings: { start: number; end: number; keys: string; action: string }[] = [];
73 let start = 0;
74 for (const line of source.split(/(?<=\n)/)) {
75 const legacy =
76 !lua &&
77 /^\s*bind[a-z]*\s*=\s*([^,]*),\s*([^,]*),\s*([^,]*),\s*(.*?)\s*(?:#.*)?$/.exec(
78 line.trimEnd(),
79 );
80 const modern =
81 lua &&
82 /^\s*hl\.bind\(\s*(["'])(.*?)\1\s*,\s*hl\.dsp\.global\(\s*(["'])(.*?)\3\s*\)\s*\)\s*;?\s*(?:--.*)?$/.exec(
83 line.trimEnd(),
84 );
85 if (legacy)
86 bindings.push({
87 start,
88 end: start + line.length,
89 keys: `${legacy[1]!.trim().replace(/\s+/g, "+")}+${legacy[2]!.trim()}`,
90 action: legacy[3]!.trim() === "global" ? legacy[4]!.trim() : "",
91 });
92 if (modern)
93 bindings.push({ start, end: start + line.length, keys: modern[2]!, action: modern[4]! });
94 start += line.length;
95 }
96 return bindings;
97}
98
99export function niriConfigIncludes(source: string) {
100 return readKdlNodes(source)
101 .filter((node) => node.name === "include")
102 .map((node) => {
103 const path = node.header.find(
104 (token, index) => index > 0 && token.quoted && node.header[index - 1]?.value !== "=",
105 )?.value;
106 if (!path) throw new Error("Couldn't resolve a Niri include. Choose the config in Advanced.");
107 const optional = node.header.some(
108 (token, index) =>
109 token.value === "optional" &&
110 node.header[index + 1]?.value === "=" &&
111 node.header[index + 2]?.value === "true",
112 );
113 return { path, optional };
114 });
115}
116
117export function niriConfigConflict(source: string, appId: string, keys: string) {
118 return (
119 niriBinds(source)?.children.some(
120 (node) => sameKeys(node.name, keys) && !niriCaptureNode(node, appId),
121 ) ?? false
122 );
123}
124
125function removeNodes(source: string, ranges: { start: number; end: number }[]) {
126 let result = source;
127 for (const range of [...ranges].sort((a, b) => b.start - a.start)) {
128 const lineStart = source.lastIndexOf("\n", range.start - 1) + 1;
129 const nextLine = source.indexOf("\n", range.end);
130 const lineEnd = nextLine < 0 ? source.length : nextLine;
131 const wholeLine =
132 !source.slice(lineStart, range.start).trim() && !source.slice(range.end, lineEnd).trim();
133 result =
134 result.slice(0, wholeLine ? lineStart : range.start) +
135 result.slice(wholeLine ? Math.min(lineEnd + 1, source.length) : range.end);
136 }
137 return result;
138}
139
140export function editCaptureConfig(
141 source: string,
142 format: CaptureConfigFormat,
143 appId: string,
144 operation: "install" | "remove",
145 requestedKeys?: string,
146) {
147 if (format === "hyprland-lua" && (/^\s*return\b/m.test(source) || /\[=*\[/.test(source)))
148 throw new Error(
149 "This Lua config needs a manual edit. Choose your bindings file or use manual setup in Advanced.",
150 );
151 const niri = format === "niri";
152 const existing = niri
153 ? (niriBinds(source)?.children ?? [])
154 .filter((node) => niriCaptureNode(node, appId))
155 .map((node) => ({ ...node, keys: node.name }))
156 : hyprlandBinds(source, format === "hyprland-lua").filter(
157 (bind) => bind.action === `${appId}:capture-window`,
158 );
159 const keys = captureConfigKeys(requestedKeys ?? existing[0]?.keys).label;
160 if (operation === "install" && existing.length === 1 && sameKeys(existing[0]!.keys, keys))
161 return { after: source, shortcut: keys };
162 let after = removeNodes(source, existing);
163 if (operation === "remove") return { after, shortcut: keys };
164 const conflict = niri
165 ? niriConfigConflict(after, appId, keys)
166 : hyprlandBinds(after, false).some((bind) => sameKeys(bind.keys, keys));
167 if (conflict) throw new Error(`${keys} is already used in this config. Choose another shortcut.`);
168 const newline = source.includes("\r\n") ? "\r\n" : "\n";
169 const binding = captureConfigBinding(format, appId, keys);
170 if (niri) {
171 const block = niriBinds(after);
172 if (block?.close !== undefined) {
173 const lineStart = after.lastIndexOf("\n", block.close - 1) + 1;
174 const indent = after.slice(lineStart, block.close);
175 if (!indent.trim())
176 after =
177 after.slice(0, lineStart) + indent + " " + binding + newline + after.slice(lineStart);
178 else
179 after =
180 after.slice(0, block.close) +
181 newline +
182 " " +
183 binding +
184 newline +
185 after.slice(block.close);
186 } else
187 after += `${after && !after.endsWith("\n") ? newline : ""}${newline}binds {${newline} ${binding}${newline}}${newline}`;
188 readKdlNodes(after);
189 } else {
190 // Do not append executable Lua inside a block/string, or after an early return.
191 // The normal Omarchy binding file is a sequence of top-level hl.bind calls.
192 after += `${after && !after.endsWith("\n") ? newline : ""}${newline}${binding}${newline}`;
193 }
194 return { after, shortcut: keys };
195}