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": "`", "slash": "/", "backslash": "\\", "period": ".", "comma": ",", "equal": "=", "minus": "-", "semicolon": ";", "apostrophe": "'", "Return": "Enter", "Escape": "Esc", "space": "Space", "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 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()); } // A modifier-only bind has no key at all -- the window switcher commits // on Super RELEASE. Appending an empty string left a dangling "SUPER + " // that matched neither the chord hypr/keybinds.lua binds nor the one an // override would be keyed by, so that bind could never be rebound and // never found its category. const key = String(bind.key ?? ""); if (key !== "") parts.push(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 ?? ""); if (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. // What hypr/keybinds.lua says this bind is for, when it has said anything. // Written at config load to a manifest keyed by the chord actually bound, // because Hyprland reports a Lua bind's dispatcher as `__lua` with a // bytecode offset and nothing can be attached to a bind that survives into // `hyprctl binds`. property var categoryManifest: ({}) FileView { path: (Quickshell.env("XDG_STATE_HOME") || `${Quickshell.env("HOME")}/.local/state`) + "/panama/keybind-categories.json" printErrors: false watchChanges: true onFileChanged: this.reload() onLoaded: { try { const parsed = JSON.parse(this.text()); root.categoryManifest = (parsed && typeof parsed === "object") ? parsed : ({}); } catch (error) { root.categoryManifest = ({}); } } // No manifest is the normal state on a machine whose compositor config // has not been reloaded since this was added. The substring derivation // below still produces groups, so the keymap page and the cheatsheet // work; they are just grouped by guesswork until the next reload. onLoadFailed: root.categoryManifest = ({}) } function groupFor(description: string, bind: var): string { // The authored category wins. Keyed by the raw chord, which is what // the manifest records and what Hyprland reports. const authored = root.categoryManifest[root.luaChord(bind)]; if (typeof authored === "string" && authored !== "") return authored; 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. // The authored categories come first, in the order somebody learning this // desktop would want them: what you do to a window, then to a workspace, // then how you start things, then the shell's own surfaces. The names // after them are the ones the substring derivation produces, kept so a // machine whose compositor has not reloaded since the manifest was added // still sorts into a sensible order rather than alphabetically. readonly property var groupOrder: ["Windows", "Workspaces", "Applications", "Shell", "Session", "Media & hardware", "Other", "Focus", "Move & split", "Size", "Window state", "Applications & shell", "Media & hardware keys"] // The action already bound to a chord, or "" if it is free. Compared on the // form keybinds.lua writes rather than the prettified display form, because // that is what a rebind is keyed by -- "SUPER + Q" and "Super+Q" are the // same binding and must not read as two. function boundTo(luaChord: string, exceptLuaChord: string): string { const wanted = String(luaChord).replace(/\s+/g, "").toLowerCase(); const skip = String(exceptLuaChord).replace(/\s+/g, "").toLowerCase(); for (const bind of root.binds) { const candidate = String(bind.luaChord).replace(/\s+/g, "").toLowerCase(); if (candidate === wanted && candidate !== skip) return String(bind.description); } return ""; } 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] })); } }