-- ─────────────────────────────────────────────────────────────────────────────
-- Keybindings
--
-- Ported 1:1 from the GNOME + Forge setup this replaces. Where Hyprland has no
-- equivalent, the deviation is called out in a comment rather than silently
-- dropped.
--
-- The mental model, unchanged from Forge:
--   SUPER            -> act on windows (focus / move / swap / resize)
--   ALT              -> act on workspaces
--   SUPER + CTRL     -> change layout structure (split, float, swap)
-- ─────────────────────────────────────────────────────────────────────────────

local prefs = require("prefs")

local mod = "SUPER"

-- Programs, matched to the GNOME custom keybindings and media-key settings.
local terminal    = "kitty"
local editor      = "kitty nvim ."
local browser     = "helium-browser-bin"
local files       = "nautilus --new-window"
local calculator  = "gnome-calculator"
local mail        = "flatpak run org.mozilla.thunderbird_esr"
-- Panama owns the Hyprland and shell controls. GNOME Settings remains installed
-- and searchable in Vicinae for its hardware/account panels.
local settings    = "qs ipc call settings toggle"
local sysmonitor  = "flatpak run io.missioncenter.MissionCenter"

-- Vicinae is the Raycast-style launcher. `vicinae toggle` shows/hides the
-- window against the already-running server (started in autostart.lua).
local launcher    = "vicinae toggle"
local osd = function(action)
    return "$HOME/.config/quickshell/scripts/panama-osd " .. action
end

local lid = function(action)
    return "$HOME/.config/quickshell/scripts/panama-lid " .. action
end

local dictate = function(action)
    return "$HOME/.config/quickshell/scripts/panama-dictate " .. action
end

-- Quickshell IPC targets. See quickshell/shell.qml for the handlers.
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end

-- ── Applications ────────────────────────────────────────────────────────────
-- ── User rebinding ──────────────────────────────────────────────────────────
-- Every bind below goes through `bind` rather than `hl.bind` directly, so a
-- chord can be replaced from Panama Settings without this file changing.
--
-- Overrides are keyed by the bind's SHIPPED chord, and ONLY the chord is taken
-- from settings -- the action is always the Lua value written here. A stored
-- override can therefore move a shortcut but can never make one do something
-- else, which is the property that makes reading them from a JSON file the
-- user can edit safe.
--
-- Keyed by chord rather than by description because chords are unique and
-- descriptions are not: "Calculator" is both SUPER+C and the XF86Calculator
-- hardware key, and keying by description moved both of them onto the same new
-- chord, silently costing the hardware key.
--
-- An override whose chord is not a plausible chord is ignored and the shipped
-- one is used, so a hand-edited settings file cannot cost you a keymap.

local overrides = prefs.get("keybindOverrides", {})

local function valid_chord(chord)
    if type(chord) ~= "string" or chord == "" or #chord > 64 then
        return false
    end
    -- "SUPER + SHIFT + K", "XF86AudioPlay", "Print", "SUPER + mouse:272"
    return chord:match("^[%w_+%s:]+$") ~= nil
end

-- ── Categories ──────────────────────────────────────────────────────────────
-- What a bind is FOR, as opposed to what it does.
--
-- The cheatsheet groups by this, and the Shortcuts settings page uses it too.
-- It is recorded here rather than guessed from the description, which is what
-- Keybinds.qml used to do: matching substrings put "Close window" and "Close
-- the notification list" in the same group and left anything phrased unusually
-- in whichever bucket matched first.
--
-- The sections of this file already ARE the categories, so a section sets one
-- and every bind below it inherits it. That keeps the annotation to one line
-- per section instead of one per bind, and makes the grouping impossible to
-- forget: a new bind lands in the category of the section it was written in.
local categories = {}
local current_category = "Other"

local function category(name)
    current_category = name
end

