195 lines · 7.4 KB · TypeScript
RawDownload
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 };}