diff --git a/hypr/.config/hypr/modules/keybind_registry.lua b/hypr/.config/hypr/modules/keybind_registry.lua new file mode 100644 index 0000000..da8b5fa --- /dev/null +++ b/hypr/.config/hypr/modules/keybind_registry.lua @@ -0,0 +1,63 @@ +-- keybind_registry.lua — wrap hl.bind so every keybind records a runnable +-- action alongside its chord/description. +-- +-- Hyprland's Lua API registers binds through hl.bind(keys, dispatcher, opts), +-- and `hyprctl binds -j` reports those as `dispatcher: "__lua"` with an opaque +-- registry reference — the underlying command is not recoverable from the +-- compositor afterwards. This module captures the action at registration time: +-- +-- exec(cmd) -> a tagged placeholder for the exec_cmd dispatcher (the final +-- command string is resolved here, so concatenations like +-- "hyprshot ... " .. var.screenshot_folder work automatically) +-- bind(...) -> same signature/behavior as hl.bind; converts the exec +-- placeholder back into a real dispatcher and records the row +-- write(path)-> dump the registry as "keys\x1fdescription\x1fcmd" TSV so the +-- show-keybinds picker can execute the selected entry +-- +-- Pure dispatcher binds (hl.dsp.window.close(), hl.dsp.focus(...), ...) pass +-- through unchanged and are recorded with an empty cmd (informational only). + +local registry = {} + +local M = {} + +--- Tag a shell command so bind() knows to turn it into an exec_cmd dispatcher. +function M.exec(cmd) + return { __cmd = cmd } +end + +--- Register a keybind like hl.bind, but record it in the registry first. +--- @param keys string final key string (e.g. "SUPER + Q") +--- @param action any M.exec(cmd) or a real dispatcher from hl.dsp.* +--- @param opts? table hl.bind options (description, flags, ...) +function M.bind(keys, action, opts) + opts = opts or {} + local dsp = action + local cmd = "" + if type(action) == "table" and action.__cmd ~= nil then + cmd = action.__cmd + dsp = hl.dsp.exec_cmd(cmd) + end + table.insert(registry, { keys = keys, description = opts.description or "", cmd = cmd }) + return hl.bind(keys, dsp, opts) +end + +--- Dump the registry as "keys\x1fdescription\x1fcmd" lines (one per bind). +function M.write(path) + local f, err = io.open(path, "w") + if not f then + return nil, err + end + for _, b in ipairs(registry) do + f:write(b.keys, "\x1f", b.description, "\x1f", b.cmd, "\n") + end + f:close() + return registry +end + +--- Access the raw registry rows ({keys, description, cmd}). +function M.get() + return registry +end + +return M \ No newline at end of file diff --git a/hypr/.config/hypr/modules/keybinds.lua b/hypr/.config/hypr/modules/keybinds.lua index e2a7ec0..32432f2 100644 --- a/hypr/.config/hypr/modules/keybinds.lua +++ b/hypr/.config/hypr/modules/keybinds.lua @@ -1,135 +1,140 @@ local var = require("modules.variables") local command = require("modules.shell_commands") local env = require("env") +-- bind/exec wrap hl.bind so each keybind also records a runnable command for +-- the show-keybinds picker (see modules/keybind_registry.lua). +local reg = require("modules.keybind_registry") +local bind = reg.bind +local exec = reg.exec -- See https://wiki.hypr.land/Configuring/Keywords/ -- Example binds, see https://wiki.hypr.land/Configuring/Binds/ for more -hl.bind(var.mainMod .. " + RETURN", hl.dsp.exec_cmd(var.terminal), { description = "Open terminal (kitty)" }) -hl.bind(var.mainMod .. " + ALT + RETURN", hl.dsp.exec_cmd(var.terminal_tmux), { description = "Open terminal (kitty)" }) -hl.bind(var.mainMod .. " + Q", hl.dsp.window.close(), { description = "Close focused window" }) -hl.bind( +bind(var.mainMod .. " + RETURN", exec(var.terminal), { description = "Open terminal (kitty)" }) +bind(var.mainMod .. " + ALT + RETURN", exec(var.terminal_tmux), { description = "Open terminal (kitty)" }) +bind(var.mainMod .. " + Q", hl.dsp.window.close(), { description = "Close focused window" }) +bind( var.mainMod .. " + ESCAPE", - hl.dsp.exec_cmd(command.session), + exec(command.session), { description = "Session menu (logout/reboot/shutdown)" } ) -hl.bind(var.mainMod .. " + SHIFT + ESCAPE", hl.dsp.exec_cmd(command.logout), { description = "Exit Hyprland" }) -hl.bind(var.mainMod .. " + CTRL + ESCAPE", hl.dsp.exec_cmd(command.shutdown), { description = "Power off the system" }) -hl.bind(var.mainMod .. " + ALT + ESCAPE", hl.dsp.exec_cmd(command.reboot), { description = "Reboot the system" }) -hl.bind(var.mainMod .. " + E", hl.dsp.exec_cmd(var.fileManager), { description = "Open file manager (dolphin)" }) -hl.bind(var.mainMod .. " + V", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle floating window" }) -hl.bind(var.mainMod .. " + SPACE", hl.dsp.exec_cmd(command.menu), { description = "Open app launcher" }) -hl.bind(var.mainMod .. " + P", hl.dsp.window.pseudo(), { description = "Toggle pseudo-tiling" }) -hl.bind(var.mainMod .. " + SHIFT + J", hl.dsp.layout("togglesplit"), { description = "Toggle split layout" }) -hl.bind(var.mainMod .. " + I", hl.dsp.exec_cmd("firefox &"), { description = "Open Firefox" }) -hl.bind(var.mainMod .. " + n", hl.dsp.exec_cmd("nm-connection-editor"), { description = "Network manager" }) -hl.bind(var.mainMod .. " + comma", hl.dsp.exec_cmd(command.settings), { description = "settings menu" }) -hl.bind(var.mainMod .. " + S", hl.dsp.exec_cmd(command.control_center), { description = "Control center" }) +bind(var.mainMod .. " + SHIFT + ESCAPE", exec(command.logout), { description = "Exit Hyprland" }) +bind(var.mainMod .. " + CTRL + ESCAPE", exec(command.shutdown), { description = "Power off the system" }) +bind(var.mainMod .. " + ALT + ESCAPE", exec(command.reboot), { description = "Reboot the system" }) +bind(var.mainMod .. " + E", exec(var.fileManager), { description = "Open file manager (dolphin)" }) +bind(var.mainMod .. " + V", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle floating window" }) +bind(var.mainMod .. " + SPACE", exec(command.menu), { description = "Open app launcher" }) +bind(var.mainMod .. " + P", hl.dsp.window.pseudo(), { description = "Toggle pseudo-tiling" }) +bind(var.mainMod .. " + SHIFT + J", hl.dsp.layout("togglesplit"), { description = "Toggle split layout" }) +bind(var.mainMod .. " + I", exec("firefox &"), { description = "Open Firefox" }) +bind(var.mainMod .. " + n", exec("nm-connection-editor"), { description = "Network manager" }) +bind(var.mainMod .. " + comma", exec(command.settings), { description = "settings menu" }) +bind(var.mainMod .. " + S", exec(command.control_center), { description = "Control center" }) -- Move focus with mainMod or Vim-style -hl.bind(var.mainMod .. " + CTRL + h", hl.dsp.focus({ direction = "left" }), { description = "Focus window left" }) -hl.bind(var.mainMod .. " + CTRL + l", hl.dsp.focus({ direction = "right" }), { description = "Focus window right" }) -hl.bind(var.mainMod .. " + CTRL + k", hl.dsp.focus({ direction = "up" }), { description = "Focus window up" }) -hl.bind(var.mainMod .. " + CTRL + j", hl.dsp.focus({ direction = "down" }), { description = "Focus window down" }) +bind(var.mainMod .. " + CTRL + h", hl.dsp.focus({ direction = "left" }), { description = "Focus window left" }) +bind(var.mainMod .. " + CTRL + l", hl.dsp.focus({ direction = "right" }), { description = "Focus window right" }) +bind(var.mainMod .. " + CTRL + k", hl.dsp.focus({ direction = "up" }), { description = "Focus window up" }) +bind(var.mainMod .. " + CTRL + j", hl.dsp.focus({ direction = "down" }), { description = "Focus window down" }) -- Move windows with mainMod or Vim-style -hl.bind(var.mainMod .. " + SHIFT + h", hl.dsp.window.move({ direction = "left" }), { description = "Move window left" }) -hl.bind( +bind(var.mainMod .. " + SHIFT + h", hl.dsp.window.move({ direction = "left" }), { description = "Move window left" }) +bind( var.mainMod .. " + SHIFT + l", hl.dsp.window.move({ direction = "right" }), { description = "Move window right" } ) -hl.bind(var.mainMod .. " + SHIFT + k", hl.dsp.window.move({ direction = "up" }), { description = "Move window up" }) -hl.bind(var.mainMod .. " + SHIFT + j", hl.dsp.window.move({ direction = "down" }), { description = "Move window down" }) +bind(var.mainMod .. " + SHIFT + k", hl.dsp.window.move({ direction = "up" }), { description = "Move window up" }) +bind(var.mainMod .. " + SHIFT + j", hl.dsp.window.move({ direction = "down" }), { description = "Move window down" }) -- Resize focused window with mainMod + Arrows -hl.bind( +bind( var.mainMod .. " + left", hl.dsp.window.resize({ x = -10, y = 0, relative = true }), { description = "Resize window left" } ) -hl.bind( +bind( var.mainMod .. " + right", hl.dsp.window.resize({ x = 10, y = 0, relative = true }), { description = "Resize window right" } ) -hl.bind( +bind( var.mainMod .. " + up", hl.dsp.window.resize({ x = 0, y = -10, relative = true }), { description = "Resize window up" } ) -hl.bind( +bind( var.mainMod .. " + down", hl.dsp.window.resize({ x = 0, y = 10, relative = true }), { description = "Resize window down" } ) -- Switch workspaces with mainMod + [0-9] -hl.bind(var.mainMod .. " + 1", hl.dsp.focus({ workspace = 1 }), { description = "Switch to workspace 1" }) -hl.bind(var.mainMod .. " + 2", hl.dsp.focus({ workspace = 2 }), { description = "Switch to workspace 2" }) -hl.bind(var.mainMod .. " + 3", hl.dsp.focus({ workspace = 3 }), { description = "Switch to workspace 3" }) -hl.bind(var.mainMod .. " + 4", hl.dsp.focus({ workspace = 4 }), { description = "Switch to workspace 4" }) -hl.bind(var.mainMod .. " + 5", hl.dsp.focus({ workspace = 5 }), { description = "Switch to workspace 5" }) -hl.bind(var.mainMod .. " + 6", hl.dsp.focus({ workspace = 6 }), { description = "Switch to workspace 6" }) -hl.bind(var.mainMod .. " + 7", hl.dsp.focus({ workspace = 7 }), { description = "Switch to workspace 7" }) -hl.bind(var.mainMod .. " + 8", hl.dsp.focus({ workspace = 8 }), { description = "Switch to workspace 8" }) -hl.bind(var.mainMod .. " + 9", hl.dsp.focus({ workspace = 9 }), { description = "Switch to workspace 9" }) -hl.bind(var.mainMod .. " + 0", hl.dsp.focus({ workspace = 10 }), { description = "Switch to workspace 10" }) -hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+"), { +bind(var.mainMod .. " + 1", hl.dsp.focus({ workspace = 1 }), { description = "Switch to workspace 1" }) +bind(var.mainMod .. " + 2", hl.dsp.focus({ workspace = 2 }), { description = "Switch to workspace 2" }) +bind(var.mainMod .. " + 3", hl.dsp.focus({ workspace = 3 }), { description = "Switch to workspace 3" }) +bind(var.mainMod .. " + 4", hl.dsp.focus({ workspace = 4 }), { description = "Switch to workspace 4" }) +bind(var.mainMod .. " + 5", hl.dsp.focus({ workspace = 5 }), { description = "Switch to workspace 5" }) +bind(var.mainMod .. " + 6", hl.dsp.focus({ workspace = 6 }), { description = "Switch to workspace 6" }) +bind(var.mainMod .. " + 7", hl.dsp.focus({ workspace = 7 }), { description = "Switch to workspace 7" }) +bind(var.mainMod .. " + 8", hl.dsp.focus({ workspace = 8 }), { description = "Switch to workspace 8" }) +bind(var.mainMod .. " + 9", hl.dsp.focus({ workspace = 9 }), { description = "Switch to workspace 9" }) +bind(var.mainMod .. " + 0", hl.dsp.focus({ workspace = 10 }), { description = "Switch to workspace 10" }) +bind("XF86AudioRaiseVolume", exec("wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+"), { repeating = true, description = "Volume up", }) -hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { +bind("XF86AudioLowerVolume", exec("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { locked = true, description = "Volume down", }) -- Move active window to a workspace with mainMod + SHIFT + [0-9] -hl.bind( +bind( var.mainMod .. " + SHIFT + 1", hl.dsp.window.move({ workspace = 1 }), { description = "Move window to workspace 1" } ) -hl.bind( +bind( var.mainMod .. " + SHIFT + 2", hl.dsp.window.move({ workspace = 2 }), { description = "Move window to workspace 2" } ) -hl.bind( +bind( var.mainMod .. " + SHIFT + 3", hl.dsp.window.move({ workspace = 3 }), { description = "Move window to workspace 3" } ) -hl.bind( +bind( var.mainMod .. " + SHIFT + 4", hl.dsp.window.move({ workspace = 4 }), { description = "Move window to workspace 4" } ) -hl.bind( +bind( var.mainMod .. " + SHIFT + 5", hl.dsp.window.move({ workspace = 5 }), { description = "Move window to workspace 5" } ) -hl.bind( +bind( var.mainMod .. " + SHIFT + 6", hl.dsp.window.move({ workspace = 6 }), { description = "Move window to workspace 6" } ) -hl.bind( +bind( var.mainMod .. " + SHIFT + 7", hl.dsp.window.move({ workspace = 7 }), { description = "Move window to workspace 7" } ) -hl.bind( +bind( var.mainMod .. " + SHIFT + 8", hl.dsp.window.move({ workspace = 8 }), { description = "Move window to workspace 8" } ) -hl.bind( +bind( var.mainMod .. " + SHIFT + 9", hl.dsp.window.move({ workspace = 9 }), { description = "Move window to workspace 9" } ) -hl.bind( +bind( var.mainMod .. " + SHIFT + 0", hl.dsp.window.move({ workspace = 10 }), { description = "Move window to workspace 10" } @@ -142,100 +147,105 @@ hl.bind( -- bind = $mainMod SHIFT, S, movetoworkspace, special:magic -- Scroll through existing workspaces with mainMod + scroll -hl.bind( +bind( var.mainMod .. " + mouse_down", hl.dsp.focus({ workspace = "e+1" }), { description = "Next workspace (scroll down)" } ) -hl.bind( +bind( var.mainMod .. " + mouse_up", hl.dsp.focus({ workspace = "e-1" }), { description = "Previous workspace (scroll up)" } ) -- Move/resize windows with mainMod + LMB/RMB and dragging -hl.bind(var.mainMod .. " + mouse:272", hl.dsp.window.drag(), { +bind(var.mainMod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window with mouse", }) -hl.bind(var.mainMod .. " + mouse:273", hl.dsp.window.resize(), { +bind(var.mainMod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with mouse", }) -- Laptop multimedia keys for volume and LCD brightness -hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"), { +bind("XF86AudioRaiseVolume", exec("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"), { repeating = true, locked = true, description = "Volume up (repeat)", }) -hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { +bind("XF86AudioLowerVolume", exec("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { repeating = true, locked = true, description = "Volume down (repeat)", }) -hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { +bind("XF86AudioMute", exec("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { repeating = true, locked = true, description = "Mute audio", }) -hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { +bind("XF86AudioMicMute", exec("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { repeating = true, locked = true, description = "Mute microphone", }) -hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { +bind("XF86MonBrightnessUp", exec("brightnessctl -e4 -n2 set 5%+"), { repeating = true, locked = true, description = "Brightness up", }) -hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { +bind("XF86MonBrightnessDown", exec("brightnessctl -e4 -n2 set 5%-"), { repeating = true, locked = true, description = "Brightness down", }) -- Requires playerctl -hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { +bind("XF86AudioNext", exec("playerctl next"), { locked = true, description = "Next track", }) -hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { +bind("XF86AudioPause", exec("playerctl play-pause"), { locked = true, description = "Play/Pause", }) -hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { +bind("XF86AudioPlay", exec("playerctl play-pause"), { locked = true, description = "Play/Pause", }) -hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { +bind("XF86AudioPrev", exec("playerctl previous"), { locked = true, description = "Previous track", }) -- Screenshots -hl.bind("PRINT", hl.dsp.exec_cmd("hyprshot -m region --output-folder " .. var.screenshot_folder), { +bind("PRINT", exec("hyprshot -m region --output-folder " .. var.screenshot_folder), { locked = true, description = "Screenshot region", }) -hl.bind("CTRL + PRINT", hl.dsp.exec_cmd("hyprshot -m window --output-folder " .. var.screenshot_folder), { +bind("CTRL + PRINT", exec("hyprshot -m window --output-folder " .. var.screenshot_folder), { locked = true, description = "Screenshot window", }) -hl.bind("SHIFT + PRINT", hl.dsp.exec_cmd("hyprshot -m active --output-folder " .. var.screenshot_folder), { +bind("SHIFT + PRINT", exec("hyprshot -m active --output-folder " .. var.screenshot_folder), { locked = true, description = "Screenshot active monitor", }) -hl.bind(var.mainMod .. " + L", hl.dsp.exec_cmd(command.lock), { locked = true, description = "Lock screen" }) -hl.bind(var.mainMod .. " + SHIFT + L", hl.dsp.exec_cmd(command.screensaver), { description = "Start screensaver (any key quits)" }) -hl.bind(var.mainMod .. " + K", hl.dsp.exec_cmd(command.keybinds_show), { description = "Show keybind overview" }) -hl.bind(var.mainMod .. " + W", hl.dsp.exec_cmd(command.wallpaper_select), { description = "Wallpaper select" }) +bind(var.mainMod .. " + L", exec(command.lock), { locked = true, description = "Lock screen" }) +bind(var.mainMod .. " + SHIFT + L", exec(command.screensaver), { description = "Start screensaver (any key quits)" }) +bind(var.mainMod .. " + K", exec(command.keybinds_show), { description = "Show keybind overview" }) +bind(var.mainMod .. " + W", exec(command.wallpaper_select), { description = "Wallpaper select" }) -- Caelestia extras if env.shell == "caelestia" then - hl.bind(var.mainMod .. " + SHIFT + V", hl.dsp.exec_cmd(command.clipboard), { description = "Clipboard history" }) - hl.bind(var.mainMod .. " + period", hl.dsp.exec_cmd(command.emoji), { description = "Emoji picker" }) - hl.bind(var.mainMod .. " + SHIFT + K", hl.dsp.exec_cmd(command.show_all), { description = "Toggle utilities" }) - hl.bind("CTRL + ALT + C", hl.dsp.exec_cmd(command.clear_notifications), { description = "Clear notifications" }) + bind(var.mainMod .. " + SHIFT + V", exec(command.clipboard), { description = "Clipboard history" }) + bind(var.mainMod .. " + period", exec(command.emoji), { description = "Emoji picker" }) + bind(var.mainMod .. " + SHIFT + K", exec(command.show_all), { description = "Toggle utilities" }) + bind("CTRL + ALT + C", exec(command.clear_notifications), { description = "Clear notifications" }) end + +-- Persist the registry for the show-keybinds picker +-- (scripts/show-keybinds.sh reads this TSV to resolve selected commands). +-- Runs on every config load/reload, keeping the picker in sync with the binds. +reg.write("/tmp/hypr-keybinds.tsv") diff --git a/hypr/.config/hypr/scripts/show-keybinds.sh b/hypr/.config/hypr/scripts/show-keybinds.sh index ce1cf2e..1d8039a 100755 --- a/hypr/.config/hypr/scripts/show-keybinds.sh +++ b/hypr/.config/hypr/scripts/show-keybinds.sh @@ -1,94 +1,161 @@ #!/usr/bin/env bash -# show-keybinds.sh — floating keybind overview popup. +# show-keybinds.sh — fuzzy, executable keybind picker. # -# Reads the live keybind list from `hyprctl binds -j` and shows it in a -# floating kitty window (single column, sorted A-Z, searchable via less). -# Display-only: wofi/rofi are gone (the Noctalia launcher has no stdin-list -# mode), so picking a row to execute is not wired up. +# SUPER+K opens a floating kitty (class `keybinds`, see +# windows_and_workspaces.lua) running fzf over the keybind list: +# - start typing immediately: the fzf prompt is focused on open +# - arrow keys browse the (filtered) list +# - Enter executes the selected entry's command, then the picker closes +# - Esc closes without action # -# Note: this config uses the Lua API (hl.bind), so `hyprctl binds -j` reports -# key + modmask + an opaque Lua registry reference instead of a readable -# dispatcher. The human-readable action comes from the `description` option -# added to each hl.bind(...) call - keep those descriptions up to date when -# you change a bind. +# Display data is read live from `hyprctl binds -j`. The command behind each +# entry cannot come from hyprctl — Lua-registered binds report as opaque +# `__lua` dispatchers there — so modules/keybind_registry.lua records every +# command at bind time and writes /tmp/hypr-keybinds.tsv on config load +# ("keys\x1fdescription\x1fcmd" lines). Entries without a recorded command +# (pure dispatchers like window.close) are informational only: Enter closes. # -# Usage: show-keybinds.sh [--print] -# --print print the formatted list to stdout instead of opening kitty -# (handy for debugging / testing the formatting pipeline) +# Usage: show-keybinds.sh [--print | --internal-pick FILE] +# --print print the formatted list to stdout (debug/testing) +# --internal-pick F run fzf over picker file F and execute the selection +# (spawned by the default mode inside the kitty window) set -euo pipefail +readonly SEP=$'\x1f' # modmask bits reported by hyprctl (XKB modifiers), pairwise bit:name in # display order. See https://wiki.hypr.land/Configuring/Binds/ readonly MOD_ORDER=(64 SUPER 8 ALT 4 CTRL 1 SHIFT 2 CAPS 16 MOD2 32 MOD3 128 MOD5) +readonly SIDECAR=/tmp/hypr-keybinds.tsv -if ! hyprctl binds -j >/dev/null 2>&1; then - echo "hyprctl binds failed - are you inside a Hyprland session?" >&2 +# --internal-pick mode: runs INSIDE the floating kitty. fzf over the picker +# list (lines are "cmd\x1fdisplay"), then execute the chosen command and let +# the window close. +if [[ "${1:-}" == "--internal-pick" ]]; then + pick_file="${2:-}" + if [[ -z "$pick_file" || ! -s "$pick_file" ]]; then + echo "show-keybinds: --internal-pick needs an existing picker list file" >&2 + exit 1 + fi + # `|| rc=$?` keeps `set -e` from aborting before cleanup when fzf is + # cancelled (e.g. Esc) or filters to no match. + rc=0 + sel="$(fzf --delimiter "$SEP" --with-nth 2.. --cycle --info=inline \ + --prompt 'keybinds> ' --header 'type to filter · Enter executes · Esc closes' \ + < "$pick_file")" || rc=$? + rm -f "$pick_file" + if (( rc != 0 )) || [[ -z "$sel" ]]; then + exit 0 + fi + cmd="${sel%%"$SEP"*}" + if [[ -n "$cmd" ]]; then + # Detach into its own session so the command outlives the picker + # window (kitty/pty teardown would otherwise signal its process group). + setsid -f sh -c "$cmd" >/dev/null 2>&1 < /dev/null + fi + exit 0 +fi + +# --------------------------------------------------------------------------- +# Gather rows: prefer the sidecar (has commands); fall back to live `hyprctl +# binds -j` (display only, e.g. right after a fresh clone without a reload). +# --------------------------------------------------------------------------- +have_sidecar=0 +if [[ -s "$SIDECAR" ]]; then + have_sidecar=1 +fi + +if (( ! have_sidecar )) && ! hyprctl binds -j >/dev/null 2>&1; then + echo "show-keybinds: nothing to show: no keybind registry ($SIDECAR) and no Hyprland session" >&2 exit 1 fi rows=() +cmds=() +keys=() -# modmask\x1fkey\x1fdescription\x1fsubmap\x1farg -# Fields are joined with the unit separator (0x1f): tabs are IFS-whitespace -# and `read` would collapse consecutive tabs, eating empty middle fields -# like the (usually empty) submap. \x1f is not whitespace, so every field -# keeps its position. -while IFS=$'\x1f' read -r modmask key description submap arg; do - # Humanize mouse-button binds. - case "$key" in - mouse:272) key="LMB" ;; - mouse:273) key="RMB" ;; - esac - - # Build the chord, e.g. "SUPER + SHIFT + Q". - chord="" - i=0 - while (( i < ${#MOD_ORDER[@]} )); do - bit=${MOD_ORDER[i]} - name=${MOD_ORDER[i + 1]} - if (( modmask & bit )); then - chord+="${name} + " +if (( have_sidecar )); then + while IFS="$SEP" read -r key description cmd; do + key="${key//mouse:272/LMB}" + key="${key//mouse:273/RMB}" + if [[ -z "$description" ]]; then + description="(no description)" fi - i=$((i + 2)) - done - chord+="$key" + rows+=("$(printf '%-26s %s' "$key" "$description")") + cmds+=("$cmd") + keys+=("$key") + done < "$SIDECAR" +else + while IFS="$SEP" read -r modmask key description submap; do + case "$key" in + mouse:272) key="LMB" ;; + mouse:273) key="RMB" ;; + esac - if [[ -n "$submap" ]]; then - chord="[$submap] $chord" - fi + chord="" + # Rebuild "SUPER + SHIFT + Q" from the modmask bits. + i=0 + while (( i < ${#MOD_ORDER[@]} )); do + bit=${MOD_ORDER[i]} + name=${MOD_ORDER[i + 1]} + if (( modmask & bit )); then + chord+="${name} + " + fi + i=$((i + 2)) + done + chord+="$key" - if [[ -n "$description" ]]; then - rows+=("$(printf '%-26s %s' "$chord" "$description")") - else - rows+=("$(printf '%-26s %s' "$chord" "(no description)")") - fi -done < <(hyprctl binds -j | jq -r '.[] | [(.modmask | tostring), .key, .description, .submap, .arg] | join("\u001f")') + if [[ -n "$submap" ]]; then + chord="[$submap] $chord" + fi + + if [[ -n "$description" ]]; then + rows+=("$(printf '%-26s %s' "$chord" "$description")") + else + rows+=("$(printf '%-26s %s' "$chord" "(no description)")") + fi + cmds+=("") + keys+=("$chord") + done < <(hyprctl binds -j | jq -r '.[] | [(.modmask | tostring), .key, .description, .submap] | join("\u001f")') +fi if (( ${#rows[@]} == 0 )); then - echo "No keybinds found." >&2 + echo "show-keybinds: no keybinds found." >&2 exit 1 fi # Drop duplicate chords (e.g. media keys bound twice with different flags), -# then sort alphabetically (A-Z). -mapfile -t rows < <(printf '%s\n' "${rows[@]}" | awk '{ c = $0; sub(/ {2,}.*/, "", c); if (!seen[c]++) print }' | LC_ALL=C sort -f) +# keeping the first occurrence; carry the matching cmd along. Then sort by +# the display column A-Z. +declare -A seen=() +lines=() +for i in "${!rows[@]}"; do + if [[ -z "${seen[${keys[$i]}]:-}" ]]; then + seen["${keys[$i]}"]=1 + lines+=("${cmds[$i]}$SEP${rows[$i]}") + fi +done +mapfile -t lines < <(printf '%s\n' "${lines[@]}" | LC_ALL=C sort -f -t "$SEP" -k2) if [[ "${1:-}" == "--print" ]]; then - printf '%s\n' "${rows[@]}" + printf '%s\n' "${lines[@]}" | cut -d "$SEP" -f2- exit 0 fi -# Floating display via kitty (class `keybinds` has a float rule in -# hyprland.lua). Rows go through a temp file rather than a stdin pipe so the -# popup does not depend on kitty forwarding stdin to its child; the previous -# run's file is cleaned up on the next invocation. q closes the window. -rm -f /tmp/keybinds.??????.txt 2>/dev/null || true +# --------------------------------------------------------------------------- +# Default mode: floating kitty + fzf. Rows go through a temp file; the inner +# --internal-pick run removes it when done, stale leftovers get cleaned on the +# next invocation. +# --------------------------------------------------------------------------- +rm -f /tmp/keybinds.??????.list 2>/dev/null || true +list="$(mktemp /tmp/keybinds.XXXXXX.list)" +printf '%s\n' "${lines[@]}" > "$list" + if command -v kitty >/dev/null 2>&1; then - tmp="$(mktemp /tmp/keybinds.XXXXXX.txt)" - printf '%s\n' "${rows[@]}" > "$tmp" - kitty --class keybinds --title Keybinds less -S "$tmp" + kitty --class keybinds --title Keybinds "$0" --internal-pick "$list" exit 0 fi -printf '%s\n' "${rows[@]}" \ No newline at end of file +# No kitty: fall back to a plain printout. +printf '%s\n' "${lines[@]}" | cut -d "$SEP" -f2- +rm -f "$list" \ No newline at end of file