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 { // Custom chords are outside the override map's domain: a custom bind // has no shipped chord to have been moved from, and answering one from // the map would let a shipped bind's override claim a user's own // shortcut. See the customBinds section below. if (root.isCustomChord(currentChord)) return currentChord; 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 { // A custom bind is edited in place in `customBinds`; it never enters // the override map. Routing here rather than refusing keeps the two // mechanisms from ever meeting even if a caller does not check first. if (root.isCustomChord(currentChord)) return root.rebindCustomBind(currentChord, newChord); 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(); } // ── Named actions ─────────────────────────────────────────────────────── // A custom shortcut and an assigned four-finger gesture both store DATA, // never a command: an enum `kind`, a validated `target`, and the `label` // to show. hypr/actions.lua turns that data into something the compositor // runs, through whitelist tables only -- so nothing a person can type into // settings.json becomes executable, and an unknown kind or an invalid // target means the bind is silently not emitted rather than guessed at. // // This is the same vocabulary on the QML side. `describeAction()` is the // single authority here on whether an entry is one the Lua would emit; // every page asks it rather than re-deriving the rules. readonly property var actionKinds: ["app", "shell", "window"] // An application id is an ARGUMENT to the launch-or-focus path, never text // interpolated into a command, and this is the shape that path accepts. readonly property var safeTargetPattern: /^[A-Za-z0-9@._-]{1,128}$/ // Shell verbs, each one a surface `shell.qml` already exposes over IPC (or, // for the last two, a command hypr/keybinds.lua already binds). The target // strings are keys of the whitelist table in hypr/actions.lua -- adding one // here without adding it there means the entry simply never emits. readonly property var shellActions: [ { target: "dnd-toggle", label: "Toggle Do Not Disturb" }, { target: "screenshot", label: "Screenshot or record" }, { target: "screenshot-screen", label: "Screenshot the whole screen" }, { target: "screenshot-window", label: "Screenshot the focused window" }, { target: "screen-intelligence", label: "Read text on screen" }, { target: "color-picker", label: "Pick a color" }, { target: "clipboard", label: "Clipboard history" }, { target: "launcher", label: "Open the launcher" }, { target: "overview", label: "Open Mission Control" }, { target: "quick-settings", label: "Open Quick Settings" }, { target: "notifications", label: "Open notifications" }, { target: "activity", label: "Open Activity" }, { target: "cheatsheet", label: "Keyboard shortcuts" }, { target: "settings", label: "Open Settings" }, { target: "focus-session", label: "Focus session" }, { target: "caffeine", label: "Keep the screen awake" }, { target: "night-light", label: "Toggle Night Light" }, { target: "power-menu", label: "Power menu" }, { target: "lock", label: "Lock the screen" } ] // Compositor verbs. The three window-state ones, then the ten workspaces // the keymap already reaches -- generated rather than typed so the range // and hypr/actions.lua's 1..10 check can never disagree. // // `workspace:N` goes TO that workspace; it does not carry the focused // window there. Said in the label because "workspace 4" on its own reads // like either one. readonly property var windowActions: { const out = [ { target: "float-toggle", label: "Toggle floating" }, { target: "fullscreen", label: "Fullscreen" }, { target: "pin", label: "Pin on every workspace" } ]; for (let n = 1; n <= 10; n++) out.push({ target: "workspace:" + n, label: "Go to workspace " + n }); return out; } function shellActionLabel(target: string): string { const found = root.shellActions.find(action => action.target === target); return found ? found.label : ""; } function windowActionLabel(target: string): string { const found = root.windowActions.find(action => action.target === target); return found ? found.label : ""; } // What an entry does, in a sentence -- or "" when it is not an action the // Lua would emit, which is what every caller checks rather than validating // kind and target for itself. function describeAction(entry: var): string { if (!entry || typeof entry !== "object") return ""; const kind = String(entry.kind ?? ""); const target = String(entry.target ?? ""); if (target === "") return ""; if (kind === "app") return root.safeTargetPattern.test(target) ? "Application · launch-or-focus" : ""; if (kind === "shell") { const shell = root.shellActionLabel(target); return shell === "" ? "" : "Shell action · " + shell; } if (kind === "window") { const window = root.windowActionLabel(target); return window === "" ? "" : "Window · " + window; } return ""; } // ── Custom shortcuts ──────────────────────────────────────────────────── // [{ chord, kind, target, label }]. hypr/keybinds.lua emits these after the // shipped binds under a "Custom" category, skipping any entry whose chord // is invalid, whose label is empty, whose action does not resolve, or whose // chord a shipped bind already holds. This end refuses all four upstream so // that a saved shortcut is a working one. readonly property var customBinds: { const stored = DesktopPreferences.get("customBinds"); return Array.isArray(stored) ? stored : []; } function normalizedChord(chord: string): string { return String(chord).replace(/\s+/g, "").toLowerCase(); } function isCustomChord(chord: string): bool { const wanted = root.normalizedChord(chord); if (wanted === "") return false; return root.customBinds.some(entry => root.normalizedChord(entry?.chord ?? "") === wanted); } function customBindFor(chord: string): var { const wanted = root.normalizedChord(chord); return root.customBinds.find(entry => root.normalizedChord(entry?.chord ?? "") === wanted) ?? null; } // Is the compositor actually answering this chord with this action? A // stored entry is an intention; the keymap is the fact. Reported as true // before the first read so a fresh page does not flash a warning it has no // basis for. function customBindApplied(entry: var): bool { if (!root.loaded || !entry) return true; return root.boundTo(String(entry.chord ?? ""), "") === String(entry.label ?? ""); } function writeCustomBinds(next: var, failure: string): bool { if (!DesktopPreferences.set("customBinds", next)) { root.lastError = failure; return false; } root.applyReload(); return true; } function addCustomBind(chord: string, kind: string, target: string, label: string): bool { const trimmed = String(label).trim(); if (chord === "" || trimmed === "") { root.lastError = "A shortcut needs a chord and a name."; return false; } if (root.describeAction({ kind: kind, target: target }) === "") { root.lastError = "That is not an action Panama can bind."; return false; } const taken = root.boundTo(chord, ""); if (taken !== "") { root.lastError = `${chord} is already ${taken}.`; return false; } if (root.isCustomChord(chord)) { root.lastError = `${chord} is already one of your shortcuts.`; return false; } const next = root.customBinds.slice(); next.push({ chord: chord, kind: String(kind), target: String(target), label: trimmed }); return root.writeCustomBinds(next, "That shortcut could not be saved."); } function rebindCustomBind(currentChord: string, newChord: string): bool { if (newChord === "" || newChord === currentChord) return false; const at = root.customBinds.findIndex(entry => root.normalizedChord(entry?.chord ?? "") === root.normalizedChord(currentChord)); if (at < 0) return false; // `exceptCurrent` is the chord being vacated, so a shortcut can be // re-recorded onto the chord it already holds without refusing itself. const taken = root.boundTo(newChord, currentChord); if (taken !== "") { root.lastError = `${newChord} is already ${taken}.`; return false; } if (root.isCustomChord(newChord)) { root.lastError = `${newChord} is already one of your shortcuts.`; return false; } const next = root.customBinds.slice(); next[at] = Object.assign({}, next[at], { chord: newChord }); return root.writeCustomBinds(next, "That shortcut could not be saved."); } function removeCustomBind(chord: string): bool { const wanted = root.normalizedChord(chord); const next = root.customBinds.filter(entry => root.normalizedChord(entry?.chord ?? "") !== wanted); if (next.length === root.customBinds.length) return false; return root.writeCustomBinds(next, "That shortcut could not be removed."); } 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. // Custom leads: a list of a hundred and thirty shipped binds is somewhere // to look things up, and the two you invented are the two you came for. readonly property var groupOrder: ["Custom", "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] })); } }