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
+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'