diff --git a/README.md b/README.md index 7b65b34..f5c7a7f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/dot/hypr/keybinds.lua b/config/dot/hypr/keybinds.lua index 05d3a39..624b8c8 100644 --- a/config/dot/hypr/keybinds.lua +++ b/config/dot/hypr/keybinds.lua @@ -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 diff --git a/config/dot/quickshell/services/Keybinds.qml b/config/dot/quickshell/services/Keybinds.qml index cdcd450..38210c1 100644 --- a/config/dot/quickshell/services/Keybinds.qml +++ b/config/dot/quickshell/services/Keybinds.qml @@ -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 diff --git a/tests/hypr/keybind-categories-contract b/tests/hypr/keybind-categories-contract new file mode 100755 index 0000000..a24fa41 --- /dev/null +++ b/tests/hypr/keybind-categories-contract @@ -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'