diff options
Diffstat (limited to 'desktop/modules/hyprland/BindsModule.qml')
| -rw-r--r-- | desktop/modules/hyprland/BindsModule.qml | 148 |
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 } + } + } +} |
