Files
Panama/config/dot/quickshell/services/Keybinds.qml
T
Gabriel Brown 9fbbdd902b Answer "what can I press" in one keypress
The Shortcuts settings page answers "how do I change this", which is
worth opening a window for. This answers the other question, the one
you have with your hands already on the keyboard, so it is an overlay
on SUPER + / and the same key closes it.

It reads Keybinds.grouped() rather than a written-down list, so a
shortcut rebound in Settings shows its new chord here with nothing kept
in sync. A cheatsheet that lies is worse than none: it gets consulted
exactly when somebody does not already know.

Three columns, balanced by how many shortcuts each category holds. The
first attempt used a Flow, which wraps into as many columns as it likes
and made 120 binds across six uneven categories unreadable; it also
sized the card from a child that filled it, which is a circular binding
and produced a card taller than the display with its contents running
off the bottom. Both were found by looking at it rather than by a test,
which is the argument for looking at it.

Fixes a real bug on the way past: luaChord and formatChord appended the
key unconditionally, so the window switcher's modifier-only release
bind became "SUPER + " with a dangling separator. That matched neither
the chord keybinds.lua binds nor the one an override is keyed by, so
that bind could never be rebound and had no category -- it was sitting
in a seventh group of its own, which is how it was noticed.
2026-08-21 23:53:50 -04:00

414 lines
16 KiB
QML

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 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] }));
}
}