diff options
Diffstat (limited to 'desktop/modules/hyprland/Hypr.js')
| -rw-r--r-- | desktop/modules/hyprland/Hypr.js | 321 |
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"); +} |