local function bind(chord, action, opts)
    local override = overrides[chord]
    if valid_chord(override) then
        chord = override
    end
    -- Keyed by the chord actually bound, so the shell can join on what
    -- hyprctl reports without having to know about overrides.
    categories[chord] = current_category
    return hl.bind(chord, action, opts)
end

-- Written where the shell can read it. Hyprland reports a Lua bind's
-- dispatcher as `__lua` with a bytecode offset, so there is no way to attach
-- anything to a bind that survives into `hyprctl binds` -- the manifest is
-- how this side of the desktop tells the other what these binds are for.
--
-- Never raises. A read-only or missing state directory costs the categories,
-- which the shell falls back from, and must never cost the keymap.
local function write_categories()
    local state_home = os.getenv("XDG_STATE_HOME")
    if state_home == nil or state_home == "" then
        local home = os.getenv("HOME")
        if home == nil or home == "" then
            return
        end
        state_home = home .. "/.local/state"
    end

    local parts = {}
    for chord, name in pairs(categories) do
        -- Chords and category names are both from this file, so the only
        -- escaping that can matter is the quote character itself.
        parts[#parts + 1] = string.format('%q:%q', chord, name)
    end
    table.sort(parts)

    local path = state_home .. "/panama/keybind-categories.json"
    os.execute("mkdir -p " .. string.format("%q", state_home .. "/panama"))
    local file = io.open(path, "w")
    if file == nil then
        return
    end
    file:write("{" .. table.concat(parts, ",") .. "}\n")
    file:close()
end

-- SUPER opens a new one. SUPER+ALT goes to the one you already have.
--
-- That order matters and was chosen deliberately after trying the reverse.
-- Making the plain key focus an existing window reads well in a demo and is
-- what macOS does, but it makes "give me another terminal" the awkward case --
-- and on a tiling desktop, opening a second terminal beside the first is not
-- an edge case, it is the normal way to work. So the plain key keeps doing
-- what it has always done, and the modifier is the new capability rather than
-- a tax on the old one.
--
-- ALT rather than SHIFT because SUPER+SHIFT is already the window-manipulation
-- space: Files, Neovim and Settings would have collided with Focus session,
-- Taller and Shorter, and breaking two keys out of the eight-key resize set to
-- make room is a worse trade than borrowing a modifier.
--
-- The go-to binds still launch when nothing is open. A key that silently does
-- nothing is worse than one that does the obvious thing.
--
-- Patterns are regular expressions and are anchored. An unanchored "mail"
-- would match gmail-notifier, and the go-to-mail key would raise somebody's
-- notifier instead. Single-quoted for the shell so a backslash reaches the
-- matcher rather than being eaten on the way.
local function shell_quote(value)
    return "'" .. value:gsub("'", "'\\''") .. "'"
end

local launcher_bin = "$HOME/.local/share/Panama/bin/panama-launch"

local function go_to(class, command, title)
    local parts = { launcher_bin, "--class", shell_quote(class) }
    if title then
        parts[#parts + 1] = "--title"
        parts[#parts + 1] = shell_quote(title)
    end
    parts[#parts + 1] = "--"
    parts[#parts + 1] = command
    return table.concat(parts, " ")
end

category("Applications")
bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })

-- Go to the one already open, or start it if there is none.
bind(mod .. " + ALT + T", hl.dsp.exec_cmd(go_to("^kitty$", terminal)),
    { description = "Go to terminal" })
bind(mod .. " + ALT + N", hl.dsp.exec_cmd(go_to("^kitty$", editor, "nvim")),
    { description = "Go to Neovim" })
bind(mod .. " + ALT + W", hl.dsp.exec_cmd(go_to("^helium", browser)),
    { description = "Go to browser" })
bind(mod .. " + ALT + F", hl.dsp.exec_cmd(go_to("^org\\.gnome\\.Nautilus$", files)),
    { description = "Go to files" })
bind(mod .. " + ALT + C", hl.dsp.exec_cmd(go_to("^org\\.gnome\\.Calculator$", calculator)),
    { description = "Go to calculator" })
bind(mod .. " + ALT + E", hl.dsp.exec_cmd(go_to("^org\\.mozilla\\.thunderbird", mail)),
    { description = "Go to mail" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })

-- ── Launcher ────────────────────────────────────────────────────────────────
category("Applications")
-- All three keys open the same launcher, on purpose: SUPER+A and SUPER+R were
-- the GNOME app-grid and run-dialog shortcuts, and SUPER+SPACE is here as a
-- third option to settle on. Vicinae covers apps, calculator, files, clipboard,
-- emoji and window switching, so a separate run dialog and app grid are gone.
bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" })

-- Emergency fallback launcher. Vicinae runs as a systemd user service and the
-- bar is Quickshell; if either fails to come up, this is how you start an
-- application without dropping to a TTY. Depends on nothing but wofi itself.
bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" })

-- Every shortcut, on one key. Slash because "what are the keys" is a question,
-- and because it is the one punctuation key no other bind wants.
bind(mod .. " + slash", hl.dsp.exec_cmd(qs("cheatsheet", "toggle")), { description = "Keyboard shortcuts" })

-- Clipboard history and emoji, straight into the relevant launcher view.
-- Deeplink form is the one from vicinae's own Hyprland quickstart.
bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"),
    { description = "Clipboard history" })
bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"),
    { description = "Emoji picker" })

-- ── Shell surfaces (Quickshell) ─────────────────────────────────────────────
category("Shell")
-- SUPER+S was GNOME's quick settings; kept.
bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" })

-- Start a 45-minute focus session on the current workspace, or reveal its
-- Signal Glass controls if one is already running.
bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { description = "Focus session" })

