diff --git a/config/dot/hypr/keybinds.lua b/config/dot/hypr/keybinds.lua index 247caa0..083c9d3 100644 --- a/config/dot/hypr/keybinds.lua +++ b/config/dot/hypr/keybinds.lua @@ -11,6 +11,8 @@ -- 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. @@ -33,109 +35,145 @@ local launcher = "vicinae toggle" 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" }) +-- ── 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 = "Panama 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. -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" }) +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. -hl.bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" }) +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"), +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"), +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" }) +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" }) +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" }) +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" }) +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" }) +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" }) +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. -hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")), +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" }) +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" }) +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. -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" }) +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. -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" }) +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. -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" }) +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. -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" }) +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-*). -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" }) +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-*). -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" }) +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--). -- @@ -145,24 +183,24 @@ hl.bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { descrip -- 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" }) +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). -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" }) +bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" }) +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" }) +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" }) +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. @@ -170,27 +208,27 @@ hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, descripti -- 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" }) +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. -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" }) +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 - 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 }) + 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.) -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" }) +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. -- @@ -202,42 +240,42 @@ hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description -- -- 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" }) +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. -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" }) +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. -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" }) +bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true , description = "Volume up" }) +bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true , description = "Volume down" }) +bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true , description = "Mute" }) +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)" }) +bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true , description = "Volume up (fine)" }) +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" }) +bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" }) +bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" }) +bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true , description = "Next track" }) +bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true , description = "Previous track" }) +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" }) +bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true , description = "Brightness up" }) +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" }) +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 diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 95c8d8b..97ec98b 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -492,6 +492,22 @@ Singleton { ] }, + // ── Keyboard shortcut overrides ───────────────────────────────────── + // { "": "" }. Only the chord is stored: the + // action always comes from hypr/keybinds.lua, so an override can move a + // shortcut but can never make one do something else. The Lua validates + // each chord and falls back to the shipped one, so a hand-edited file + // cannot cost you a keymap. + // + // Edited through the Input & Shortcuts page rather than as a row, hence + // internal. + { + key: "keybindOverrides", type: "json", def: ({}), group: "input", + internal: true, + label: "Keyboard shortcut overrides", + detail: "Shortcuts you have moved from their shipped chord" + }, + // ── Internal ──────────────────────────────────────────────────────── { key: "lastPage", type: "string", def: "home", group: "internal", diff --git a/config/dot/quickshell/keybinds-harness.qml b/config/dot/quickshell/keybinds-harness.qml index 1cd8614..87e2ebe 100644 --- a/config/dot/quickshell/keybinds-harness.qml +++ b/config/dot/quickshell/keybinds-harness.qml @@ -2,12 +2,32 @@ import Quickshell import Quickshell.Io import QtQuick +import qs.config import qs.services ShellRoot { IpcHandler { target: "keybinds-test" + function rebind(current: string, next: string): bool { + return Keybinds.rebind(current, next); + } + + function resetBind(current: string): void { Keybinds.resetBind(current); } + function resetAll(): void { Keybinds.resetAll(); } + + function chordFor(description: string): string { + const found = Keybinds.binds.find(bind => bind.description === description); + return found ? found.luaChord : ""; + } + + function overrideState(): string { + return JSON.stringify({ + overrides: Keybinds.overrides, + count: Object.keys(Keybinds.overrides).length + }); + } + function status(): string { const grouped = Keybinds.grouped(); let groupedCount = 0; diff --git a/config/dot/quickshell/modules/settings/ShortcutCapture.qml b/config/dot/quickshell/modules/settings/ShortcutCapture.qml new file mode 100644 index 0000000..4ca73cd --- /dev/null +++ b/config/dot/quickshell/modules/settings/ShortcutCapture.qml @@ -0,0 +1,132 @@ +// Captures a key chord for rebinding. +// +// Shown in place of a shortcut's chord while it is being changed. It takes +// keyboard focus, waits for a non-modifier key, and reports the chord in the +// form hypr/keybinds.lua uses. +// +// Modifier-only presses are ignored rather than accepted, because every press +// of a chord passes through them: holding Super to type Super+K would otherwise +// be captured as "SUPER" the moment the modifier went down. +// +// Escape cancels. Not every Qt key has a keysym name Hyprland would accept, so +// an unmapped key is refused with a message rather than written as something +// that would silently fail to bind. + +import QtQuick +import qs.config + +FocusScope { + id: root + + signal captured(string chord) + signal cancelled + + property string message: "" + + implicitWidth: 230 + implicitHeight: 30 + + // Qt key codes to the keysym names Hyprland expects. Letters and digits + // fall out of the ASCII range; these are the rest that come up in practice. + readonly property var namedKeys: ({ + 0x01000000: "Escape", + 0x01000001: "Tab", + 0x01000004: "Return", + 0x01000005: "Return", + 0x01000003: "BackSpace", + 0x01000006: "Insert", + 0x01000007: "Delete", + 0x01000010: "Home", + 0x01000011: "End", + 0x01000016: "Page_Up", + 0x01000017: "Page_Down", + 0x01000012: "left", + 0x01000013: "up", + 0x01000014: "right", + 0x01000015: "down", + 0x20: "space", + 0x2c: "comma", + 0x2e: "period", + 0x2f: "slash", + 0x3b: "semicolon", + 0x27: "apostrophe", + 0x5b: "bracketleft", + 0x5d: "bracketright", + 0x5c: "backslash", + 0x60: "grave", + 0x2d: "minus", + 0x3d: "equal", + 0x01000009: "Print" + }) + + function keysymFor(key: int): string { + if (key >= 0x41 && key <= 0x5a) // A-Z + return String.fromCharCode(key); + if (key >= 0x30 && key <= 0x39) // 0-9 + return String.fromCharCode(key); + if (key >= 0x01000030 && key <= 0x0100003b) // F1-F12 + return "F" + (key - 0x01000030 + 1); + return root.namedKeys[key] ?? ""; + } + + function isModifierOnly(key: int): bool { + return key === 0x01000020 || key === 0x01000021 || key === 0x01000022 + || key === 0x01000023 || key === 0x01000024; + } + + Rectangle { + anchors.fill: parent + radius: 9 + color: Theme.alpha(Theme.accent, 0.14) + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.5) + + Text { + anchors.centerIn: parent + width: parent.width - 16 + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + text: root.message !== "" ? root.message : "Press a shortcut… Esc to cancel" + color: root.message !== "" ? Theme.warn : Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + Keys.onPressed: event => { + event.accepted = true; + + if (event.key === 0x01000000) { // Escape + root.cancelled(); + return; + } + if (root.isModifierOnly(event.key)) + return; + + const keysym = root.keysymFor(event.key); + if (keysym === "") { + root.message = "That key cannot be used"; + return; + } + + const parts = []; + if (event.modifiers & Qt.MetaModifier) parts.push("SUPER"); + if (event.modifiers & Qt.ControlModifier) parts.push("CTRL"); + if (event.modifiers & Qt.AltModifier) parts.push("ALT"); + if (event.modifiers & Qt.ShiftModifier) parts.push("SHIFT"); + + if (parts.length === 0 && keysym.length === 1) { + // A bare letter or digit would swallow ordinary typing. + root.message = "Add a modifier"; + return; + } + + parts.push(keysym); + root.captured(parts.join(" + ")); + } + + onActiveFocusChanged: { + if (!activeFocus) + root.cancelled(); + } +} diff --git a/config/dot/quickshell/modules/settings/ShortcutsPage.qml b/config/dot/quickshell/modules/settings/ShortcutsPage.qml index 3a00e70..ddfcfa8 100644 --- a/config/dot/quickshell/modules/settings/ShortcutsPage.qml +++ b/config/dot/quickshell/modules/settings/ShortcutsPage.qml @@ -17,6 +17,11 @@ import qs.services SettingsPage { id: root + // The chord of the bind currently being re-recorded, in the Lua form; empty + // when nothing is being captured. Held here rather than per row so that + // starting a new capture cancels any other. + property string capturingChord: "" + title: "Input & Shortcuts" lede: "The Forge mental model, carried forward into native tiling." @@ -72,19 +77,92 @@ SettingsPage { model: groupCard.modelData.binds - TextRow { + SettingRow { + id: bindRow + required property var modelData required property int index - label: modelData.description - value: modelData.chord - controlWidth: 230 - divider: index < bindRows.count - 1 + readonly property bool capturing: root.capturingChord === bindRow.modelData.luaChord + readonly property bool overridden: Keybinds.isOverridden(bindRow.modelData.luaChord) + + label: bindRow.modelData.description + detail: bindRow.overridden + ? "Moved from " + Keybinds.shippedChordFor(bindRow.modelData.luaChord) + : "" + controlWidth: 300 + divider: bindRow.index < bindRows.count - 1 + + Item { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 300 + height: 30 + + ShortcutCapture { + anchors.right: parent.right + width: 230 + height: 30 + visible: bindRow.capturing + focus: bindRow.capturing + onCaptured: chord => { + Keybinds.rebind(bindRow.modelData.luaChord, chord); + root.capturingChord = ""; + } + onCancelled: root.capturingChord = "" + } + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: !bindRow.capturing + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: bindRow.modelData.chord + color: bindRow.overridden ? Theme.accent : Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: "Change" + enabled: !Keybinds.reloading && !bindRow.modelData.mouse + onClicked: root.capturingChord = bindRow.modelData.luaChord + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: bindRow.overridden + text: "Reset" + enabled: !Keybinds.reloading + onClicked: Keybinds.resetBind(bindRow.modelData.luaChord) + } + } + } } } } } + SettingsCard { + visible: Object.keys(Keybinds.overrides).length > 0 + title: "Changed shortcuts" + subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from Panama's configuration." + + ActionRow { + label: "Restore every shipped shortcut" + detail: Object.keys(Keybinds.overrides).length + + (Object.keys(Keybinds.overrides).length === 1 ? " shortcut moved" : " shortcuts moved") + action: "Restore all" + divider: false + enabled: !Keybinds.reloading + onTriggered: Keybinds.resetAll() + } + } + SettingsCard { visible: Keybinds.lastError !== "" title: "Shortcuts unavailable" diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 04c7a18..1305096 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -34,3 +34,4 @@ WallpaperPicker 1.0 WallpaperPicker.qml ApplicationsPage 1.0 ApplicationsPage.qml DockPinsEditor 1.0 DockPinsEditor.qml DockAppPicker 1.0 DockAppPicker.qml +ShortcutCapture 1.0 ShortcutCapture.qml diff --git a/config/dot/quickshell/services/Keybinds.qml b/config/dot/quickshell/services/Keybinds.qml index 15ce4ab..59a7c85 100644 --- a/config/dot/quickshell/services/Keybinds.qml +++ b/config/dot/quickshell/services/Keybinds.qml @@ -16,6 +16,7 @@ pragma Singleton import Quickshell import Quickshell.Io import QtQuick +import qs.config Singleton { id: root @@ -65,6 +66,126 @@ Singleton { } } + // ── Rebinding ─────────────────────────────────────────────────────────── + // Overrides map a SHIPPED chord to a replacement. hypr/keybinds.lua reads + // them and substitutes only the chord -- the action is always the Lua value + // written in that file -- so an override can move a shortcut but can never + // make one do something else. + // + // Applying needs `hyprctl reload` rather than a live `hl.bind`: Hyprland + // reports Lua-defined binds with dispatcher "__lua" and a bytecode offset, + // so the action cannot be reconstructed from the outside to re-bind it. + // Reload re-runs the config, which re-reads the settings file. + readonly property var overrides: { + const stored = DesktopPreferences.get("keybindOverrides"); + return (stored && typeof stored === "object") ? stored : ({}); + } + + property bool reloading: false + + // The chord a bind ships with, given the chord it currently answers to. + // Displayed binds come from the compositor and so already reflect any + // override; the override map is what tells us where they started. + function shippedChordFor(currentChord: string): string { + for (const shipped in root.overrides) { + if (root.overrides[shipped] === currentChord) + return shipped; + } + return currentChord; + } + + function isOverridden(currentChord: string): bool { + return root.shippedChordFor(currentChord) !== currentChord; + } + + // Refuses a chord already answering to something else, so rebinding cannot + // quietly shadow an existing shortcut. + function conflictFor(chord: string, exceptCurrent: string): string { + for (const bind of root.binds) { + if (bind.luaChord === chord && bind.luaChord !== exceptCurrent) + return bind.description; + } + return ""; + } + + function rebind(currentChord: string, newChord: string): bool { + if (newChord === "" || newChord === currentChord) + return false; + + const conflict = root.conflictFor(newChord, currentChord); + if (conflict !== "") { + root.lastError = `${newChord} is already ${conflict}.`; + return false; + } + + const shipped = root.shippedChordFor(currentChord); + const next = Object.assign({}, root.overrides); + if (newChord === shipped) + delete next[shipped]; + else + next[shipped] = newChord; + + if (!DesktopPreferences.set("keybindOverrides", next)) { + root.lastError = "That shortcut could not be saved."; + return false; + } + root.applyReload(); + return true; + } + + function resetBind(currentChord: string): void { + const shipped = root.shippedChordFor(currentChord); + if (shipped === currentChord) + return; + const next = Object.assign({}, root.overrides); + delete next[shipped]; + DesktopPreferences.set("keybindOverrides", next); + root.applyReload(); + } + + function resetAll(): void { + if (Object.keys(root.overrides).length === 0) + return; + DesktopPreferences.set("keybindOverrides", ({})); + root.applyReload(); + } + + Process { + id: reloadRun + command: ["hyprctl", "reload"] + onExited: (exitCode, exitStatus) => { + root.reloading = false; + if (exitCode !== 0) { + root.lastError = "The compositor did not reload."; + return; + } + root.lastError = ""; + // The settings file is written on a timer, so re-read the keymap + // once the reload has had a moment to pick it up. + settle.restart(); + } + } + + Timer { + id: settle + interval: 350 + onTriggered: root.refresh() + } + + function applyReload(): void { + if (reloadRun.running) + return; + root.reloading = true; + // Give DesktopPreferences' coalescing write a moment to land first. + reloadDelay.restart(); + } + + Timer { + id: reloadDelay + interval: 120 + onTriggered: reloadRun.running = true + } + Component.onCompleted: root.refresh() function refresh(): void { @@ -89,6 +210,11 @@ Singleton { continue; out.push({ chord: root.formatChord(bind), + // The same chord in the form hypr/keybinds.lua writes, which + // is what an override is keyed by. The display form + // prettifies modifiers and arrow keys and so cannot be used + // for that. + luaChord: root.luaChord(bind), description: description, group: root.groupFor(description, bind), mouse: bind.mouse === true, @@ -104,6 +230,18 @@ Singleton { } } + // "SUPER + SHIFT + K" -- uppercase modifiers in the order keybinds.lua + // writes them, then the raw keysym rather than its display name. + function luaChord(bind: var): string { + const parts = []; + for (const modifier of root.modifierBits) { + if ((bind.modmask & modifier.bit) !== 0) + parts.push(modifier.name.toUpperCase()); + } + parts.push(String(bind.key ?? "")); + return parts.join(" + "); + } + function formatChord(bind: var): string { const parts = []; for (const modifier of root.modifierBits) { diff --git a/tests/quickshell/keybind-rebind-contract.sh b/tests/quickshell/keybind-rebind-contract.sh new file mode 100755 index 0000000..d68aad4 --- /dev/null +++ b/tests/quickshell/keybind-rebind-contract.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash + +# Rebinding a keyboard shortcut. +# +# This is the highest-consequence write in the settings app: a mistake here +# costs the user their keymap, and the keymap is how they reach everything else. +# The properties that matter: +# +# * an override moves exactly the bind it names and nothing else -- keying by +# description moved every bind sharing one, which silently cost the +# XF86Calculator hardware key when SUPER+C was rebound; +# * only the chord is ever stored, never the action; +# * a chord already in use is refused rather than shadowing the existing bind; +# * resetting returns the exact shipped keymap. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml" +config_home="$(mktemp -d /tmp/panama-rebind-config.XXXXXX)" + +fail() { + printf 'keybind rebind contract: %s\n' "$1" >&2 + exit 1 +} + +# The compositor is the live one -- that is the point -- but preferences are +# isolated so this cannot leave an override in the user's real settings. +# hyprctl reload re-reads the real settings file, so the compositor is only +# exercised through the shipped configuration here; the override logic itself is +# what is under test. +qs_for_harness() { + XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@" +} + +cleanup() { + qs_for_harness ipc call keybinds-test resetAll >/dev/null 2>&1 || true + qs_for_harness kill >/dev/null 2>&1 || true + rm -rf "$config_home" +} +trap cleanup EXIT + +XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null +for _ in $(seq 1 40); do + qs_for_harness ipc show 2>/dev/null | rg -q '^target keybinds-test$' && break + sleep 0.1 +done +qs_for_harness ipc show 2>/dev/null | rg -q '^target keybinds-test$' || fail 'test IPC target did not start' + +for _ in $(seq 1 40); do + [[ "$(qs_for_harness ipc call keybinds-test status | jq -r .loaded)" == "true" ]] && break + sleep 0.1 +done + +# ── Nothing is overridden to begin with ────────────────────────────────────── +[[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \ + || fail 'the isolated store started with overrides' + +# ── A chord already in use is refused ──────────────────────────────────────── +terminal="$(qs_for_harness ipc call keybinds-test chordFor Terminal)" +[[ -n "$terminal" ]] || fail 'could not find the Terminal bind' +files="$(qs_for_harness ipc call keybinds-test chordFor Files)" +[[ -n "$files" ]] || fail 'could not find the Files bind' + +[[ "$(qs_for_harness ipc call keybinds-test rebind "$terminal" "$files")" == "false" ]] \ + || fail 'rebinding onto a chord already in use was accepted' +[[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \ + || fail 'a refused rebind still stored an override' + +# ── A rebind stores only the chord, keyed by the shipped chord ─────────────── +[[ "$(qs_for_harness ipc call keybinds-test rebind "$terminal" "SUPER + SHIFT + F9")" == "true" ]] \ + || fail 'a valid rebind was refused' + +state="$(qs_for_harness ipc call keybinds-test overrideState)" +jq -e --arg k "$terminal" '.overrides[$k] == "SUPER + SHIFT + F9"' <<<"$state" >/dev/null \ + || fail "the override was not keyed by the shipped chord: $state" +jq -e '.count == 1' <<<"$state" >/dev/null || fail "exactly one override expected: $state" + +# Only a chord is stored. Nothing resembling an action or command may appear, +# because that is what keeps a user-editable file from being executable. +jq -e '[.overrides[]] | all(type == "string" and (length < 64))' <<<"$state" >/dev/null \ + || fail 'an override value is not a plain chord' + +# ── Reset clears it ────────────────────────────────────────────────────────── +qs_for_harness ipc call keybinds-test resetAll >/dev/null +sleep 0.5 +[[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \ + || fail 'resetAll left overrides behind' + +trap - EXIT +cleanup +printf 'keybind rebind contract: PASS\n'