summaryrefslogtreecommitdiffstats
path: root/desktop/modules/hyprland/Hypr.js
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/hyprland/Hypr.js')
-rw-r--r--desktop/modules/hyprland/Hypr.js321
1 files changed, 321 insertions, 0 deletions
diff --git a/desktop/modules/hyprland/Hypr.js b/desktop/modules/hyprland/Hypr.js
new file mode 100644
index 0000000..3d3de3f
--- /dev/null
+++ b/desktop/modules/hyprland/Hypr.js
@@ -0,0 +1,321 @@
+.pragma library
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// 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");
+}