Files
Panama/config/dot/hypr/keybinds.lua
T

542 lines
31 KiB
Lua

-- ─────────────────────────────────────────────────────────────────────────────
-- Keybindings
--
-- Ported 1:1 from the GNOME + Forge setup this replaces. Where Hyprland has no
-- equivalent, the deviation is called out in a comment rather than silently
-- dropped.
--
-- The mental model, unchanged from Forge:
-- SUPER -> act on windows (focus / move / swap / resize)
-- ALT -> act on workspaces
-- SUPER + CTRL -> change layout structure (split, float, swap)
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
local mod = "SUPER"
-- Programs, matched to the GNOME custom keybindings and media-key settings.
local terminal = "kitty"
local editor = "kitty nvim ."
local browser = "helium-browser-bin"
local files = "nautilus --new-window"
local calculator = "gnome-calculator"
local mail = "flatpak run org.mozilla.thunderbird_esr"
-- Panama owns the Hyprland and shell controls. GNOME Settings remains installed
-- and searchable in Vicinae for its hardware/account panels.
local settings = "qs ipc call settings toggle"
local sysmonitor = "flatpak run io.missioncenter.MissionCenter"
-- Vicinae is the Raycast-style launcher. `vicinae toggle` shows/hides the
-- window against the already-running server (started in autostart.lua).
local launcher = "vicinae toggle"
local osd = function(action)
return "$HOME/.config/quickshell/scripts/panama-osd " .. action
end
local lid = function(action)
return "$HOME/.config/quickshell/scripts/panama-lid " .. action
end
local dictate = function(action)
return "$HOME/.config/quickshell/scripts/panama-dictate " .. action
end
-- Quickshell IPC targets. See quickshell/shell.qml for the handlers.
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
-- ── Applications ────────────────────────────────────────────────────────────
-- ── User rebinding ──────────────────────────────────────────────────────────
-- Every bind below goes through `bind` rather than `hl.bind` directly, so a
-- chord can be replaced from Panama Settings without this file changing.
--
-- Overrides are keyed by the bind's SHIPPED chord, and ONLY the chord is taken
-- from settings -- the action is always the Lua value written here. A stored
-- override can therefore move a shortcut but can never make one do something
-- else, which is the property that makes reading them from a JSON file the
-- user can edit safe.
--
-- Keyed by chord rather than by description because chords are unique and
-- descriptions are not: "Calculator" is both SUPER+C and the XF86Calculator
-- hardware key, and keying by description moved both of them onto the same new
-- chord, silently costing the hardware key.
--
-- An override whose chord is not a plausible chord is ignored and the shipped
-- one is used, so a hand-edited settings file cannot cost you a keymap.
local overrides = prefs.get("keybindOverrides", {})
local function valid_chord(chord)
if type(chord) ~= "string" or chord == "" or #chord > 64 then
return false
end
-- "SUPER + SHIFT + K", "XF86AudioPlay", "Print", "SUPER + mouse:272"
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
-- SUPER opens a new one. SUPER+ALT goes to the one you already have.
--
-- That order matters and was chosen deliberately after trying the reverse.
-- Making the plain key focus an existing window reads well in a demo and is
-- what macOS does, but it makes "give me another terminal" the awkward case --
-- and on a tiling desktop, opening a second terminal beside the first is not
-- an edge case, it is the normal way to work. So the plain key keeps doing
-- what it has always done, and the modifier is the new capability rather than
-- a tax on the old one.
--
-- ALT rather than SHIFT because SUPER+SHIFT is already the window-manipulation
-- space: Files, Neovim and Settings would have collided with Focus session,
-- Taller and Shorter, and breaking two keys out of the eight-key resize set to
-- make room is a worse trade than borrowing a modifier.
--
-- The go-to binds still launch when nothing is open. A key that silently does
-- nothing is worse than one that does the obvious thing.
--
-- Patterns are regular expressions and are anchored. An unanchored "mail"
-- would match gmail-notifier, and the go-to-mail key would raise somebody's
-- notifier instead. Single-quoted for the shell so a backslash reaches the
-- matcher rather than being eaten on the way.
local function shell_quote(value)
return "'" .. value:gsub("'", "'\\''") .. "'"
end
local launcher_bin = "$HOME/.local/share/Panama/bin/panama-launch"
local function go_to(class, command, title)
local parts = { launcher_bin, "--class", shell_quote(class) }
if title then
parts[#parts + 1] = "--title"
parts[#parts + 1] = shell_quote(title)
end
parts[#parts + 1] = "--"
parts[#parts + 1] = command
return table.concat(parts, " ")
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" })
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
-- Go to the one already open, or start it if there is none.
bind(mod .. " + ALT + T", hl.dsp.exec_cmd(go_to("^kitty$", terminal)),
{ description = "Go to terminal" })
bind(mod .. " + ALT + N", hl.dsp.exec_cmd(go_to("^kitty$", editor, "nvim")),
{ description = "Go to Neovim" })
bind(mod .. " + ALT + W", hl.dsp.exec_cmd(go_to("^helium", browser)),
{ description = "Go to browser" })
bind(mod .. " + ALT + F", hl.dsp.exec_cmd(go_to("^org\\.gnome\\.Nautilus$", files)),
{ description = "Go to files" })
bind(mod .. " + ALT + C", hl.dsp.exec_cmd(go_to("^org\\.gnome\\.Calculator$", calculator)),
{ description = "Go to calculator" })
bind(mod .. " + ALT + E", hl.dsp.exec_cmd(go_to("^org\\.mozilla\\.thunderbird", mail)),
{ description = "Go to mail" })
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,
-- emoji and window switching, so a separate run dialog and app grid are gone.
bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
-- Emergency fallback launcher. Vicinae runs as a systemd user service and the
-- bar is Quickshell; if either fails to come up, this is how you start an
-- application without dropping to a TTY. Depends on nothing but wofi itself.
bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" })
-- Every shortcut, on one key. Slash because "what are the keys" is a question,
-- and because it is the one punctuation key no other bind wants.
bind(mod .. " + slash", hl.dsp.exec_cmd(qs("cheatsheet", "toggle")), { description = "Keyboard shortcuts" })
-- Clipboard history and emoji, straight into the relevant launcher view.
-- Deeplink form is the one from vicinae's own Hyprland quickstart.
bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"),
{ description = "Clipboard history" })
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" })
-- Start a 45-minute focus session on the current workspace, or reveal its
-- Signal Glass controls if one is already running.
bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { description = "Focus session" })
-- Workspace overview. GNOME put this on a bare SUPER tap; tap-detection on a
-- modifier misfires when you're fast with SUPER+key combos, so it lives on a
-- real chord instead. SUPER+grave was Forge's "cycle windows of same app",
-- which Hyprland has no equivalent for.
bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
-- Notification center.
bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
-- Screenshot / screen record. One key, then pick screen / window / region and
-- whether to capture or record -- reproducing GNOME's Print-screen UI.
bind("Print", hl.dsp.exec_cmd(qs("capture", "open")), { description = "Screenshot / record" })
-- The GNOME direct-capture variants, kept as shortcuts past the picker.
bind("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" })
bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description = "Screenshot: window" })
-- Local OCR and QR/barcode recognition through the same region picker. This
-- opens directly in Selection + Read mode; Print still exposes every mode.
bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
{ description = "Screen Intelligence" })
-- Color picker: copies the hex under the cursor to the clipboard.
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" })
-- Forge: window-toggle-float / window-toggle-always-float.
-- "Always float" has no Hyprland equivalent (it wrote a persistent rule); pin
-- is the nearest useful thing -- the window floats above every workspace.
bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" })
bind(mod .. " + CTRL + SHIFT + C", hl.dsp.window.pin({ action = "toggle" }), { description = "Pin window" })
-- Forge: con-split-layout-toggle / con-split-horizontal / con-split-vertical.
bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" })
bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" })
bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" })
-- Forge: window-shrink / window-expand / window-reset-sizes.
bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" })
bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" })
bind(mod .. " + equal", hl.dsp.layout("splitratio exact 0.5"), { description = "Reset split" })
-- Focus (Forge: window-focus-*). Both vim keys and arrows, as in Forge.
bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
bind(mod .. " + left", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
bind(mod .. " + down", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
bind(mod .. " + up", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
bind(mod .. " + right", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
-- Move (Forge: window-move-*).
bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" })
bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" })
bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" })
bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" })
-- Swap (Forge: window-swap-*).
bind(mod .. " + CTRL + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" })
bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" })
bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" })
bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { description = "Swap right" })
-- Resize (Forge: window-resize-<edge>-<increase|decrease>).
--
-- Forge resized one named EDGE at a time: its resize() grows the window for a
-- positive amount in every direction, and the edge only decides which side
-- moves -- Y grew leftward, O grew rightward, and so on. Hyprland resizes along
-- an axis and lets the layout choose the border, so those eight distinct
-- behaviours collapse onto four and the direction is simply not expressible.
--
-- Because of that the sizes are deliberately INVERTED from Forge's naming.
-- Carried over faithfully, "increase" grew and "decrease" shrank, which was
-- correct on paper and wrong under the fingers: with the edge gone, the keys
-- that used to pull a window open from one side now push it from the other.
-- Gabriel uses these daily and reads Y/O as shrink and B/M as grow, so that is
-- what they do. Faithfulness to a mapping nobody can feel is not worth much.
local step = 60
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
-- SUPER+SHIFT+P was double-bound with the color picker above; moved to
-- Comma, which continues the bottom-row cluster (B/M/N) this axis already
-- uses rather than landing on an arbitrary free key.
bind(mod .. " + SHIFT + Comma", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
-- showing what you are choosing between.
--
-- The gesture needs three binds, not two. Tab steps the selection, and the
-- switch is only COMMITTED when the modifier is released -- which is the sole
-- way the compositor can tell the gesture is finished. That release bind is on
-- the bare modifier, so it fires on EVERY Super release in the session; the
-- handler returns immediately when no switch is open, which is why this is
-- affordable.
--
-- The release bind carries no description on purpose: it is not a shortcut
-- anyone would look up or rebind, and the Shortcuts page lists what it finds.
bind(mod .. " + Tab", hl.dsp.exec_cmd(qs("switcher", "next")), { description = "Next window" })
bind(mod .. " + SHIFT + Tab", hl.dsp.exec_cmd(qs("switcher", "previous")), { description = "Previous window" })
bind(mod, hl.dsp.exec_cmd(qs("switcher", "commit")), { release = true, description = "Commit window switch" })
-- Jump back to the previously focused window.
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
-- Mouse: drag to move, right-drag to resize.
bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window with pointer" })
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:
-- moving right past the last workspace creates a new one, and moving left from
-- the first clamps instead of wrapping.
-- Behavior for the relative/cyclic binds below. These are Hyprland's own
-- `binds:` options -- not part of general/dwindle -- and have no other home
-- in the config, so they are read here rather than in looks.lua.
hl.config({
binds = {
workspace_back_and_forth = prefs.get("workspaceBackAndForth", false),
allow_workspace_cycles = prefs.get("allowWorkspaceCycles", false),
},
})
bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
bind("ALT + SHIFT + L", hl.dsp.window.move({ workspace = "+1" }), { description = "Move window to workspace right" })
-- GNOME also had these on CTRL+ALT+Up/Down.
bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
-- Direct jump. ALT+0 is workspace 10.
for i = 1, 10 do
local key = i % 10
bind("ALT + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i })
bind("ALT + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }), { description = "Move window to workspace " .. i })
end
-- Scroll the mouse wheel over the desktop with SUPER held to change workspace.
-- (Scrolling the workspace indicator in the bar does the same; that's handled
-- in quickshell/modules/bar/Workspaces.qml.)
bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
-- Minimize, as far as Hyprland has one.
--
-- Hyprland has no minimize: it receives the request (the binary has
-- setSetMinimized handlers for xdg, XWayland and foreign-toplevel) but exposes
-- no dispatcher, no config option and not even an event to hook, so titlebar
-- minimize buttons are inert and cannot be made to work. A tiling WM has no
-- iconified state and no taskbar to restore from.
--
-- The scratchpad is the honest equivalent: the window goes away, and the same
-- key brings it back. Bound to X to match the muscle memory it replaces.
bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimized)" })
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" })
bind("SUPER + Backspace", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
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" })
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = true, repeating = true , description = "Volume down" })
bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" })
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.
-- Two binds on one chord, the second flagged `release`.
--
-- No `repeating`: holding a key normally repeats the press, which would restart
-- the recording several times a second. The daemon refuses a second start while
-- one is running, so a repeat would be harmless -- but not asking for it is
-- better than relying on being refused.
bind(mod .. " + D", hl.dsp.exec_cmd(dictate("start")),
{ description = "Dictate (hold to talk)" })
bind(mod .. " + D", hl.dsp.exec_cmd(dictate("stop")),
{ release = true, description = "Dictate (transcribe on release)" })
-- Escape out of a recording without transcribing it. Bound to the same modifier
-- so it can be reached with the dictation key still held.
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)" })
bind("XF86AudioPlay", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
bind("XF86AudioPause", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
bind("XF86AudioNext", hl.dsp.exec_cmd(osd("media next")), { locked = true , description = "Next track" })
bind("XF86AudioPrev", hl.dsp.exec_cmd(osd("media previous")), { locked = true , description = "Previous track" })
bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , description = "Stop playback" })
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" })
-- Airplane mode. The Framework's F10 emits exactly this keysym, and for a
-- while it emitted it into silence. panama-osd owns the toggle so the OSD
-- can say which way it went.
bind("XF86RFKill", hl.dsp.exec_cmd(osd("airplane toggle")), { locked = true, description = "Airplane mode" })
-- F9 on the same row. GNOME shows a display-switching OSD here; until
-- mirroring exists (DESKTOP-PARITY gap), the honest action is the page where
-- displays are actually arranged.
bind("XF86Display", hl.dsp.exec_cmd("qs ipc call settings page displays"), { description = "Display settings" })
-- ── The power button ────────────────────────────────────────────────────────
category("Media & hardware")
--
-- logind is told to ignore the power key (config/copy ships the drop-in) so a
-- stray press is a question, not an instant poweroff. That makes what the
-- question IS Panama's to choose, and `powerButtonAction` is where the choice
-- is recorded. Until the next boot after that drop-in lands, logind still acts
-- on the key; this bind costs nothing extra then.
--
-- The branch runs ON EVERY PRESS rather than here at config time.
--
-- A config-time branch would be shorter -- `prefs.get` and four `if`s -- and it
-- would also make this the one control on the Power page that does nothing
-- until the compositor is reloaded. Every other setting in Panama applies as
-- you change it, and a power button that ignores what the settings app says it
-- does is a worse thing to ship than a long command string. So the bind is a
-- `case` over what the settings file says at the moment the key goes down.
--
-- Everything that can go wrong lands on the shipped default: no jq, no file, a
-- truncated file, or a value nobody recognises all fall through to `*)` and
-- open the menu. The failure direction is "the power button opens a menu",
-- never "the power button does something you did not ask for".
--
-- Powering off goes THROUGH the menu with Power Off pre-armed rather than
-- calling `systemctl poweroff` here. The menu's two-press confirm is what
-- stands between a pocketed key and an unsaved afternoon, and a direct
-- poweroff would quietly throw it away -- so a person who picks "Powers off"
-- gets a fast poweroff, not an unguarded one.
local power_button = {
menu = qs("powermenu", "toggle"),
suspend = "systemctl suspend",
poweroff = qs("powermenu", "open") .. " poweroff",
nothing = ":",
}
local power_button_command = table.concat({
[[case "$(jq -r '.powerButtonAction // empty' "${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" 2>/dev/null)" in]],
"suspend) " .. power_button.suspend .. " ;;",
"poweroff) " .. power_button.poweroff .. " ;;",
"nothing) " .. power_button.nothing .. " ;;",
"*) " .. power_button.menu .. " ;;",
"esac",
}, " ")
bind("XF86PowerOff", hl.dsp.exec_cmd(power_button_command),
{ locked = true, description = "Power button" })
-- The lid, as a switch rather than a key. Closing a docked lid turns the
-- internal panel off so nothing renders inside a closed shell and no
-- workspace strands on an invisible output; opening it turns the panel back
-- on. panama-lid owns the decision -- undocked machines suspend via logind
-- before this matters, and the guard's inhibitor handles staying awake.
bind("switch:on:Lid Switch", hl.dsp.exec_cmd(lid("close")), { locked = true, description = "Lid closed" })
bind("switch:off:Lid Switch", hl.dsp.exec_cmd(lid("open")), { locked = true, description = "Lid opened" })
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" })
bind("XF86Explorer", hl.dsp.exec_cmd(files), { description = "Files" })
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