| 1 | import { parseKeybindingShortcut } from "@t3tools/shared/keybindings"; |
| 2 | import { readKdlNodes, type KdlNode } from "./captureConfigKdl.ts"; |
| 3 | import { niriCaptureBinding } from "./linuxCaptureSession.ts"; |
| 4 | |
| 5 | export type CaptureConfigFormat = "niri" | "hyprland" | "hyprland-lua"; |
| 6 | |
| 7 | export 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 | |
| 34 | function sameKeys(left: string, right: string) { |
| 35 | try { |
| 36 | return captureConfigKeys(left).label === captureConfigKeys(right).label; |
| 37 | } catch { |
| 38 | return false; |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | export 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 | |
| 52 | function 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 | |
| 59 | function 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 | |
| 71 | function 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 | |
| 99 | export 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 | |
| 117 | export 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 | |
| 125 | function 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 | |
| 140 | export 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 | } |