pragma Singleton // The keymap, read from the compositor rather than restated. // // The Shortcuts page used to hold a hand-typed array of nineteen entries while // keybinds.lua produced a hundred and thirteen. It could not show the other // ninety-four, and it drifted the moment a bind was edited. `hyprctl binds -j` // is the only description of the keymap that cannot be wrong, so this reads // that and every bind carries its own human label (see the `description` // argument in hypr/keybinds.lua). // // Refreshed on demand, not polled: binds only change when the config is // reloaded, and nothing in this shell should wake up to re-read something that // has not moved. import Quickshell import Quickshell.Io import QtQuick Singleton { id: root // [{ chord, description, group, mouse, repeating, locked }], ordered as // Hyprland reports them, which follows the order they appear in the config. property var binds: [] property bool loaded: false property string lastError: "" readonly property bool busy: query.running // Hyprland's modmask bits. SUPER is the Panama modifier. readonly property var modifierBits: [ { bit: 64, name: "Super" }, { bit: 4, name: "Ctrl" }, { bit: 8, name: "Alt" }, { bit: 1, name: "Shift" } ] // Keysyms whose raw names would be noise in a shortcuts list. readonly property var keyNames: ({ "mouse_up": "Scroll up", "mouse_down": "Scroll down", "mouse:272": "Left click", "mouse:273": "Right click", "mouse:274": "Middle click", "bracketleft": "[", "bracketright": "]", "grave": "`", "Print": "Print Screen", "left": "←", "right": "→", "up": "↑", "down": "↓" }) Process { id: query command: ["hyprctl", "-j", "binds"] stdout: StdioCollector { onStreamFinished: root.parse(this.text) } onExited: (exitCode, exitStatus) => { if (exitCode !== 0) root.lastError = "Could not read the keymap from Hyprland."; } } Component.onCompleted: root.refresh() function refresh(): void { if (query.running) return; root.lastError = ""; query.running = true; } function parse(text: string): void { try { const raw = JSON.parse(text); const out = []; for (const bind of raw) { const description = String(bind.description ?? "").trim(); // A bind with no description cannot be presented usefully -- // the dispatcher is "__lua" and the argument is a bytecode // offset. Showing the chord alone would be worse than omitting // it, and tests/quickshell/keybinds-contract.sh fails the build // if any exist, so this should never be reached in practice. if (description === "") continue; out.push({ chord: root.formatChord(bind), description: description, group: root.groupFor(description, bind), mouse: bind.mouse === true, repeating: bind.repeat === true, locked: bind.locked === true }); } root.binds = out; root.loaded = true; root.lastError = ""; } catch (error) { root.lastError = "The keymap could not be read."; } } function formatChord(bind: var): string { const parts = []; for (const modifier of root.modifierBits) { if ((bind.modmask & modifier.bit) !== 0) parts.push(modifier.name); } const key = String(bind.key ?? ""); parts.push(root.keyNames[key] ?? (key.length === 1 ? key.toUpperCase() : key)); return parts.join(" + "); } // Grouping is by what the shortcut does, taken from its own description, // so adding a bind puts it in the right section without touching this file. function groupFor(description: string, bind: var): string { const text = description.toLowerCase(); if (bind.key && String(bind.key).indexOf("XF86") === 0) return "Media & hardware keys"; if (text.indexOf("workspace") >= 0) return "Workspaces"; if (text.indexOf("window") >= 0 || text.indexOf("focus") >= 0 || text.indexOf("swap") >= 0 || text.indexOf("split") >= 0 || text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0 || text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0 || text.indexOf("shrink") >= 0 || text.indexOf("grow") >= 0 || text.indexOf("float") >= 0 || text.indexOf("fullscreen") >= 0 || text.indexOf("close") >= 0 || text.indexOf("scratchpad") >= 0) return "Windows"; if (text.indexOf("volume") >= 0 || text.indexOf("mute") >= 0 || text.indexOf("track") >= 0 || text.indexOf("play") >= 0 || text.indexOf("brightness") >= 0) return "Media & hardware keys"; return "Applications & shell"; } // Section order for the page. Anything a future bind invents lands at the // end rather than being dropped. readonly property var groupOrder: ["Windows", "Workspaces", "Applications & shell", "Media & hardware keys"] function grouped(): var { const buckets = {}; for (const bind of root.binds) { buckets[bind.group] = buckets[bind.group] ?? []; buckets[bind.group].push(bind); } const names = Object.keys(buckets).sort((a, b) => { const ia = root.groupOrder.indexOf(a); const ib = root.groupOrder.indexOf(b); return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib); }); return names.map(name => ({ name: name, binds: buckets[name] })); } }