summaryrefslogtreecommitdiffstats
path: root/desktop/modules
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules')
-rw-r--r--desktop/modules/hyprland/BindsModule.qml148
-rw-r--r--desktop/modules/hyprland/BindsPage.qml334
-rw-r--r--desktop/modules/hyprland/Chip.qml42
-rw-r--r--desktop/modules/hyprland/Field.qml47
-rw-r--r--desktop/modules/hyprland/Hypr.js321
-rw-r--r--desktop/modules/hyprland/OptionsModule.qml137
-rw-r--r--desktop/modules/hyprland/OptionsPage.qml267
-rw-r--r--desktop/modules/hyprland/README.md56
-rw-r--r--desktop/modules/hyprland/test_hypr.js103
9 files changed, 1455 insertions, 0 deletions
diff --git a/desktop/modules/hyprland/BindsModule.qml b/desktop/modules/hyprland/BindsModule.qml
new file mode 100644
index 0000000..64dadef
--- /dev/null
+++ b/desktop/modules/hyprland/BindsModule.qml
@@ -0,0 +1,148 @@
+// 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.
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Hyprland
+import QtQuick
+import "../.."
+import "Hypr.js" as H
+
+// Every bind Hyprland has loaded, joined to the hl.bind call in the Lua
+// config that made it, so a row shows its action and file:line, and a combo
+// written as a plain string can be changed from the page. hyprctl reports
+// every Lua action as an opaque __lua number: the source is the only place
+// the action is readable.
+Module {
+ id: bm
+ name: "shortcuts"
+ label: "Keyboard shortcuts"
+ // nf-fa-keyboard_o, present in Inconsolata Nerd Font.
+ icon: ""
+ settings: true
+ group: "Hyprland"
+ keywords: ["keybind", "bind", "hotkey", "combo", "rebind", "keybindings.lua", "hyprland", "clash"]
+
+ readonly property string dir: (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/hypr"
+ // hyprland.lua and its sections, relative to dir.
+ property var files: []
+ property var live: []
+ // [{keys, desc, src, bind}], see Hypr.js.
+ property var rows: []
+ // file -> its text as last read or written. FileView.text() still
+ // returns the old text right after setText(), so a second rebind in the
+ // same file would undo the first.
+ property var texts: ({})
+
+ function refresh() {
+ lister.running = false;
+ lister.running = true;
+ binds.running = false;
+ binds.running = true;
+ }
+
+ function join() {
+ const fs = [];
+ for (const v of views.instances) fs.push({ path: v.modelData, text: bm.texts[v.modelData] ?? "" });
+ bm.rows = H.rows(bm.live, H.sources(fs));
+ bm.searchItems = bm.rows.map((r, i) => ({ text: r.keys.join(" + ") + " " + r.desc, key: "bind:" + i }));
+ }
+
+ // A config edit anywhere, by this page or an editor, reloads Hyprland.
+ property Connections reloads: Connections {
+ target: Hyprland
+ function onRawEvent(event) { if (event.name === "configreloaded") bm.refresh(); }
+ }
+
+ property Process lister: Process {
+ command: ["sh", "-c", 'cd "$1" && ls hyprland.lua sections/*.lua', "sh", bm.dir]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const l = this.text.split("\n").filter(x => x !== "");
+ if (JSON.stringify(l) !== JSON.stringify(bm.files)) bm.files = l;
+ }
+ }
+ }
+
+ property Process binds: Process {
+ command: ["hyprctl", "binds", "-j"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try { bm.live = JSON.parse(this.text); } catch (e) { bm.live = []; }
+ bm.join();
+ }
+ }
+ }
+
+ property Variants views: Variants {
+ model: bm.files
+ FileView {
+ required property string modelData
+ path: bm.dir + "/" + modelData
+ // Hyprland reloads on every change, so it must never read a
+ // half-written file.
+ atomicWrites: true
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ onLoaded: { bm.texts[modelData] = text(); bm.join(); }
+ }
+ }
+
+ // Changes row's combo to combo, calling done(error) with "" on success.
+ // Hyprland checks the combo first: an eval of a do-nothing bind fails on
+ // an unknown modifier or keysym and changes nothing. One that passes is
+ // registered live until the reload the write causes clears it.
+ property var pending: null
+ function rebind(row, combo, done) {
+ const c = combo.trim();
+ if (H.parseCombo(c) === null) {
+ done("Write it as modifiers and a key: SUPER + SHIFT + q. Modifiers are SUPER, CTRL, ALT, SHIFT.");
+ return;
+ }
+ bm.pending = { row: row, combo: H.formatCombo(c), done: done };
+ probe.command = ["hyprctl", "eval", 'hl.bind("' + H.quote(bm.pending.combo) + '", function() end)'];
+ probe.running = false;
+ probe.running = true;
+ }
+
+ property Process probe: Process {
+ stdout: StdioCollector { id: probeOut }
+ onExited: code => {
+ const p = bm.pending;
+ bm.pending = null;
+ if (!p) return;
+ if (code !== 0) {
+ p.done(probeOut.text.trim().replace(/^error: .*?:1: /, "") || "Hyprland refused it.");
+ return;
+ }
+ const v = bm.views.instances.find(x => x.modelData === p.row.src.file);
+ const t = v ? H.rebind(bm.texts[v.modelData] ?? "", p.row.src, p.combo) : null;
+ if (t === null) {
+ // Clears the probe's bind, which no write will.
+ Quickshell.execDetached(["hyprctl", "reload", "config-only"]);
+ p.done(p.row.src.file + " changed underneath, reloaded. Try again.");
+ return;
+ }
+ bm.texts[v.modelData] = t;
+ v.setText(t);
+ bm.join();
+ p.done("");
+ }
+ }
+
+ page: Component {
+ Page {
+ title: "Keyboard shortcuts"
+ BindsPage { width: parent.width; mod: bm }
+ }
+ }
+}
diff --git a/desktop/modules/hyprland/BindsPage.qml b/desktop/modules/hyprland/BindsPage.qml
new file mode 100644
index 0000000..eca71be
--- /dev/null
+++ b/desktop/modules/hyprland/BindsPage.qml
@@ -0,0 +1,334 @@
+// 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.
+
+import QtQuick
+import "../.."
+import "Hypr.js" as H
+
+// One row per live bind: the combo as keycaps, what it does (its
+// description, then the action as the Lua source writes it), and where it is
+// set. Clicking a row opens its combo for editing when the source writes it
+// as a plain string; one built at run time (a loop) says where to change it.
+// Source shows the files that hold binds, Clashes the combos bound twice.
+Column {
+ id: page
+
+ required property QtObject mod
+ property string tab: "list"
+ property string query: ""
+ // The mod.rows index being edited, -1 for none.
+ property int editing: -1
+ property string draft: ""
+ property string error: ""
+ property bool busy: false
+
+ spacing: 10
+
+ readonly property var shown: {
+ const q = page.query.trim().toLowerCase();
+ const out = [];
+ page.mod.rows.forEach((r, i) => {
+ const hay = r.keys.join(" + ") + " " + r.desc + " " + (r.src ? r.src.action + " " + r.src.file : "");
+ if (q === "" || hay.toLowerCase().includes(q)) out.push({ r: r, i: i });
+ });
+ return out;
+ }
+ readonly property var clashes: H.clashes(page.mod.rows)
+ .reduce((a, g) => a.concat(g.map(r => ({ r: r, i: page.mod.rows.indexOf(r) }))), [])
+
+ // Other binds already on the combo being typed, in the same submap.
+ readonly property var draftClash: {
+ const c = H.parseCombo(page.draft);
+ const me = page.mod.rows[page.editing];
+ if (!c || !me) return [];
+ return page.mod.rows.filter(r => r !== me && r.bind.submap === me.bind.submap
+ && r.bind.modmask === c.mask && r.bind.key.toLowerCase() === c.key.toLowerCase());
+ }
+
+ // The files holding binds, each line numbered. Reads mod.rows so it
+ // recomputes when a file is reloaded.
+ readonly property var sourceFiles: {
+ const used = [];
+ for (const r of page.mod.rows) if (r.src && !used.includes(r.src.file)) used.push(r.src.file);
+ return page.mod.files.filter(f => used.includes(f)).map(f => ({
+ file: f,
+ text: (page.mod.texts[f] ?? "").replace(/\n$/, "").split("\n").map((l, i) => String(i + 1).padStart(4) + " " + l).join("\n")
+ }));
+ }
+
+ function toggle(i) {
+ page.error = "";
+ if (page.editing === i) { page.editing = -1; return; }
+ page.editing = i;
+ const r = page.mod.rows[i];
+ page.draft = r.src && r.src.key !== null ? r.src.key : "";
+ }
+
+ function save() {
+ if (page.busy || page.editing < 0) return;
+ page.busy = true;
+ page.error = "";
+ page.mod.rebind(page.mod.rows[page.editing], page.draft, err => {
+ page.busy = false;
+ page.error = err;
+ if (err === "") page.editing = -1;
+ });
+ }
+
+ component Small: Text {
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ elide: Text.ElideRight
+ }
+
+ component Keycap: Rectangle {
+ property alias text: capLabel.text
+ width: capLabel.implicitWidth + 10
+ height: 22
+ radius: 4
+ color: Theme.surfaceAlt
+ Text {
+ id: capLabel
+ anchors.centerIn: parent
+ color: Theme.text
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4; bold: true }
+ }
+ }
+
+ component BindRow: Column {
+ id: br
+ required property var row
+ required property int idx
+ readonly property bool open: page.editing === br.idx
+ readonly property bool editable: br.row.src !== null && br.row.src.key !== null
+ objectName: "bind:" + br.idx
+ onOpenChanged: if (open && editable) comboField.focusIn()
+ width: page.width
+ spacing: 6
+
+ Item {
+ width: parent.width
+ height: 46
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 6
+ color: rowArea.containsMouse || br.open ? Qt.alpha(Theme.accent, 0.12) : "transparent"
+ }
+
+ Row {
+ x: 8
+ width: parent.width * 0.3 - 16
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 4
+ clip: true
+ Repeater {
+ model: br.row.keys
+ Row {
+ required property string modelData
+ required property int index
+ spacing: 4
+ Text {
+ visible: index > 0
+ anchors.verticalCenter: parent.verticalCenter
+ text: "+"
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Keycap { text: modelData }
+ }
+ }
+ }
+
+ Column {
+ x: parent.width * 0.3
+ width: parent.width * 0.5 - 12
+ anchors.verticalCenter: parent.verticalCenter
+ Text {
+ width: parent.width
+ text: br.row.desc !== "" ? br.row.desc : "No description"
+ color: br.row.desc !== "" ? Theme.text : Theme.subtext
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Text {
+ width: parent.width
+ visible: br.row.src !== null
+ text: br.row.src ? br.row.src.action : ""
+ color: Theme.blue
+ elide: Text.ElideRight
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ }
+
+ Small {
+ x: parent.width * 0.8
+ width: parent.width * 0.2 - 8
+ anchors.verticalCenter: parent.verticalCenter
+ text: br.row.src ? br.row.src.file.replace(/^sections\//, "") + ":" + br.row.src.line : "outside the config"
+ }
+
+ MouseArea {
+ id: rowArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: page.toggle(br.idx)
+ }
+ }
+
+ Column {
+ visible: br.open
+ x: 8
+ width: parent.width - 16
+ spacing: 6
+ bottomPadding: 8
+
+ Small {
+ width: parent.width
+ visible: !br.editable
+ wrapMode: Text.Wrap
+ text: br.row.src
+ ? "Built at run time, " + br.row.src.keyExpr + " in " + br.row.src.file + " line " + br.row.src.line + ". Change it there."
+ : "No hl.bind call in the config files made this one: a plugin or hyprctl did."
+ }
+
+ Item {
+ visible: br.editable
+ width: parent.width
+ height: 32
+ Field {
+ id: comboField
+ anchors { left: parent.left; right: saveBtn.left; rightMargin: 8 }
+ mono: true
+ text: page.draft
+ placeholder: "SUPER + SHIFT + q"
+ onEdited: t => page.draft = t
+ onAccepted: page.save()
+ }
+ Button {
+ id: saveBtn
+ anchors { right: cancelBtn.left; rightMargin: 6; verticalCenter: parent.verticalCenter }
+ text: page.busy ? "Checking…" : "Save"
+ enabled: !page.busy && page.draft.trim() !== "" && page.draft.trim() !== br.row.src?.key
+ onClicked: page.save()
+ }
+ Button {
+ id: cancelBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: "Cancel"
+ onClicked: page.toggle(br.idx)
+ }
+ }
+
+ Small {
+ width: parent.width
+ visible: br.editable && page.draftClash.length > 0
+ wrapMode: Text.Wrap
+ color: Theme.yellow
+ text: "Already bound: " + page.draftClash.map(r => r.desc || r.src?.action || "?").join(", ") + ". Saving keeps both."
+ }
+ Small {
+ width: parent.width
+ visible: br.editable && page.error !== ""
+ wrapMode: Text.Wrap
+ color: Theme.red
+ text: page.error
+ }
+ Small {
+ width: parent.width
+ visible: br.editable
+ wrapMode: Text.Wrap
+ text: "Hyprland checks the combo first. Saving rewrites line " + (br.row.src?.line ?? "") + " of "
+ + (br.row.src?.file ?? "") + ", and Hyprland reloads."
+ }
+ }
+ }
+
+ Small {
+ width: parent.width
+ text: page.mod.rows.length + " bindings, live from Hyprland."
+ }
+
+ Item {
+ width: parent.width
+ height: 32
+ Row {
+ id: tabs
+ spacing: 6
+ anchors.verticalCenter: parent.verticalCenter
+ Chip { text: "List"; selected: page.tab === "list"; onClicked: page.tab = "list" }
+ Chip { text: "Source"; selected: page.tab === "source"; onClicked: page.tab = "source" }
+ Chip {
+ text: "Clashes · " + H.clashes(page.mod.rows).length
+ selected: page.tab === "clashes"
+ onClicked: page.tab = "clashes"
+ }
+ }
+ Field {
+ visible: page.tab === "list"
+ anchors { left: tabs.right; leftMargin: 12; right: parent.right }
+ text: page.query
+ placeholder: "Search keys, actions and files"
+ onEdited: t => page.query = t
+ }
+ }
+
+ Item {
+ visible: page.tab !== "source"
+ width: parent.width
+ height: 20
+ Small { x: 8; text: "Keys" }
+ Small { x: parent.width * 0.3; text: "Does" }
+ Small { x: parent.width * 0.8; text: "Set at" }
+ }
+
+ Repeater {
+ model: page.tab === "list" ? page.shown : page.tab === "clashes" ? page.clashes : []
+ BindRow {
+ required property var modelData
+ row: modelData.r
+ idx: modelData.i
+ }
+ }
+
+ Small {
+ width: parent.width
+ visible: page.tab === "clashes" && page.clashes.length === 0
+ text: "No combo is bound twice."
+ }
+
+ Repeater {
+ model: page.tab === "source" ? page.sourceFiles : []
+ Column {
+ required property var modelData
+ width: page.width
+ spacing: 4
+ Text {
+ text: modelData.file
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ }
+ TextEdit {
+ width: parent.width
+ readOnly: true
+ selectByMouse: true
+ textFormat: TextEdit.PlainText
+ wrapMode: TextEdit.NoWrap
+ clip: true
+ text: modelData.text
+ color: Theme.text
+ selectionColor: Qt.alpha(Theme.accent, 0.4)
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ }
+ }
+}
diff --git a/desktop/modules/hyprland/Chip.qml b/desktop/modules/hyprland/Chip.qml
new file mode 100644
index 0000000..d2fa29f
--- /dev/null
+++ b/desktop/modules/hyprland/Chip.qml
@@ -0,0 +1,42 @@
+// 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.
+
+import QtQuick
+import "../.."
+
+// A selectable pill, as the autostart page draws them: the tabs and filters.
+Rectangle {
+ id: chip
+ property string text: ""
+ property bool selected: false
+ signal clicked
+ implicitWidth: chipLabel.implicitWidth + 24
+ implicitHeight: 30
+ radius: 15
+ color: selected ? Qt.alpha(Theme.accent, 0.85)
+ : chipArea.containsMouse ? Qt.alpha(Theme.accent, 0.22) : Qt.alpha(Theme.surface, 0.8)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.15)
+ Text {
+ id: chipLabel
+ anchors.centerIn: parent
+ text: chip.text
+ color: chip.selected ? Theme.base : Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ MouseArea {
+ id: chipArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: chip.clicked()
+ }
+}
diff --git a/desktop/modules/hyprland/Field.qml b/desktop/modules/hyprland/Field.qml
new file mode 100644
index 0000000..8f029a1
--- /dev/null
+++ b/desktop/modules/hyprland/Field.qml
@@ -0,0 +1,47 @@
+// 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.
+
+import QtQuick
+import "../.."
+
+// A one-line text field, as the autostart page draws them. mono for code.
+Rectangle {
+ id: field
+ property string text: ""
+ property string placeholder: ""
+ property bool mono: false
+ signal edited(string text)
+ signal accepted
+ function focusIn() { ti.forceActiveFocus(); }
+ height: 32
+ radius: 8
+ color: Theme.surface
+ TextInput {
+ id: ti
+ activeFocusOnTab: true
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: field.mono ? Theme.iconFamily : Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ text: field.text
+ onTextEdited: field.edited(text)
+ onAccepted: field.accepted()
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: parent.text === ""
+ text: field.placeholder
+ color: Theme.subtext
+ font: parent.font
+ }
+ }
+}
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");
+}
diff --git a/desktop/modules/hyprland/OptionsModule.qml b/desktop/modules/hyprland/OptionsModule.qml
new file mode 100644
index 0000000..45f62b0
--- /dev/null
+++ b/desktop/modules/hyprland/OptionsModule.qml
@@ -0,0 +1,137 @@
+// 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.
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Hyprland
+import QtQuick
+import "../.."
+import "Hypr.js" as H
+
+// Every Hyprland option: its description, default and range from hyprctl
+// descriptions, its value from one batched getoption (descriptions' own
+// "current" field lags the real value). A change is written as one line in
+// sections/keystone.lua, which hyprland.lua requires last, so it wins over
+// the hand-written sections without editing them. Each line is checked by
+// running it through hyprctl eval first, which refuses an unknown key or a
+// value of the wrong shape and changes nothing.
+Module {
+ id: om
+ name: "hyprland"
+ label: "Options"
+ // nf-fa-gears, present in Inconsolata Nerd Font.
+ icon: ""
+ settings: true
+ group: "Hyprland"
+ keywords: ["option", "config", "gaps", "border", "decoration", "animation", "input", "misc", "keystone.lua"]
+
+ readonly property string file: (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config")
+ + "/hypr/sections/keystone.lua"
+ readonly property string header: "-- Hyprland options changed from the keystone settings window, Hyprland >\n"
+ + "-- Options: one line per option, rewritten by that page. hyprland.lua requires\n"
+ + "-- this last, so a line here wins over the sections above it.\n"
+
+ property var descs: []
+ // name -> getoption object.
+ property var values: ({})
+ // The names keystone.lua sets.
+ property var overridden: []
+ // keystone.lua as last read or written. FileView.text() still returns
+ // the old text right after setText(), so a second edit made from it
+ // would drop the first.
+ property string text: ""
+ function write(t) {
+ om.text = t;
+ om.overridden = H.overridden(t);
+ om.fv.setText(t);
+ }
+
+ function refresh() {
+ descProc.running = false;
+ descProc.running = true;
+ }
+
+ property Connections reloads: Connections {
+ target: Hyprland
+ function onRawEvent(event) { if (event.name === "configreloaded") om.refresh(); }
+ }
+
+ property Process descProc: Process {
+ command: ["hyprctl", "descriptions", "-j"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try { om.descs = JSON.parse(this.text); } catch (e) { return; }
+ om.searchItems = om.descs.map(d => ({ text: d.name, key: "opt:" + d.name }));
+ om.readValues();
+ }
+ }
+ }
+
+ function readValues() {
+ valueProc.command = ["hyprctl", "-j", "--batch", om.descs.map(d => "getoption " + d.name).join(";")];
+ valueProc.running = false;
+ valueProc.running = true;
+ }
+
+ property Process valueProc: Process {
+ stdout: StdioCollector { onStreamFinished: om.values = H.parseBatch(this.text) }
+ }
+
+ property FileView fv: FileView {
+ path: om.file
+ atomicWrites: true
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ onLoaded: { om.text = text(); om.overridden = H.overridden(om.text); }
+ onLoadFailed: { om.text = ""; om.overridden = []; }
+ }
+
+ // Sets name to the Lua expression expr, calling done(error) with "" on
+ // success. The eval applies it at once; the write keeps it.
+ property var pending: null
+ function set(name, expr, done) {
+ if (expr.trim() === "" || expr.includes("\n")) { done("Write the value as one Lua expression."); return; }
+ om.pending = { name: name, expr: expr, done: done };
+ probe.command = ["hyprctl", "eval", H.line(name, expr)];
+ probe.running = false;
+ probe.running = true;
+ }
+
+ property Process probe: Process {
+ stdout: StdioCollector { id: probeOut }
+ onExited: code => {
+ const p = om.pending;
+ om.pending = null;
+ if (!p) return;
+ if (code !== 0) {
+ p.done(probeOut.text.trim().split("\n")[0].replace(/^error: .*?:1: /, "") || "Hyprland refused it.");
+ return;
+ }
+ om.write(H.override(om.text || om.header, p.name, p.expr));
+ om.readValues();
+ p.done("");
+ }
+ }
+
+ // Drops name's line; the reload the write causes restores the value the
+ // other sections, or Hyprland's default, give it.
+ function reset(name) {
+ om.write(H.drop(om.text, name));
+ }
+
+ page: Component {
+ Page {
+ title: "Options"
+ OptionsPage { width: parent.width; mod: om }
+ }
+ }
+}
diff --git a/desktop/modules/hyprland/OptionsPage.qml b/desktop/modules/hyprland/OptionsPage.qml
new file mode 100644
index 0000000..b9d806e
--- /dev/null
+++ b/desktop/modules/hyprland/OptionsPage.qml
@@ -0,0 +1,267 @@
+// 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.
+
+import QtQuick
+import "../.."
+import "Hypr.js" as H
+
+// One row per option: its name, its value (in the accent when it differs
+// from Hyprland's default, colours as swatches), and what it does. Filters
+// for everything, what the config sets, and what differs from the default.
+// Clicking a row opens it: the whole description, the default and range,
+// and an editor shaped by the type: true/false and an enum's names as
+// choices, anything else as a Lua expression started from the value.
+Column {
+ id: page
+
+ required property QtObject mod
+ property string filter: "all"
+ property string query: ""
+ property string editing: ""
+ property string draft: ""
+ property string error: ""
+ property bool busy: false
+
+ spacing: 10
+
+ readonly property var all: page.mod.descs.map(d => {
+ const o = page.mod.values[d.name];
+ return { d: d, o: o, set: !!(o && o.set), changed: H.changed(d, o), over: page.mod.overridden.includes(d.name) };
+ })
+ readonly property int setCount: page.all.filter(r => r.set).length
+ readonly property int changedCount: page.all.filter(r => r.changed).length
+ readonly property var shown: {
+ const q = page.query.trim().toLowerCase();
+ return page.all.filter(r => (page.filter === "all" || (page.filter === "set" ? r.set : r.changed))
+ && (q === "" || r.d.name.toLowerCase().includes(q) || r.d.description.toLowerCase().includes(q)));
+ }
+
+ function toggle(r) {
+ page.error = "";
+ if (page.editing === r.d.name) { page.editing = ""; return; }
+ page.editing = r.d.name;
+ page.draft = r.o ? H.lua(r.o) : "";
+ }
+
+ function apply(name, expr) {
+ if (page.busy) return;
+ page.busy = true;
+ page.error = "";
+ page.mod.set(name, expr, err => {
+ page.busy = false;
+ page.error = err;
+ if (err === "") page.draft = expr;
+ });
+ }
+
+ component Small: Text {
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ elide: Text.ElideRight
+ }
+
+ component OptionRow: Column {
+ id: or
+ required property var row
+ readonly property bool open: page.editing === or.row.d.name
+ readonly property string kind: H.kind(or.row.o)
+ readonly property var choices: or.kind === "bool"
+ ? [{ name: "true", v: true }, { name: "false", v: false }]
+ : or.kind === "int" ? H.choices(or.row.d) : []
+ objectName: "opt:" + or.row.d.name
+ width: page.width
+ spacing: 6
+
+ Item {
+ width: parent.width
+ height: 30
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 6
+ color: rowArea.containsMouse || or.open ? Qt.alpha(Theme.accent, 0.12) : "transparent"
+ }
+
+ Text {
+ x: 8
+ width: parent.width * 0.36 - 16
+ anchors.verticalCenter: parent.verticalCenter
+ elide: Text.ElideRight
+ textFormat: Text.StyledText
+ // The section faded, the option itself bright.
+ readonly property int cut: or.row.d.name.lastIndexOf(":") + 1
+ text: "<font color='" + Theme.subtext + "'>" + or.row.d.name.slice(0, cut) + "</font>" + or.row.d.name.slice(cut)
+ color: Theme.text
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 3 }
+ }
+
+ Row {
+ x: parent.width * 0.36
+ width: parent.width * 0.26 - 12
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 4
+ clip: true
+ Repeater {
+ model: H.colors(or.row.o)
+ Rectangle {
+ required property string modelData
+ anchors.verticalCenter: parent.verticalCenter
+ width: 10; height: 10; radius: 2
+ color: modelData
+ }
+ }
+ Text {
+ text: H.show(or.row.d, or.row.o)
+ color: or.row.changed ? Theme.accent : Theme.subtext
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 3; bold: or.row.changed }
+ }
+ }
+
+ Small {
+ x: parent.width * 0.62
+ width: parent.width * 0.38 - 8
+ anchors.verticalCenter: parent.verticalCenter
+ text: (or.row.over ? "● " : "") + or.row.d.description
+ }
+
+ MouseArea {
+ id: rowArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: page.toggle(or.row)
+ }
+ }
+
+ Column {
+ visible: or.open
+ x: 8
+ width: parent.width - 16
+ spacing: 6
+ bottomPadding: 8
+
+ Text {
+ width: parent.width
+ wrapMode: Text.Wrap
+ text: or.row.d.description
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Small {
+ width: parent.width
+ wrapMode: Text.Wrap
+ text: "Default " + H.show(or.row.d, { [or.kind || "str"]: or.row.d.default })
+ + (or.row.d.min !== undefined && or.row.d.min !== null ? ", from " + or.row.d.min + " to " + or.row.d.max : "")
+ + (or.row.over ? ". Set by keystone.lua."
+ : or.row.set ? ". Set in your config: a change here overrides it from keystone.lua." : ".")
+ }
+
+ // true/false, or an enum's names.
+ Flow {
+ visible: or.choices.length > 0
+ width: parent.width
+ spacing: 6
+ Repeater {
+ model: or.choices
+ Chip {
+ required property var modelData
+ text: modelData.name
+ selected: or.row.o && or.row.o[or.kind] === modelData.v
+ onClicked: if (!selected) page.apply(or.row.d.name, String(modelData.v))
+ }
+ }
+ }
+
+ Item {
+ visible: or.choices.length === 0 && or.kind !== ""
+ width: parent.width
+ height: 32
+ Field {
+ anchors { left: parent.left; right: applyBtn.left; rightMargin: 8 }
+ mono: true
+ text: page.draft
+ onEdited: t => page.draft = t
+ onAccepted: page.apply(or.row.d.name, page.draft)
+ }
+ Button {
+ id: applyBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: page.busy ? "Checking…" : "Apply"
+ enabled: !page.busy && page.draft.trim() !== ""
+ onClicked: page.apply(or.row.d.name, page.draft)
+ }
+ }
+
+ Small {
+ width: parent.width
+ visible: page.error !== ""
+ wrapMode: Text.Wrap
+ color: Theme.red
+ text: page.error
+ }
+
+ Button {
+ visible: or.row.over
+ text: "Reset, drop the keystone.lua line"
+ onClicked: page.mod.reset(or.row.d.name)
+ }
+ }
+ }
+
+ Small {
+ width: parent.width
+ wrapMode: Text.Wrap
+ text: page.all.length + " options, live from Hyprland. Your config sets " + page.setCount + ", and "
+ + page.changedCount + " differ from the default. Click one to see more, or change it."
+ }
+
+ Item {
+ width: parent.width
+ height: 32
+ Field {
+ id: search
+ width: parent.width * 0.3
+ text: page.query
+ placeholder: "Search options"
+ onEdited: t => page.query = t
+ }
+ Row {
+ anchors { left: search.right; leftMargin: 12; verticalCenter: parent.verticalCenter }
+ spacing: 6
+ Chip { text: "Everything · " + page.all.length; selected: page.filter === "all"; onClicked: page.filter = "all" }
+ Chip { text: "Set in your config · " + page.setCount; selected: page.filter === "set"; onClicked: page.filter = "set" }
+ Chip { text: "Changed from default · " + page.changedCount; selected: page.filter === "changed"; onClicked: page.filter = "changed" }
+ }
+ }
+
+ Item {
+ width: parent.width
+ height: 20
+ Small { x: 8; text: "Option" }
+ Small { x: parent.width * 0.36; text: "Value" }
+ Small { x: parent.width * 0.62; text: "What it does" }
+ }
+
+ Repeater {
+ model: page.shown
+ OptionRow {
+ required property var modelData
+ row: modelData
+ }
+ }
+
+ Small {
+ width: parent.width
+ wrapMode: Text.Wrap
+ text: "Values in the accent differ from Hyprland's default; faded ones are the default. Colours show as swatches. "
+ + "● marks an option set from this page, in sections/keystone.lua."
+ }
+}
diff --git a/desktop/modules/hyprland/README.md b/desktop/modules/hyprland/README.md
new file mode 100644
index 0000000..278a3b4
--- /dev/null
+++ b/desktop/modules/hyprland/README.md
@@ -0,0 +1,56 @@
+# hyprland
+
+Two settings pages, under Hyprland in the settings window.
+
+## Keyboard shortcuts
+
+Every bind Hyprland has loaded, from `hyprctl binds -j`, joined to the
+`hl.bind` call in `~/.config/hypr` (`hyprland.lua` and `sections/*.lua`)
+that made it: by description, then by combo, then, for a bind built in a
+loop, by the longest literal head of its description. hyprctl reports every
+Lua action as an opaque `__lua` number, so the action shown is the source's.
+
+List shows the combo, the description and action, and the file:line. Source
+shows the files holding binds, line-numbered. Clashes lists combos bound
+more than once in the same submap.
+
+Clicking a row opens its combo for editing when the source writes it as a
+plain string, or one built from file-level string locals (`mainMod .. " +
+w"`). Save asks Hyprland first, by `hyprctl eval` of a do-nothing bind on the
+new combo, which fails on an unknown modifier or keysym. Then the first
+argument of that call is replaced with the combo as a string literal, and
+Hyprland's autoreload picks up the file. An edit is refused if the call is
+no longer where it was parsed. A bind built at run time says where to change
+it instead.
+
+## Options
+
+All of Hyprland's options from `hyprctl descriptions -j`, with values from
+one batched `getoption`, filtered by everything, set in the config, or
+changed from the default. Values differing from the default are in the
+accent, gradients show their colours.
+
+A change is one line in `sections/keystone.lua`, which `hyprland.lua`
+requires last, so it wins over the hand-written sections without editing
+them:
+
+ hl.config({ general = { border_size = 4 } }) -- keystone: general:border_size
+
+The line is run through `hyprctl eval` first, which refuses an unknown key or
+a value of the wrong shape and changes nothing; one that passes applies at
+once and is then written. Booleans and enums are picked from their choices,
+everything else is typed as a Lua expression, started from the current value
+(`{ top = 8, right = 8, bottom = 8, left = 8 }` for gaps, `{ colors = {
+"rgba(...)" }, angle = 45 }` for a gradient). Reset drops the line, and the
+reload brings back what the other sections, or the default, give it.
+
+Not handled: an option a hand-written section sets is shadowed, not edited
+there; and the two options `getoption` cannot read show as unreadable and
+cannot be edited.
+
+## Check
+
+ node desktop/modules/hyprland/test_hypr.js
+
+It also checks every live bind finds its source, when `~/.config/hypr` is
+there.
diff --git a/desktop/modules/hyprland/test_hypr.js b/desktop/modules/hyprland/test_hypr.js
new file mode 100644
index 0000000..8398aa7
--- /dev/null
+++ b/desktop/modules/hyprland/test_hypr.js
@@ -0,0 +1,103 @@
+// 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.
+
+// Run: node desktop/modules/hyprland/test_hypr.js
+// Hypr.js is a QML library, so its .pragma line is stripped before node
+// evaluates it.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/Hypr.js", "utf8").replace(/^\.pragma library$/m, "");
+const H = new Function(src + "\nreturn { parseCombo, formatCombo, sources, rows, clashes, rebind, "
+ + "parseBatch, show, changed, colors, lua, line, overridden, override, drop };")();
+
+const file = [
+ 'local mainMod = "SUPER"',
+ '-- hl.bind("SUPER + x", hl.dsp.exec_cmd("off"), { description = "Off: Commented" })',
+ 'hl.bind(mainMod .. " + w", hl.dsp.exec_cmd(P.browser), { description = "Apps: Browser" })',
+ 'hl.bind("ALT + l", hl.dsp.exec_cmd([[sh -c \'a, b) hl.bind(\']]), { description = "Apps: QR" })',
+ 'for _, g in ipairs(groups) do',
+ ' hl.bind(g.mod .. " + " .. key,',
+ ' hl.dsp.focus({ workspace = g.wss[i] }),',
+ ' { description = "Workspaces: Go to " .. g.wss[i] })',
+ 'end',
+ 'hl.bind("CTRL + SUPER + z", zoom, { description = "Zoom: Toggle" })',
+].join("\n");
+
+const s = H.sources([{ path: "k.lua", text: file }]);
+assert.strictEqual(s.length, 4, "the commented bind and the one in a long string are skipped");
+assert.deepStrictEqual([s[0].line, s[0].key, s[0].action, s[0].desc, s[0].exact],
+ [3, "SUPER + w", "exec_cmd(P.browser)", "Apps: Browser", true]);
+assert.strictEqual(s[1].key, "ALT + l");
+assert.deepStrictEqual([s[2].line, s[2].key, s[2].desc, s[2].exact], [6, null, "Workspaces: Go to ", false]);
+assert.strictEqual(s[3].action, "zoom");
+
+const bind = (modmask, key, description) => ({ modmask, key, keycode: 0, description, submap: "", release: false, mouse: false });
+const r = H.rows([bind(64, "w", "Apps: Browser"), bind(4, "F3", "Workspaces: Go to 3"),
+ bind(68, "z", ""), bind(64, "W", "Other"), bind(1, "x", "")], s);
+assert.deepStrictEqual(r[0].keys, ["SUPER", "w"]);
+assert.strictEqual(r[0].src.line, 3);
+assert.strictEqual(r[1].src.line, 6, "a loop bind finds its call by description head");
+assert.strictEqual(r[2].src.line, 10, "an undescribed bind finds its call by combo");
+assert.strictEqual(r[4].src, null);
+assert.deepStrictEqual(H.clashes(r).map(g => g.map(x => x.desc)), [["Apps: Browser", "Other"]]);
+
+assert.deepStrictEqual(H.parseCombo("super + Shift+q"), { mask: 65, key: "q" });
+assert.strictEqual(H.parseCombo("SUPRE + q"), null);
+assert.strictEqual(H.parseCombo("SUPER +"), null);
+assert.strictEqual(H.parseCombo("SUPER + SHIFT"), null);
+assert.strictEqual(H.formatCombo("super+shift +q"), "SUPER + SHIFT + q");
+
+const re = H.rebind(file, s[0], "SUPER + SHIFT + b");
+assert.ok(re.includes('hl.bind("SUPER + SHIFT + b", hl.dsp.exec_cmd(P.browser)'));
+assert.strictEqual(re.split("\n").length, file.split("\n").length);
+assert.strictEqual(H.rebind("x" + file, s[0], "SUPER + b"), null, "a moved call is refused");
+
+const batch = '{"option": "general:border_size", "int": 2, "set": true }\n\n\ninvalid type (internal error)\n\n\n'
+ + '{"option": "general:col.active_border", "gradient": "ffc3e88d ff89ddff 45deg", "set": true }\n\n\n'
+ + '{"option": "general:gaps_in", "css": "8 8 8 8", "set": true }\n\n\n'
+ + '{"option": "general:resize_corner", "int": 2, "set": false }\n\n\n'
+ + '{"option": "decoration:rounding_power", "float": 4.000000, "set": true }\n';
+const o = H.parseBatch(batch);
+assert.deepStrictEqual(Object.keys(o).length, 5);
+const corner = { default: 0, map: [{ top_right: 2 }, { disable: 0 }] };
+assert.strictEqual(H.show(corner, o["general:resize_corner"]), "2 · top_right");
+assert.strictEqual(H.show({}, o["decoration:rounding_power"]), "4");
+assert.strictEqual(H.changed({ default: 4 }, o["decoration:rounding_power"]), false);
+assert.strictEqual(H.changed({ default: "5 5 5 5" }, o["general:gaps_in"]), true);
+assert.deepStrictEqual(H.colors(o["general:col.active_border"]), ["#ffc3e88d", "#ff89ddff"]);
+assert.strictEqual(H.lua(o["general:col.active_border"]), '{ colors = { "rgba(c3e88dff)", "rgba(89ddffff)" }, angle = 45 }');
+assert.strictEqual(H.lua(o["general:gaps_in"]), "{ top = 8, right = 8, bottom = 8, left = 8 }");
+assert.strictEqual(H.lua({ str: 'a"b' }), '"a\\"b"');
+
+assert.strictEqual(H.line("general:col.active_border", "x"),
+ "hl.config({ general = { col = { active_border = x } } }) -- keystone: general:col.active_border");
+let t = "-- header\n";
+t = H.override(t, "general:border_size", "4");
+t = H.override(t, "general:gaps_in", "2");
+t = H.override(t, "general:border_size", "3");
+assert.deepStrictEqual(H.overridden(t), ["general:border_size", "general:gaps_in"]);
+assert.ok(t.includes("border_size = 3 }"));
+assert.deepStrictEqual(H.overridden(H.drop(t, "general:border_size")), ["general:gaps_in"]);
+
+// The live config, when it is there: every bind Hyprland reports should
+// find its source.
+const dir = process.env.HOME + "/.config/hypr";
+if (fs.existsSync(dir + "/hyprland.lua")) {
+ const files = ["hyprland.lua"].concat(fs.readdirSync(dir + "/sections").filter(f => f.endsWith(".lua")).map(f => "sections/" + f))
+ .map(p => ({ path: p, text: fs.readFileSync(dir + "/" + p, "utf8") }));
+ const live = JSON.parse(require("child_process").execFileSync("hyprctl", ["binds", "-j"], { encoding: "utf8" }));
+ const lost = H.rows(live, H.sources(files)).filter(x => !x.src);
+ assert.deepStrictEqual(lost.map(x => x.keys.join("+")), [], "live binds without a source");
+}
+
+console.log("ok");