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
+73
View File
@@ -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