.pragma library // Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // Pure helpers for the Hyprland pages, tested by test_hypr.js under node. // No String.matchAll, Object.fromEntries or flatMap: the shell's engine has // none of them (AGENTS.md). // ---- Binds ---- // Modmask bits, in the order the config writes them. const MODS = [[64, "SUPER"], [4, "CTRL"], [8, "ALT"], [1, "SHIFT"], [2, "CAPS"], [16, "MOD2"], [32, "MOD3"], [128, "MOD5"]]; const BITS = { SUPER: 64, WIN: 64, LOGO: 64, MOD4: 64, CTRL: 4, CONTROL: 4, ALT: 8, MOD1: 8, SHIFT: 1, CAPS: 2, MOD2: 16, MOD3: 32, MOD5: 128 }; function mods(mask) { return MODS.filter(m => mask & m[0]).map(m => m[1]); } // A bind from hyprctl binds -j as its combo's parts: ["SUPER", "SHIFT", "q"]. function keys(b) { return mods(b.modmask).concat([b.key !== "" ? b.key : "code:" + b.keycode]); } // A typed combo as {mask, key}, or null when a modifier is unknown or the // key is missing. Whether the key itself exists is Hyprland's to say. function parseCombo(s) { const parts = s.split("+").map(p => p.trim()); const key = parts.pop(); if (!key || /\s/.test(key) || BITS[key.toUpperCase()]) return null; let mask = 0; for (const p of parts) { const b = BITS[p.toUpperCase()]; if (!b) return null; mask |= b; } return { mask: mask, key: key }; } // A typed combo the way the config writes it: "super+shift +q" is // "SUPER + SHIFT + q". function formatCombo(s) { const parts = s.split("+").map(p => p.trim()); const key = parts.pop(); return parts.map(p => p.toUpperCase()).concat([key]).join(" + "); } function sameCombo(a, b) { return a.mask === b.mask && a.key.toLowerCase() === b.key.toLowerCase(); } // The offset just past a comment or string starting at i, or i itself. // Long brackets count, with their = levels: the config quotes shell // commands as [[...]]. function skip(t, i) { let m; if (t.startsWith("--", i)) { if ((m = /^--\[(=*)\[/.exec(t.substr(i, 40)))) { const e = t.indexOf("]" + m[1] + "]", i); return e < 0 ? t.length : e + m[1].length + 2; } const e = t.indexOf("\n", i); return e < 0 ? t.length : e; } if ((m = /^\[(=*)\[/.exec(t.substr(i, 40)))) { const e = t.indexOf("]" + m[1] + "]", i); return e < 0 ? t.length : e + m[1].length + 2; } if (t[i] === '"' || t[i] === "'") { let j = i + 1; while (j < t.length && t[j] !== t[i] && t[j] !== "\n") j += t[j] === "\\" ? 2 : 1; return j + 1; } return i; } function seg(t, a, b) { const raw = t.slice(a, b); const lead = raw.length - raw.replace(/^\s+/, "").length; const s = raw.trim(); return { text: s, start: a + lead, end: a + lead + s.length }; } // Every hl.bind( call outside comments and strings, as {line, args}, each // argument {text, start, end} with offsets into t. function calls(t) { const out = []; let line = 1, counted = 0; for (let i = 0; i < t.length;) { const j = skip(t, i); if (j !== i) { i = j; continue; } if (!t.startsWith("hl.bind(", i) || /[\w.]/.test(t[i - 1] || "")) { i++; continue; } const args = []; let depth = 0, k = i + 8, from = k; while (k < t.length) { const s = skip(t, k); if (s !== k) { k = s; continue; } const c = t[k]; if ("({[".includes(c)) depth++; else if (")}]".includes(c)) { if (depth === 0) break; depth--; } else if (c === "," && depth === 0) { args.push(seg(t, from, k)); from = k + 1; } k++; } args.push(seg(t, from, k)); for (; counted < i; counted++) if (t[counted] === "\n") line++; out.push({ line: line, args: args }); i = k + 1; } return out; } function unquote(s) { return s.replace(/\\(.)/g, "$1"); } function quote(s) { return s.replace(/[\\"]/g, c => "\\" + c).replace(/\n/g, "\\n"); } // File-level `local name = "string"` assignments. function localsOf(t) { const out = Object.create(null); const re = /^local\s+([A-Za-z_]\w*)\s*=\s*"((?:[^"\\]|\\.)*)"\s*$/gm; let m; while ((m = re.exec(t)) !== null) out[m[1]] = unquote(m[2]); return out; } // A string concatenation of literals and string locals as {s, full}: s is // the value, or its head up to the first part only known at run time, and // full says which. function strValue(expr, locals) { const re = /^\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([A-Za-z_]\w*)(?![\w.\[(]))\s*(\.\.|$)/; let s = "", rest = expr; while (rest !== "") { const m = re.exec(rest); if (!m) return { s: s, full: false }; if (m[3] !== undefined) { if (!(m[3] in locals)) return { s: s, full: false }; s += locals[m[3]]; } else s += unquote(m[1] !== undefined ? m[1] : m[2]); rest = rest.slice(m[0].length); } return { s: s, full: true }; } const DESC = /description\s*=\s*((?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[\w.\[\]]+)(?:\s*\.\.\s*(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[\w.\[\]]+))*)/; // Each hl.bind call in files ([{path, text}]) as {file, line, start, end, // keyExpr, key, action, desc, exact}. key is the combo when the first // argument is a plain string, or one built from string locals, so the page // can rewrite it; null for one built at run time (a loop). desc is the // description, or its literal head when built at run time, and exact says // which. start and end are the first argument's offsets in the file. function sources(files) { const out = []; for (const f of files) { const locals = localsOf(f.text); for (const c of calls(f.text)) { if (c.args.length < 2) continue; const k = strValue(c.args[0].text, locals); const m = c.args.length > 2 ? DESC.exec(c.args[2].text) : null; const d = m ? strValue(m[1], locals) : { s: "", full: false }; out.push({ file: f.path, line: c.line, start: c.args[0].start, end: c.args[0].end, keyExpr: c.args[0].text, key: k.full ? k.s : null, action: c.args[1].text.replace(/^hl\.dsp\./, ""), desc: d.s, exact: d.full && m !== null }); } } return out; } // The source of a live bind: the call with the same description, else the // one with the same combo, else the run-time description whose literal head // is longest. null for a bind made outside the files (a plugin, hyprctl). function sourceOf(b, srcs) { const d = b.description || ""; const c = { mask: b.modmask, key: b.key }; let best = null; for (const s of srcs) if (s.exact && d !== "" && s.desc === d) return s; for (const s of srcs) if (s.key !== null) { const p = parseCombo(s.key); if (p && sameCombo(p, c)) return s; } for (const s of srcs) if (!s.exact && s.desc !== "" && d.startsWith(s.desc) && (!best || s.desc.length > best.desc.length)) best = s; return best; } // The live binds as page rows: {keys, desc, src, bind}. hyprctl reports a // `code:49` bind with an empty key and keycode 0, so its combo is the // source's. function rows(binds, srcs) { return binds.map(b => { const src = sourceOf(b, srcs); const k = b.key === "" && src && src.key ? src.key.split("+").map(p => p.trim()) : keys(b); return { keys: k, desc: b.description || "", src: src, bind: b }; }); } // Rows grouped by what triggers them, where more than one shares a trigger: // same submap, combo, and press or release. function clashes(rs) { const by = Object.create(null), order = []; for (const r of rs) { const b = r.bind; const k = b.submap + "|" + b.modmask + "|" + b.key.toLowerCase() + "|" + b.keycode + "|" + b.release + "|" + b.mouse; if (!by[k]) { by[k] = []; order.push(k); } by[k].push(r); } return order.map(k => by[k]).filter(g => g.length > 1); } // The file text with src's combo rewritten to combo, or null when the text // there is no longer the one parsed (the file changed underneath). function rebind(text, src, combo) { if (text.slice(src.start, src.end) !== src.keyExpr) return null; return text.slice(0, src.start) + '"' + quote(combo) + '"' + text.slice(src.end); } // ---- Options ---- // hyprctl -j --batch "getoption a;getoption b" into name -> object. Blocks // are blank-line separated; one Hyprland cannot read is plain text, skipped. function parseBatch(out) { const r = Object.create(null); for (const block of out.split(/\n\s*\n/)) { const b = block.trim(); if (!b.startsWith("{")) continue; try { const o = JSON.parse(b); r[o.option] = o; } catch (e) {} } return r; } const KINDS = ["bool", "int", "float", "str", "css", "gradient", "vec2"]; function kind(o) { return o ? KINDS.find(k => k in o) || "" : ""; } // An enum's map, [{"name": n}, ...], as [{name, v}]. function choices(d) { const out = []; for (const e of d.map || []) for (const n in e) out.push({ name: n, v: e[n] }); return out; } // The value as the page shows it: an enum's number carries its name. function show(d, o) { const k = kind(o); if (k === "") return "unreadable"; const v = o[k]; if (k === "float") return String(Number(v)); if (k === "vec2") return v.join(" "); if (k === "int" && d.map) { const c = choices(d).find(e => e.v === v); if (c) return v + " ยท " + c.name; } return String(v); } // Whether the value differs from Hyprland's default. function changed(d, o) { const k = kind(o); if (k === "") return false; const v = o[k]; if (k === "int" || k === "float") return Math.abs(Number(v) - Number(d.default)) > 1e-6; if (k === "vec2") return JSON.stringify(v) !== JSON.stringify(d.default); return String(v) !== String(d.default); } // A gradient's colours, AARRGGBB, as QML colour strings. function colors(o) { if (kind(o) !== "gradient") return []; return o.gradient.split(/\s+/).filter(t => /^[0-9a-f]{8}$/i.test(t)).map(t => "#" + t); } // The current value as a Lua expression, to start an edit from. function lua(o) { const k = kind(o); const v = o[k]; if (k === "bool" || k === "int") return String(v); if (k === "float") return String(Number(v)); if (k === "str") return '"' + quote(v) + '"'; if (k === "vec2") return "{ " + v.join(", ") + " }"; if (k === "css") { const n = v.split(/\s+/); return "{ top = " + n[0] + ", right = " + n[1] + ", bottom = " + n[2] + ", left = " + n[3] + " }"; } if (k === "gradient") { const t = v.split(/\s+/); const cs = t.filter(x => /^[0-9a-f]{8}$/i.test(x)).map(x => '"rgba(' + x.slice(2) + x.slice(0, 2) + ')"'); const a = t.find(x => /deg$/.test(x)); return "{ colors = { " + cs.join(", ") + " }, angle = " + (a ? parseFloat(a) : 0) + " }"; } return ""; } const MARK = " -- keystone: "; // The override line setting name to the Lua expression expr: // general:col.active_border nests as { general = { col = { active_border = expr } } }. function line(name, expr) { const path = name.split(/[:.]/); let t = expr.trim(); for (let i = path.length - 1; i >= 0; i--) t = "{ " + path[i] + " = " + t + " }"; return "hl.config(" + t + ")" + MARK + name; } // The names the overrides file sets. function overridden(text) { return text.split("\n").filter(l => l.includes(MARK)).map(l => l.slice(l.indexOf(MARK) + MARK.length).trim()); } // The overrides file with name's line replaced, or added at the end. function override(text, name, expr) { const ls = text.replace(/\n+$/, "").split("\n"); const i = ls.findIndex(l => l.endsWith(MARK + name)); if (i >= 0) ls[i] = line(name, expr); else ls.push(line(name, expr)); return ls.join("\n") + "\n"; } // The overrides file without name's line. function drop(text, name) { return text.split("\n").filter(l => !l.endsWith(MARK + name)).join("\n"); }