Say what a keybind is for, rather than guessing from its name

The Shortcuts page grouped shortcuts by matching substrings in their
descriptions, which put "Close window" and "Close the notification
list" in the same group and left anything phrased unusually in
whichever bucket matched first. The cheatsheet that comes next would
have inherited the same guesswork.

keybinds.lua says it outright now. Its sections already were the
categories, so a section sets one and the binds below inherit it: one
line per section instead of one per bind, and a new bind lands in the
category of the section somebody wrote it in without having to
remember anything.

Hyprland reports a Lua bind's dispatcher as __lua with a bytecode
offset, so nothing can be attached to a bind that survives into
`hyprctl binds`. The config writes a manifest at load instead, keyed
by the chord actually bound so the shell can join on what it sees.
Writing never raises: a read-only state directory costs the grouping,
never the keymap, and the shell keeps the old derivation as its
fallback so a machine that has not reloaded its compositor still works.

The one failure mode is a section that forgets to set a category and
silently inherits the one above. That is not hypothetical -- it
happened while writing this, because the dictation section sits in the
middle of the media binds and its category leaked onto the volume,
media and brightness keys below it. The contract walks the file for
sections with binds and no category, and spot-checks the boundaries
where inheritance is doing the work.
This commit is contained in:
Gabriel Brown
2026-08-21 23:30:36 -04:00
parent 317b7a0962
commit 6ae8265730
4 changed files with 263 additions and 3 deletions
+1 -1
View File
@@ -113,7 +113,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
139 of them, under `tests/`. Run the lot, or a subset by pattern:
140 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh
panama test # everything
+73
View File
@@ -70,14 +70,73 @@ local function valid_chord(chord)
return chord:match("^[%w_+%s:]+$") ~= nil
end
-- ── Categories ──────────────────────────────────────────────────────────────
-- What a bind is FOR, as opposed to what it does.
--
-- The cheatsheet groups by this, and the Shortcuts settings page uses it too.
-- It is recorded here rather than guessed from the description, which is what
-- Keybinds.qml used to do: matching substrings put "Close window" and "Close
-- the notification list" in the same group and left anything phrased unusually
-- in whichever bucket matched first.
--
-- The sections of this file already ARE the categories, so a section sets one
-- and every bind below it inherits it. That keeps the annotation to one line
-- per section instead of one per bind, and makes the grouping impossible to
-- forget: a new bind lands in the category of the section it was written in.
local categories = {}
local current_category = "Other"
local function category(name)
current_category = name
end
local function bind(chord, action, opts)
local override = overrides[chord]
if valid_chord(override) then
chord = override
end
-- Keyed by the chord actually bound, so the shell can join on what
-- hyprctl reports without having to know about overrides.
categories[chord] = current_category
return hl.bind(chord, action, opts)
end
-- Written where the shell can read it. Hyprland reports a Lua bind's
-- dispatcher as `__lua` with a bytecode offset, so there is no way to attach
-- anything to a bind that survives into `hyprctl binds` -- the manifest is
-- how this side of the desktop tells the other what these binds are for.
--
-- Never raises. A read-only or missing state directory costs the categories,
-- which the shell falls back from, and must never cost the keymap.
local function write_categories()
local state_home = os.getenv("XDG_STATE_HOME")
if state_home == nil or state_home == "" then
local home = os.getenv("HOME")
if home == nil or home == "" then
return
end
state_home = home .. "/.local/state"
end
local parts = {}
for chord, name in pairs(categories) do
-- Chords and category names are both from this file, so the only
-- escaping that can matter is the quote character itself.
parts[#parts + 1] = string.format('%q:%q', chord, name)
end
table.sort(parts)
local path = state_home .. "/panama/keybind-categories.json"
os.execute("mkdir -p " .. string.format("%q", state_home .. "/panama"))
local file = io.open(path, "w")
if file == nil then
return
end
file:write("{" .. table.concat(parts, ",") .. "}\n")
file:close()
end
category("Applications")
bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
@@ -88,6 +147,7 @@ bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
-- ── Launcher ────────────────────────────────────────────────────────────────
category("Applications")
-- All three keys open the same launcher, on purpose: SUPER+A and SUPER+R were
-- the GNOME app-grid and run-dialog shortcuts, and SUPER+SPACE is here as a
-- third option to settle on. Vicinae covers apps, calculator, files, clipboard,
@@ -109,6 +169,7 @@ bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"
{ description = "Emoji picker" })
-- ── Shell surfaces (Quickshell) ─────────────────────────────────────────────
category("Shell")
-- SUPER+S was GNOME's quick settings; kept.
bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" })
@@ -141,6 +202,7 @@ bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Color picker" })
-- ── Window management ───────────────────────────────────────────────────────
category("Windows")
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" })
@@ -232,6 +294,7 @@ bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description =
bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with pointer" })
-- ── Workspaces ──────────────────────────────────────────────────────────────
category("Workspaces")
-- ALT is the workspace modifier, matching the GNOME setup.
--
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
@@ -284,6 +347,7 @@ bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description =
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimize to scratchpad" })
-- ── Session ─────────────────────────────────────────────────────────────────
category("Session")
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
-- CTRL+ALT+L -- the other binding most people already have in muscle memory.
bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
@@ -291,6 +355,7 @@ bind("SUPER + Backspace", hl.dsp.exec_cmd("loginctl lock-session"), { descriptio
bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" })
-- ── Media and volume ────────────────────────────────────────────────────────
category("Media & hardware")
-- locked = true keeps these working on the lock screen, as they do in GNOME.
-- 6% steps match the GNOME volume-step setting.
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 6")), { locked = true, repeating = true , description = "Volume up" })
@@ -299,6 +364,7 @@ bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , d
bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" })
-- ── Dictation ───────────────────────────────────────────────────────────────
category("Shell")
--
-- Hold to talk, exactly like push-to-talk anywhere else: the mic is open only
-- while the key is down, so it cannot be left listening by forgetting about it.
@@ -318,6 +384,10 @@ bind(mod .. " + D", hl.dsp.exec_cmd(dictate("stop")),
bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd(dictate("cancel")),
{ description = "Cancel dictation" })
-- Back to media: the dictation binds sit here for historical reasons, and the
-- category has to be set again or everything below inherits theirs.
category("Media & hardware")
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" })
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" })
@@ -331,6 +401,7 @@ bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , desc
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" })
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" })
category("Applications")
-- Hardware keys GNOME mapped that have obvious equivalents.
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("XF86Calculator", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
@@ -339,4 +410,6 @@ bind("XF86WWW", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
write_categories()
return true
+44 -2
View File
@@ -287,7 +287,41 @@ Singleton {
// 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";
@@ -329,8 +363,16 @@ Singleton {
// 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"]
// 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
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# What each keybind is FOR.
#
# The cheatsheet and the Shortcuts settings page both group by this, and until
# now the grouping was guessed from the description by substring matching --
# which put "Close window" and "Close the notification list" in the same group
# and left anything phrased unusually in whichever bucket matched first.
#
# hypr/keybinds.lua now says it outright. The sections of that file already ARE
# the categories, so a section sets one and every bind below it inherits it,
# which keeps the annotation to one line per section rather than one per bind.
# That is cheap, and it has exactly one failure mode worth testing: a section
# that forgets to set its category silently inherits the previous section's,
# and the binds land somewhere plausible-looking but wrong. That is what
# happened to the media keys the first time this was written -- the dictation
# section sits in the middle of them and its category leaked onto everything
# below it.
#
# So this checks the manifest the compositor actually produced, not the source.
# Generating it needs a running Hyprland; without one, the structural checks
# still run.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
keybinds="$repo_dir/config/dot/hypr/keybinds.lua"
service="$repo_dir/config/dot/quickshell/services/Keybinds.qml"
manifest="${XDG_STATE_HOME:-$HOME/.local/state}/panama/keybind-categories.json"
findings=()
note() { findings+=("$1"); }
# ── The source says what it is doing ─────────────────────────────────────────
grep -q 'local function category' "$keybinds" \
|| note 'keybinds.lua has no category() marker, so nothing records what a bind is for'
grep -q 'categories\[chord\]' "$keybinds" \
|| note 'the bind wrapper does not record a category'
grep -q 'write_categories()' "$keybinds" \
|| note 'the manifest is never written'
# Writing must never cost the keymap. A read-only state directory is a bad day,
# not a machine without shortcuts.
grep -q 'if file == nil then' "$keybinds" \
|| note 'the manifest writer does not tolerate being unable to open the file'
# The shell prefers the authored category and still works without one.
grep -q 'categoryManifest' "$service" \
|| note 'the shell never reads the category manifest'
grep -q 'onLoadFailed: root.categoryManifest' "$service" \
|| note 'a missing manifest is not handled, so a machine that has not reloaded its compositor would break'
# ── Every section sets a category ────────────────────────────────────────────
#
# Walk the file: each `-- ── Name ──` header should be followed by a category()
# call before the next bind(). A header with binds under it and no category
# between is a section inheriting the previous one's, which is the mistake.
python3 - "$keybinds" <<'PY' || note 'a section of keybinds.lua has binds but never sets a category, so they inherit the section above'
import re, sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
pending_header = None
seen_category = True
problems = []
for number, line in enumerate(lines, 1):
if re.match(r"^-- ── ", line):
pending_header = (number, line)
seen_category = False
continue
if re.match(r"^category\(", line):
seen_category = True
continue
if re.match(r"^bind\(", line) and not seen_category and pending_header:
problems.append(f"line {pending_header[0]}: {pending_header[1][:60]}")
seen_category = True
if problems:
print("\n".join(problems), file=sys.stderr)
raise SystemExit(1)
PY
# ── The manifest the compositor produced ─────────────────────────────────────
if ! command -v hyprctl >/dev/null 2>&1 || ! hyprctl version >/dev/null 2>&1; then
printf 'keybind categories contract: PASS (structure only; no running compositor)\n'
exit 0
fi
[[ -r "$manifest" ]] || {
# A reload regenerates it. Ask for one rather than failing on a machine
# that simply has not reloaded since this landed.
hyprctl reload >/dev/null 2>&1
sleep 2
}
[[ -r "$manifest" ]] || { note 'the compositor produced no category manifest'; }
if [[ -r "$manifest" ]]; then
total="$(jq 'length' "$manifest" 2>/dev/null || echo 0)"
(( total > 100 )) || note "the manifest holds $total categories; the keymap has well over a hundred binds"
# Every category is one the shell knows how to order. A typo produces a
# group that sorts last and looks like a bug in the cheatsheet.
known='Windows Workspaces Applications Shell Session Media & hardware Other'
while read -r value; do
[[ -n "$value" ]] || continue
grep -qF "$value" <<<"$known" \
|| note "the manifest contains an unknown category: $value"
done < <(jq -r '[.[]] | unique | .[]' "$manifest" 2>/dev/null)
# Nothing should land in Other: it is the default for a bind written above
# the first category() call, which means somebody added a section without
# one.
others="$(jq -r '[to_entries[] | select(.value == "Other") | .key] | join(", ")' "$manifest" 2>/dev/null)"
[[ -n "$others" && "$others" != "" ]] \
&& note "these binds have no category and fell back to Other: $others"
# Spot checks. Chosen because each one sits at a boundary where the
# category is inherited rather than obvious, which is where this breaks.
check() {
local chord="$1" want="$2"
local got
got="$(jq -r --arg c "$chord" '.[$c] // ""' "$manifest" 2>/dev/null)"
[[ "$got" == "$want" ]] \
|| note "$chord is categorised as '${got:-nothing}', expected '$want'"
}
check "SUPER + Q" "Windows"
check "ALT + 1" "Workspaces"
check "SUPER + T" "Applications"
check "CTRL + ALT + L" "Session"
check "SUPER + D" "Shell"
# The two that leaked the first time: both sit below the dictation section,
# which is physically in the middle of the media binds.
check "XF86AudioPlay" "Media & hardware"
check "XF86MonBrightnessUp" "Media & hardware"
# And the hardware application keys at the very end of the file.
check "XF86Calculator" "Applications"
fi
if (( ${#findings[@]} > 0 )); then
printf 'keybind categories contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'keybind categories contract: PASS\n'