Stage 3 and 4 of docs/superpowers/plans/2026-08-17-panama-cohesion.md. Add SettingsPage plus ToggleRow, SliderRow, ChoiceRow, ActionRow, and TextRow. A row names a schema key and needs nothing else: label, detail, range, and unit come from PreferenceSchema, and writes go through SystemSettings.commitPreference, which routes compositor-backed keys through apply-and-verify and local keys straight to the store. The page scaffold that was copy-pasted eleven times is now one component. Rebuild Appearance around a live preview of the real desktop, scaled by the ratio between the preview and the actual monitor so a 10px gap on a 4500px display looks as small as it is. Rebuild Desktop & Dock and Input & Shortcuts on the shared rows, replacing the read-only text that stood in for controls that were merely expensive to add. Generate the shortcut list from hyprctl binds. The page held a hand-typed nineteen entries against a real keymap of a hundred and thirteen; it could not show the rest and went stale whenever a bind changed. Every bind now carries its own description -- backfilled for the twenty-nine that lacked one -- and keybinds-contract.sh fails if any bind lacks one, since undescribed binds are dropped from the page. Make Restore defaults span every store Panama owns. Resetting only the schema store left the Home accessory arrangement customised while claiming to restore defaults, which is worse than no reset because it is silent. Done through HomePreferences' existing public aliases rather than a new API. Four defects found while building: cursor:inactive_timeout is answered by getoption as float, not int. A wrong readAs does not fail loudly; it makes every write to that key look rejected, and the user saw an error for a change that worked. schema-hypr-shape-contract.sh now checks all 23 mapped options against the running compositor. The Settings window is tiled, so implicitWidth is only a hint and rows must survive roughly 400px. SliderRow stacks its control under the label below 520px. Binding an anchor to undefined to switch layouts does not reliably release it. Both row layouts are positioned explicitly. Concurrent compositor writes are queued and merged rather than refused. The startup replay of every compositor-backed preference routinely overlaps a UI change, and refusing left the store and the compositor disagreeing. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
244 lines
17 KiB
Lua
244 lines
17 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 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"
|
|
|
|
-- Quickshell IPC targets. See quickshell/shell.qml for the handlers.
|
|
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
|
|
|
|
-- ── Applications ────────────────────────────────────────────────────────────
|
|
hl.bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
|
|
hl.bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
|
|
hl.bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
|
|
hl.bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
|
|
hl.bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
|
|
hl.bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
|
hl.bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" })
|
|
hl.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.
|
|
hl.bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
|
hl.bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
|
hl.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.
|
|
hl.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.
|
|
hl.bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"),
|
|
{ description = "Clipboard history" })
|
|
hl.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.
|
|
hl.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.
|
|
hl.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.
|
|
hl.bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
|
|
|
|
-- Notification centre.
|
|
hl.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.
|
|
hl.bind("Print", hl.dsp.exec_cmd(qs("capture", "open")), { description = "Screenshot / record" })
|
|
-- The GNOME direct-capture variants, kept as shortcuts past the picker.
|
|
hl.bind("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" })
|
|
hl.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.
|
|
hl.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.
|
|
hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" })
|
|
|
|
-- ── Window management ───────────────────────────────────────────────────────
|
|
hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
|
hl.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.
|
|
hl.bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" })
|
|
hl.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.
|
|
hl.bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" })
|
|
hl.bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" })
|
|
hl.bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" })
|
|
|
|
-- Forge: window-shrink / window-expand / window-reset-sizes.
|
|
hl.bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" })
|
|
hl.bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" })
|
|
hl.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.
|
|
hl.bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
|
|
hl.bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
|
|
hl.bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
|
|
hl.bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
|
|
hl.bind(mod .. " + left", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
|
|
hl.bind(mod .. " + down", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
|
|
hl.bind(mod .. " + up", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
|
|
hl.bind(mod .. " + right", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
|
|
|
|
-- Move (Forge: window-move-*).
|
|
hl.bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" })
|
|
hl.bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" })
|
|
hl.bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" })
|
|
hl.bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" })
|
|
|
|
-- Swap (Forge: window-swap-*).
|
|
hl.bind(mod .. " + CTRL + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" })
|
|
hl.bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" })
|
|
hl.bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" })
|
|
hl.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
|
|
hl.bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
|
hl.bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
|
hl.bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
|
hl.bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
|
hl.bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
|
hl.bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
|
hl.bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
|
hl.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).
|
|
hl.bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
|
|
hl.bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
|
|
-- Jump back to the previously focused window.
|
|
hl.bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
|
|
|
|
-- Mouse: drag to move, right-drag to resize.
|
|
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window with pointer" })
|
|
hl.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.
|
|
hl.bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
|
hl.bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
|
hl.bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
|
|
hl.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.
|
|
hl.bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
|
hl.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
|
|
hl.bind("ALT + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i })
|
|
hl.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.)
|
|
hl.bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
|
hl.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.
|
|
hl.bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" })
|
|
hl.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.
|
|
hl.bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
|
|
hl.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.
|
|
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true , description = "Volume up" })
|
|
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true , description = "Volume down" })
|
|
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true , description = "Mute" })
|
|
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true , description = "Mute microphone" })
|
|
|
|
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
|
|
hl.bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true , description = "Volume up (fine)" })
|
|
hl.bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true , description = "Volume down (fine)" })
|
|
|
|
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" })
|
|
hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" })
|
|
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true , description = "Next track" })
|
|
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true , description = "Previous track" })
|
|
hl.bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true , description = "Stop playback" })
|
|
|
|
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true , description = "Brightness up" })
|
|
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true , description = "Brightness down" })
|
|
|
|
-- Hardware keys GNOME mapped that have obvious equivalents.
|
|
hl.bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
|
|
hl.bind("XF86Calculator", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
|
|
hl.bind("XF86Explorer", hl.dsp.exec_cmd(files), { description = "Files" })
|
|
hl.bind("XF86WWW", hl.dsp.exec_cmd(browser), { description = "Browser" })
|
|
hl.bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
|
hl.bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
|
|
|
return true
|