Super+Tab already cycled windows, but nothing was drawn, so you chose
blind and could only confirm the choice by arriving. A visible switcher
is muscle memory for anyone arriving from macOS or GNOME, and it was the
last item of roadmap phase 03 that did not need coordination.
Ordered most-recently-used, not by creation, because that is what makes
the gesture useful: one Tab returns to the window you just came from.
Hyprland does not report an MRU order, so it is tracked from focus
changes and keyed by address, which is the only property stable for a
window's lifetime.
The gesture needs three binds rather than two. Tab steps the selection,
and the switch is committed on Super RELEASE -- the only way the
compositor can say the gesture is over. That bind is on the bare
modifier, so it fires on every Super release in the session; commit()
returns immediately when nothing is open, which is what makes it
affordable.
A list of names rather than thumbnails: at a glance you are looking for
"the other terminal", and a row of live previews is slower to read and
far more expensive to draw than this gesture deserves.
The interesting part is the bug. The overlay was built, mapped nothing,
and logged absolutely nothing -- because it declared `required property
var screen` while Variants supplies `modelData`. shell.qml has carried a
comment warning about exactly this since the Bar hit it, and I read that
comment earlier in the same session and still walked into it. A comment
that does not stop the person who read it is an argument for a test, so
per-screen-surface-contract now checks every per-screen delegate takes
its screen from modelData. Verified it catches the exact mistake.
Also fixes a regression from 8be3fc2: settings-pages-contract still
required vitalsIntervalMs on Home, where it no longer is. That contract
was pinning the split-across-two-pages arrangement the same commit
fixed, and I pushed without running it.
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
297 lines
19 KiB
Lua
297 lines
19 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
|
|
|
|
-- 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
|
|
|
|
local function bind(chord, action, opts)
|
|
local override = overrides[chord]
|
|
if valid_chord(override) then
|
|
chord = override
|
|
end
|
|
return hl.bind(chord, action, opts)
|
|
end
|
|
|
|
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" })
|
|
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" })
|
|
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
|
|
|
|
-- ── Launcher ────────────────────────────────────────────────────────────────
|
|
-- 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" })
|
|
|
|
-- 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) ─────────────────────────────────────────────
|
|
-- 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 centre.
|
|
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" })
|
|
|
|
-- Colour picker: copies the hex under the cursor to the clipboard.
|
|
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" })
|
|
|
|
-- ── Window management ───────────────────────────────────────────────────────
|
|
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. Hyprland resizes the active window
|
|
-- along an axis and lets the layout decide which edge actually moves, so the
|
|
-- eight Forge keys collapse onto four behaviours. The pairing is kept
|
|
-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical,
|
|
-- and "increase" always grows while "decrease" always shrinks.
|
|
local step = 60
|
|
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
|
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
|
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
|
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
|
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
|
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
|
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
|
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
|
|
|
-- 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 ──────────────────────────────────────────────────────────────
|
|
-- 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.
|
|
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" })
|
|
|
|
-- Minimise, as far as Hyprland has one.
|
|
--
|
|
-- Hyprland has no minimise: 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
|
|
-- minimise 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 minimised)" })
|
|
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" })
|
|
|
|
-- ── 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("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" })
|
|
|
|
-- ── Media and volume ────────────────────────────────────────────────────────
|
|
-- 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" })
|
|
|
|
-- 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" })
|
|
|
|
-- 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" })
|
|
|
|
return true
|