summaryrefslogtreecommitdiffstats
path: root/desktop/modules/hyprland/Hypr.js
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-29 09:31:12 +0100
committerDanilo M. <danix@danix.xyz>2026-10-29 09:31:12 +0100
commit7171f99539c86d79420bb6a098c76c17fcb1bbf6 (patch)
tree61ca125f45b7d3ae918a268ac1ebe7c2c0c23a53 /desktop/modules/hyprland/Hypr.js
parent81d21f2e335d2fd59f8fc43d4788c2426c8a2fe1 (diff)
downloadquickshell-7171f99539c86d79420bb6a098c76c17fcb1bbf6.tar.gz
quickshell-7171f99539c86d79420bb6a098c76c17fcb1bbf6.zip
hyprland: keyboard shortcuts and options pages in the settings window
Keyboard shortcuts lists every bind hyprctl reports, joined to the hl.bind call in ~/.config/hypr that made it: hyprctl gives every Lua action as an opaque __lua number, so the action and file:line only exist in the source. The join is by description, then combo, then the longest literal head of a description built in a loop. List, Source and Clashes tabs. A combo the source writes as a plain string (or from file-level string locals) can be changed: hyprctl eval of a do-nothing bind checks it first, refusing an unknown modifier or keysym, then the call's first argument is rewritten and Hyprland autoreloads. Options lists all 353 options from hyprctl descriptions, with values from one batched getoption, since descriptions' own "current" field is stale. Filters for everything, set in the config, and changed from the default. A change is one line in hypr-theme's sections/keystone.lua, required last, and is run through hyprctl eval first, which refuses an unknown key or a wrongly shaped value without changing anything. Both modules keep their own copy of the file text: FileView.text() returns the old text right after setText(), and a second quick edit built from it dropped the first. The AGENTS.md notes record that and the hyprctl quirks found on the way. Check: node desktop/modules/hyprland/test_hypr.js Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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");
+}