-- Workspace overview. GNOME put this on a bare SUPER tap; tap-detection on a
-- modifier misfires when you're fast with SUPER+key combos, so it lives on a
-- real chord instead. SUPER+grave was Forge's "cycle windows of same app",
-- which Hyprland has no equivalent for.
bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })

-- Notification center.
bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })

-- Screenshot / screen record. One key, then pick screen / window / region and
-- whether to capture or record -- reproducing GNOME's Print-screen UI.
bind("Print", hl.dsp.exec_cmd(qs("capture", "open")), { description = "Screenshot / record" })
-- The GNOME direct-capture variants, kept as shortcuts past the picker.
bind("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" })
bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description = "Screenshot: window" })

-- Local OCR and QR/barcode recognition through the same region picker. This
-- opens directly in Selection + Read mode; Print still exposes every mode.
bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
    { description = "Screen Intelligence" })

-- Color picker: copies the hex under the cursor to the clipboard.
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Color picker" })

-- ── Window management ───────────────────────────────────────────────────────
category("Windows")
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" })

-- Forge: window-toggle-float / window-toggle-always-float.
-- "Always float" has no Hyprland equivalent (it wrote a persistent rule); pin
-- is the nearest useful thing -- the window floats above every workspace.
bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" })
bind(mod .. " + CTRL + SHIFT + C", hl.dsp.window.pin({ action = "toggle" }), { description = "Pin window" })

-- Forge: con-split-layout-toggle / con-split-horizontal / con-split-vertical.
bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" })
bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" })
bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" })

-- Forge: window-shrink / window-expand / window-reset-sizes.
bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" })
bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" })
bind(mod .. " + equal", hl.dsp.layout("splitratio exact 0.5"), { description = "Reset split" })

-- Focus (Forge: window-focus-*). Both vim keys and arrows, as in Forge.
bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
bind(mod .. " + left", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
bind(mod .. " + down", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
bind(mod .. " + up", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
bind(mod .. " + right", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })

-- Move (Forge: window-move-*).
bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" })
bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" })
bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" })
bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" })

-- Swap (Forge: window-swap-*).
bind(mod .. " + CTRL + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" })
bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" })
bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" })
bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { description = "Swap right" })

