summaryrefslogtreecommitdiffstats
path: root/desktop/modules/hyprland/BindsModule.qml
blob: 64dadef00f60b33450520218068571e2a73a7847 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
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 }
        }
    }
}