summaryrefslogtreecommitdiffstats
path: root/desktop/modules/hyprland/BindsModule.qml
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/hyprland/BindsModule.qml')
-rw-r--r--desktop/modules/hyprland/BindsModule.qml148
1 files changed, 148 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 }
+ }
+ }
+}