-- Resize (Forge: window-resize-<edge>-<increase|decrease>).
--
-- Forge resized one named EDGE at a time: its resize() grows the window for a
-- positive amount in every direction, and the edge only decides which side
-- moves -- Y grew leftward, O grew rightward, and so on. Hyprland resizes along
-- an axis and lets the layout choose the border, so those eight distinct
-- behaviours collapse onto four and the direction is simply not expressible.
--
-- Because of that the sizes are deliberately INVERTED from Forge's naming.
-- Carried over faithfully, "increase" grew and "decrease" shrank, which was
-- correct on paper and wrong under the fingers: with the edge gone, the keys
-- that used to pull a window open from one side now push it from the other.
-- Gabriel uses these daily and reads Y/O as shrink and B/M as grow, so that is
-- what they do. Faithfulness to a mapping nobody can feel is not worth much.
local step = 60
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
-- SUPER+SHIFT+P was double-bound with the color picker above; moved to
-- Comma, which continues the bottom-row cluster (B/M/N) this axis already
-- uses rather than landing on an arbitrary free key.
bind(mod .. " + SHIFT + Comma", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })

-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
-- showing what you are choosing between.
--
-- The gesture needs three binds, not two. Tab steps the selection, and the
-- switch is only COMMITTED when the modifier is released -- which is the sole
-- way the compositor can tell the gesture is finished. That release bind is on
-- the bare modifier, so it fires on EVERY Super release in the session; the
-- handler returns immediately when no switch is open, which is why this is
-- affordable.
--
-- The release bind carries no description on purpose: it is not a shortcut
-- anyone would look up or rebind, and the Shortcuts page lists what it finds.
bind(mod .. " + Tab", hl.dsp.exec_cmd(qs("switcher", "next")), { description = "Next window" })
bind(mod .. " + SHIFT + Tab", hl.dsp.exec_cmd(qs("switcher", "previous")), { description = "Previous window" })
bind(mod, hl.dsp.exec_cmd(qs("switcher", "commit")), { release = true, description = "Commit window switch" })
-- Jump back to the previously focused window.
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })

-- Mouse: drag to move, right-drag to resize.
bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window with pointer" })
bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with pointer" })

-- ── Workspaces ──────────────────────────────────────────────────────────────
category("Workspaces")
-- ALT is the workspace modifier, matching the GNOME setup.
--
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
-- moving right past the last workspace creates a new one, and moving left from
-- the first clamps instead of wrapping.

-- Behavior for the relative/cyclic binds below. These are Hyprland's own
-- `binds:` options -- not part of general/dwindle -- and have no other home
-- in the config, so they are read here rather than in looks.lua.
hl.config({
    binds = {
        workspace_back_and_forth = prefs.get("workspaceBackAndForth", false),
        allow_workspace_cycles   = prefs.get("allowWorkspaceCycles", false),
    },
})

bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
bind("ALT + SHIFT + L", hl.dsp.window.move({ workspace = "+1" }), { description = "Move window to workspace right" })

-- GNOME also had these on CTRL+ALT+Up/Down.
bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })

-- Direct jump. ALT+0 is workspace 10.
for i = 1, 10 do
    local key = i % 10
    bind("ALT + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i })
    bind("ALT + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }), { description = "Move window to workspace " .. i })
end

-- Scroll the mouse wheel over the desktop with SUPER held to change workspace.
-- (Scrolling the workspace indicator in the bar does the same; that's handled
-- in quickshell/modules/bar/Workspaces.qml.)
bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })

-- Minimize, as far as Hyprland has one.
--
-- Hyprland has no minimize: it receives the request (the binary has
-- setSetMinimized handlers for xdg, XWayland and foreign-toplevel) but exposes
-- no dispatcher, no config option and not even an event to hook, so titlebar
-- minimize buttons are inert and cannot be made to work. A tiling WM has no
-- iconified state and no taskbar to restore from.
--
-- The scratchpad is the honest equivalent: the window goes away, and the same
-- key brings it back. Bound to X to match the muscle memory it replaces.
bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimized)" })
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimize to scratchpad" })

