627 lines
36 KiB
Lua
627 lines
36 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 actions = require("actions")
|
||
|
||
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" })
|
||
|
||
-- ── Magnifier ───────────────────────────────────────────────────────────────
|
||
category("Shell")
|
||
--
|
||
-- The chords are NOT the obvious SUPER+=/-/0. SUPER+equal is already "Reset
|
||
-- split" (Window management, below), and taking a daily tiling key away to
|
||
-- give the magnifier the prettiest chord on the keyboard is the wrong trade.
|
||
--
|
||
-- SUPER+ALT is where they went instead, which is also where GNOME's magnifier
|
||
-- lives: gsettings' magnifier-zoom-in / magnifier-zoom-out ship as
|
||
-- <Alt><Super>= and <Alt><Super>-, so this is the shortcut the machine this
|
||
-- desktop replaced already had. SUPER+ALT+0 -- free; the workspace digits are
|
||
-- plain ALT -- resets to 1.00 ×, reading as "back to zero magnification".
|
||
--
|
||
-- These go THROUGH the shell rather than calling `hyprctl keyword
|
||
-- cursor:zoom_factor` directly. Setting the compositor option behind Panama's
|
||
-- back would leave the stored preference and the Magnifier slider claiming a
|
||
-- magnification that is not the one on screen; the IPC call commits through
|
||
-- the same verified-preference path the slider uses, so the store, the
|
||
-- compositor and the settings page can never disagree. It also posts the OSD,
|
||
-- which is the only way to see what the factor now is with the pointer
|
||
-- somewhere else entirely.
|
||
--
|
||
-- Not `repeating`: the step is multiplicative (×1.25), so a held key repeating
|
||
-- at the keyboard rate would arrive at the 5.00 × ceiling in about a tenth of
|
||
-- a second. One press, one step.
|
||
--
|
||
-- Written as literal chords rather than `mod .. " + ALT + ..."` (as
|
||
-- "SUPER + Backspace" already is, above) because these three are the most
|
||
-- collision-prone binds in the file -- they were placed around one -- and a
|
||
-- literal is the form both the duplicate-chord check and the settings page's
|
||
-- chord display can actually read.
|
||
bind("SUPER + ALT + equal", hl.dsp.exec_cmd(qs("accessibility", "zoom in")),
|
||
{ description = "Zoom in" })
|
||
bind("SUPER + ALT + minus", hl.dsp.exec_cmd(qs("accessibility", "zoom out")),
|
||
{ description = "Zoom out" })
|
||
bind("SUPER + ALT + 0", hl.dsp.exec_cmd(qs("accessibility", "zoom reset")),
|
||
{ description = "Reset zoom" })
|
||
|
||
-- ── 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" })
|
||
|
||
-- ── Custom shortcuts ────────────────────────────────────────────────────────
|
||
--
|
||
-- Shortcuts the user invented, from `customBinds` in settings.json. Each entry
|
||
-- is { chord, kind, target, label } -- data, never a command. actions.lua turns
|
||
-- the kind/target pair into a dispatcher through whitelist tables; an entry it
|
||
-- does not recognise resolves to nil and is silently not emitted.
|
||
--
|
||
-- Emitted LAST, and through hl.bind rather than the `bind` wrapper above. Two
|
||
-- separate reasons, both about keeping the two rebinding mechanisms apart:
|
||
--
|
||
-- * `keybindOverrides` is keyed by a SHIPPED chord. A custom bind has no
|
||
-- shipped chord -- it is rebound by rewriting its own entry -- so putting
|
||
-- one through `bind` would let an override for some shipped key silently
|
||
-- move a custom one that happened to share a chord.
|
||
-- * last means a custom chord that collides with a shipped one loses, which
|
||
-- is checked explicitly below rather than left to Hyprland's ordering.
|
||
--
|
||
-- Settings prevents a collision upstream; this is the second lock, because the
|
||
-- file is hand-editable and losing a shipped key to a typo is not acceptable.
|
||
category("Custom")
|
||
|
||
local function custom_bind(chord, dispatcher, label)
|
||
categories[chord] = current_category
|
||
return hl.bind(chord, dispatcher, { description = label })
|
||
end
|
||
|
||
for _, entry in ipairs(prefs.get("customBinds", {})) do
|
||
if type(entry) == "table" then
|
||
local chord = entry.chord
|
||
local label = entry.label
|
||
|
||
-- A description is not decoration: keybinds-contract fails a build
|
||
-- with a description-less bind, and the cheatsheet and Shortcuts page
|
||
-- both list what they find. A nameless shortcut is unfindable.
|
||
if valid_chord(chord)
|
||
and type(label) == "string" and label ~= ""
|
||
and categories[chord] == nil
|
||
then
|
||
local dispatcher = actions.dispatcher(entry)
|
||
if dispatcher ~= nil then
|
||
custom_bind(chord, dispatcher, label)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
write_categories()
|
||
|
||
return true
|