163 lines
7.5 KiB
Bash
Executable File
163 lines
7.5 KiB
Bash
Executable File
#!/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'
|