-- ── Session ─────────────────────────────────────────────────────────────────
category("Session")
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
-- CTRL+ALT+L -- the other binding most people already have in muscle memory.
bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
bind("SUPER + Backspace", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" })

-- ── Media and volume ────────────────────────────────────────────────────────
category("Media & hardware")
-- locked = true keeps these working on the lock screen, as they do in GNOME.
-- 6% steps match the GNOME volume-step setting.
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 6")), { locked = true, repeating = true , description = "Volume up" })
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = true, repeating = true , description = "Volume down" })
bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" })
bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" })

-- ── Dictation ───────────────────────────────────────────────────────────────
category("Shell")
--
-- Hold to talk, exactly like push-to-talk anywhere else: the mic is open only
-- while the key is down, so it cannot be left listening by forgetting about it.
-- Two binds on one chord, the second flagged `release`.
--
-- No `repeating`: holding a key normally repeats the press, which would restart
-- the recording several times a second. The daemon refuses a second start while
-- one is running, so a repeat would be harmless -- but not asking for it is
-- better than relying on being refused.
bind(mod .. " + D", hl.dsp.exec_cmd(dictate("start")),
    { description = "Dictate (hold to talk)" })
bind(mod .. " + D", hl.dsp.exec_cmd(dictate("stop")),
    { release = true, description = "Dictate (transcribe on release)" })

-- Escape out of a recording without transcribing it. Bound to the same modifier
-- so it can be reached with the dictation key still held.
bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd(dictate("cancel")),
    { description = "Cancel dictation" })

-- Back to media: the dictation binds sit here for historical reasons, and the
-- category has to be set again or everything below inherits theirs.
category("Media & hardware")

-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" })
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" })

bind("XF86AudioPlay", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
bind("XF86AudioPause", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
bind("XF86AudioNext", hl.dsp.exec_cmd(osd("media next")), { locked = true , description = "Next track" })
bind("XF86AudioPrev", hl.dsp.exec_cmd(osd("media previous")), { locked = true , description = "Previous track" })
bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , description = "Stop playback" })

bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" })
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" })

-- Airplane mode. The Framework's F10 emits exactly this keysym, and for a
-- while it emitted it into silence. panama-osd owns the toggle so the OSD
-- can say which way it went.
bind("XF86RFKill", hl.dsp.exec_cmd(osd("airplane toggle")), { locked = true, description = "Airplane mode" })

-- F9 on the same row. GNOME shows a display-switching OSD here; until
-- mirroring exists (DESKTOP-PARITY gap), the honest action is the page where
-- displays are actually arranged.
bind("XF86Display", hl.dsp.exec_cmd("qs ipc call settings page displays"), { description = "Display settings" })

-- The power button. logind is told to ignore it (config/copy ships the
-- drop-in) so a stray press is a question, not an instant poweroff -- the
-- menu this opens is the question. Until the next boot after that drop-in
-- lands, logind still acts on the key; this bind costs nothing extra then.
bind("XF86PowerOff", hl.dsp.exec_cmd("qs ipc call powermenu toggle"), { locked = true, description = "Power menu" })

-- The lid, as a switch rather than a key. Closing a docked lid turns the
-- internal panel off so nothing renders inside a closed shell and no
-- workspace strands on an invisible output; opening it turns the panel back
-- on. panama-lid owns the decision -- undocked machines suspend via logind
-- before this matters, and the guard's inhibitor handles staying awake.
bind("switch:on:Lid Switch", hl.dsp.exec_cmd(lid("close")), { locked = true, description = "Lid closed" })
bind("switch:off:Lid Switch", hl.dsp.exec_cmd(lid("open")), { locked = true, description = "Lid opened" })

category("Applications")
-- Hardware keys GNOME mapped that have obvious equivalents.
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("XF86Calculator", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind("XF86Explorer", hl.dsp.exec_cmd(files), { description = "Files" })
bind("XF86WWW", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })

write_categories()

return true
