#!/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 user's own shortcuts are a category too, and they are the one group whose # membership is not written in this file. They are emitted last and outside the # `bind` wrapper -- deliberately, so keybindOverrides (which is keyed by a # SHIPPED chord) can never reach one -- which means they would be invisible to # the manifest unless the loop records the category itself. grep -q 'category("Custom")' "$keybinds" \ || note 'custom shortcuts are emitted without a category, so they land in Other' grep -q 'customBinds' "$keybinds" \ || note 'keybinds.lua never reads customBinds, so shortcuts the user invents are not bound' # 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. # # Custom is the one group the user fills: shortcuts they invented, read # from customBinds at the end of keybinds.lua. It is legitimately empty on # a machine nobody has customized and legitimately full on one somebody # has, so unlike Other it carries no count expectation -- only the # requirement that it be a name the shell orders (Keybinds.qml's groupOrder # puts it first) rather than an unknown that sorts last. known='Windows Workspaces Applications Shell Session Media & hardware Custom 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'