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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+145
@@ -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'
|
||||
Reference in New Issue
Block a user