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 import qs.config 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."; } } // ── Rebinding ─────────────────────────────────────────────────────────── // Overrides map a SHIPPED chord to a replacement. hypr/keybinds.lua reads // them and substitutes only the chord -- the action is always the Lua value // written in that file -- so an override can move a shortcut but can never // make one do something else. // // Applying needs `hyprctl reload` rather than a live `hl.bind`: Hyprland // reports Lua-defined binds with dispatcher "__lua" and a bytecode offset, // so the action cannot be reconstructed from the outside to re-bind it. // Reload re-runs the config, which re-reads the settings file. readonly property var overrides: { const stored = DesktopPreferences.get("keybindOverrides"); return (stored && typeof stored === "object") ? stored : ({}); } property bool reloading: false // The chord a bind ships with, given the chord it currently answers to. // Displayed binds come from the compositor and so already reflect any // override; the override map is what tells us where they started. function shippedChordFor(currentChord: string): string { for (const shipped in root.overrides) { if (root.overrides[shipped] === currentChord) return shipped; } return currentChord; } function isOverridden(currentChord: string): bool { return root.shippedChordFor(currentChord) !== currentChord; } // Refuses a chord already answering to something else, so rebinding cannot // quietly shadow an existing shortcut. function conflictFor(chord: string, exceptCurrent: string): string { for (const bind of root.binds) { if (bind.luaChord === chord && bind.luaChord !== exceptCurrent) return bind.description; } return ""; } // A moved shortcut vacates its shipped chord, so another override may use // it legitimately. Refuse to reset the first shortcut until that occupant // moves away; otherwise Hyprland would receive two binds on one chord. function overrideOccupantFor(chord: string, exceptShipped: string): string { for (const shipped in root.overrides) { if (shipped !== exceptShipped && root.overrides[shipped] === chord) return shipped; } return ""; } function rebind(currentChord: string, newChord: string): bool { if (newChord === "" || newChord === currentChord) return false; const conflict = root.conflictFor(newChord, currentChord); if (conflict !== "") { root.lastError = `${newChord} is already ${conflict}.`; return false; } const shipped = root.shippedChordFor(currentChord); const next = Object.assign({}, root.overrides); if (newChord === shipped) delete next[shipped]; else next[shipped] = newChord; if (!DesktopPreferences.set("keybindOverrides", next)) { root.lastError = "That shortcut could not be saved."; return false; } root.applyReload(); return true; } function resetBind(currentChord: string): bool { const shipped = root.shippedChordFor(currentChord); if (shipped === currentChord) return true; const occupant = root.overrideOccupantFor(shipped, shipped); if (occupant !== "") { root.lastError = `${shipped} is used by another rebound shortcut. Reset that shortcut first.`; return false; } const next = Object.assign({}, root.overrides); delete next[shipped]; if (!DesktopPreferences.set("keybindOverrides", next)) { root.lastError = "That shortcut could not be reset."; return false; } root.applyReload(); return true; } function resetAll(): void { if (Object.keys(root.overrides).length === 0) return; DesktopPreferences.set("keybindOverrides", ({})); root.applyReload(); } Process { id: reloadRun command: ["hyprctl", "reload"] onExited: (exitCode, exitStatus) => { root.reloading = false; if (exitCode !== 0) { root.lastError = "The compositor did not reload."; return; } root.lastError = ""; // The settings file is written on a timer, so re-read the keymap // once the reload has had a moment to pick it up. settle.restart(); } } Timer { id: settle interval: 350 onTriggered: root.refresh() } function applyReload(): void { if (reloadRun.running) return; root.reloading = true; // Give DesktopPreferences' coalescing write a moment to land first. reloadDelay.restart(); } Timer { id: reloadDelay interval: 120 onTriggered: reloadRun.running = true } 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), // The same chord in the form hypr/keybinds.lua writes, which // is what an override is keyed by. The display form // prettifies modifiers and arrow keys and so cannot be used // for that. luaChord: root.luaChord(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."; } } // "SUPER + SHIFT + K" -- uppercase modifiers in the order keybinds.lua // writes them, then the raw keysym rather than its display name. function luaChord(bind: var): string { const parts = []; for (const modifier of root.modifierBits) { if ((bind.modmask & modifier.bit) !== 0) parts.push(modifier.name.toUpperCase()); } parts.push(String(bind.key ?? "")); return parts.join(" + "); } 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. // Which section a bind belongs to. // // "Windows" used to catch focus, movement, splitting, resizing and window // state alike, which put 43 of the 93 binds under one heading -- a section // that long is a list, not a grouping. The window verbs are separated here // by what you are actually trying to do. // // Order matters: "Next window splits down" is about splitting rather than // focus, and "Focus session" is a Panama feature rather than window focus, // so both are settled before the general checks below them. function groupFor(description: string, bind: var): string { const text = description.toLowerCase(); if (bind.key && String(bind.key).indexOf("XF86") === 0) return "Media & hardware keys"; // Quiet mode and Caffeine bound to a workspace, not window focus. if (text.indexOf("focus session") >= 0) return "Applications & shell"; if (text.indexOf("workspace") >= 0) return "Workspaces"; if (text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0 || text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0 || text.indexOf("shrink") >= 0 || text.indexOf("expand") >= 0 || text.indexOf("grow") >= 0 || text.indexOf("resize") >= 0) return "Size"; if (text.indexOf("split") >= 0 || text.indexOf("swap") >= 0 || text.indexOf("move window") >= 0) return "Move & split"; if (text.indexOf("close") >= 0 || text.indexOf("fullscreen") >= 0 || text.indexOf("float") >= 0 || text.indexOf("pin ") >= 0 || text.indexOf("scratchpad") >= 0 || text.indexOf("minimize") >= 0) return "Window state"; if (text.indexOf("focus") >= 0 || text.indexOf("next window") >= 0 || text.indexOf("previous window") >= 0 || text.indexOf("last window") >= 0 || text.indexOf("window switch") >= 0) return "Focus"; 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: ["Focus", "Move & split", "Size", "Window state", "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] })); } }