diff options
Diffstat (limited to 'desktop')
| -rw-r--r-- | desktop/README.md | 1 | ||||
| -rw-r--r-- | desktop/modules/hyprland/BindsModule.qml | 148 | ||||
| -rw-r--r-- | desktop/modules/hyprland/BindsPage.qml | 334 | ||||
| -rw-r--r-- | desktop/modules/hyprland/Chip.qml | 42 | ||||
| -rw-r--r-- | desktop/modules/hyprland/Field.qml | 47 | ||||
| -rw-r--r-- | desktop/modules/hyprland/Hypr.js | 321 | ||||
| -rw-r--r-- | desktop/modules/hyprland/OptionsModule.qml | 137 | ||||
| -rw-r--r-- | desktop/modules/hyprland/OptionsPage.qml | 267 | ||||
| -rw-r--r-- | desktop/modules/hyprland/README.md | 56 | ||||
| -rw-r--r-- | desktop/modules/hyprland/test_hypr.js | 103 | ||||
| -rw-r--r-- | desktop/shell.qml | 3 |
11 files changed, 1459 insertions, 0 deletions
diff --git a/desktop/README.md b/desktop/README.md index 555095e..7716aed 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -94,6 +94,7 @@ and flashes it, so a page needs no highlight code of its own. modules/clipboard/ cliphist history: search, copy, delete, clear all modules/git/ the global git config, in the settings window modules/autostart/ Hyprland's autostart.lua, in the settings window + modules/hyprland/ Hyprland's keyboard shortcuts and options, in the settings window modules/assistant/ desktop-assistant's config, tools, and Ask presets modules/qtmaildir/ qtmaildir.conf: general, compose, accounts, tags and keys modules/sbbuilder/ sb-builder's GUI config and the build VM helper's config 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"); diff --git a/desktop/shell.qml b/desktop/shell.qml index ca78d00..451dde8 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -28,6 +28,7 @@ import "modules/bluetooth" import "modules/clipboard" import "modules/companion" import "modules/git" +import "modules/hyprland" import "modules/kdeconnect" import "modules/mail" import "modules/network" @@ -94,6 +95,8 @@ ShellRoot { NotificationsModule {}, AutostartModule {}, ShellsModule {}, + BindsModule {}, + OptionsModule {}, ObsScenesModule {}, AssistantModule { id: asstSettings }, PresetsModule {}, |
