#!/bin/bash # omarchy:summary=Display Hyprland keybindings defined in your configuration using an interactive search menu. # Hyprland's Lua config provider currently reports Lua binds as dispatcher # __lua in `hyprctl binds`. Keep a lightweight source-derived cache so the # menu can still show and dispatch those bindings. declare -A LUA_BIND_KEY_MAP declare -A LUA_BIND_DISPATCHER_MAP declare -A LUA_BIND_ARG_MAP # Hyprland reports XKB keycodes for code: bindings. Resolve them to symbols # via the compiled keymap, with a small fallback for common keys so the menu # remains readable if xkbcli cannot resolve a symbol. parse_keycodes() { awk ' BEGIN { split("10=1 11=2 12=3 13=4 14=5 15=6 16=7 17=8 18=9 19=0 20=MINUS 21=EQUAL 59=COMMA 60=PERIOD 61=SLASH", fallbacks, " ") for (i in fallbacks) { separator = index(fallbacks[i], "=") keycode_symbol[substr(fallbacks[i], 1, separator - 1)] = substr(fallbacks[i], separator + 1) } # 0) { if (line ~ /xkb_keycodes/) { section = "codes"; continue } if (line ~ /xkb_symbols/) { section = "syms"; continue } if (section == "codes" && match(line, /<([A-Za-z0-9_]+)>\s*=\s*([0-9]+)\s*;/, m)) code_by_name[m[1]] = m[2] if (section == "syms" && match(line, /key\s*<([A-Za-z0-9_]+)>\s*\{\s*\[\s*([^, \]]+)/, m)) sym_by_name[m[1]] = m[2] } close(keymap_cmd) for (name in code_by_name) { code = code_by_name[name] symbol = sym_by_name[name] if (code != "" && symbol != "" && symbol != "NoSymbol") keycode_symbol[code] = toupper(symbol) } # Read off the keysym rather than the keycode, so the key left of 1 reads # as the symbol printed on it whichever key the active layout puts it on. for (code in keycode_symbol) { if (keycode_symbol[code] == "GRAVE") keycode_symbol[code] = "~" } mouse_symbol["272"] = "LEFT MOUSE BUTTON" mouse_symbol["273"] = "RIGHT MOUSE BUTTON" mouse_symbol["274"] = "MIDDLE MOUSE BUTTON" } { if (match($0, /code:([0-9]+)/, match_parts)) { code = match_parts[1] symbol = keycode_symbol[code] if (symbol == "") symbol = "code:" code sub("code:" code, symbol) } else if (match($0, /mouse:([0-9]+)/, match_parts)) { code = match_parts[1] symbol = mouse_symbol[code] if (symbol == "") symbol = "mouse:" code sub("mouse:" code, symbol) } print } ' } # Supplement `hyprctl binds` for Lua-only binds that Hyprland currently # reports as dispatcher __lua and without their original key for code: binds. build_lua_bind_cache() { local modmask description key dispatcher arg cache_key omarchy-cmd-present lua || return 0 while IFS=$'\t' read -r modmask description key dispatcher arg; do [[ -z $modmask || -z $description || -z $key ]] && continue LUA_BIND_KEY_MAP["$modmask,$description"]="$key" cache_key="$modmask,$description,$key" LUA_BIND_DISPATCHER_MAP["$cache_key"]="$dispatcher" LUA_BIND_ARG_MAP["$cache_key"]="$arg" done < <( lua <<'LUA' local modifiers = { SHIFT = 1, CTRL = 4, CONTROL = 4, ALT = 8, SUPER = 64 } local function split_keys(keys) local modmask = 0 local key = "" for part in string.gmatch(tostring(keys or ""), "[^+]+") do local value = part:gsub("^%s+", ""):gsub("%s+$", "") local modifier = modifiers[string.upper(value)] if modifier then modmask = modmask + modifier else key = value end end return modmask, key end local function lua_literal(value) local value_type = type(value) if value_type == "string" then return string.format("%q", value) elseif value_type == "number" or value_type == "boolean" then return tostring(value) elseif value_type == "table" then local parts = {} local keys = {} local array_length = #value for index = 1, array_length do parts[#parts + 1] = lua_literal(value[index]) end for key in pairs(value) do if not (type(key) == "number" and key >= 1 and key <= array_length and math.floor(key) == key) then keys[#keys + 1] = key end end table.sort(keys, function(left, right) return tostring(left) < tostring(right) end) for _, key in ipairs(keys) do local key_prefix if type(key) == "string" and key:match("^[%a_][%w_]*$") then key_prefix = key .. " = " else key_prefix = "[" .. lua_literal(key) .. "] = " end parts[#parts + 1] = key_prefix .. lua_literal(value[key]) end return "{ " .. table.concat(parts, ", ") .. " }" elseif value_type == "nil" then return "nil" else return "nil" end end local function call_expression(path, ...) local args = {} for index = 1, select("#", ...) do args[index] = lua_literal(select(index, ...)) end return path .. "(" .. table.concat(args, ", ") .. ")" end local function dispatcher(kind, arg, expr) return { __omarchy_dispatcher = true, kind = kind or "", arg = arg or "", expr = expr or "", } end local function dsp_proxy(path) return setmetatable({ path = path }, { __index = function(self, key) return dsp_proxy(self.path .. "." .. tostring(key)) end, __call = function(self, ...) local first_arg = ... local expr = call_expression(self.path, ...) if self.path == "hl.dsp.exec_cmd" and type(first_arg) == "string" then return dispatcher("exec", first_arg, expr) end return dispatcher("lua", expr, expr) end, }) end local noop noop = setmetatable({}, { __index = function() return noop end, __call = function() return noop end, }) hl = setmetatable({ dsp = dsp_proxy("hl.dsp"), bind = function(keys, bind_dispatcher, opts) opts = opts or {} if opts.description and opts.description ~= "" then local modmask, key = split_keys(keys) local kind = "" local arg = "" if type(bind_dispatcher) == "table" and bind_dispatcher.__omarchy_dispatcher then kind = bind_dispatcher.kind or "" arg = bind_dispatcher.arg or bind_dispatcher.expr or "" elseif type(bind_dispatcher) == "string" and bind_dispatcher ~= "" then kind = "exec" arg = bind_dispatcher end print(table.concat({ tostring(modmask), opts.description, key, kind, arg }, "\t")) end return noop end, get_config = function() return nil end, }, { __index = function() return noop end, }) local config = os.getenv("HOME") .. "/.config/hypr/hyprland.lua" local file = io.open(config, "r") if file then file:close() local ok, err = pcall(dofile, config) if not ok and os.getenv("DEBUG") == "1" then io.stderr:write("[DEBUG] lua bind scan failed: " .. tostring(err) .. "\n") end end LUA ) } modmask_to_text() { case "$1" in 0) printf '' ;; 1) printf 'SHIFT' ;; 4) printf 'CTRL' ;; 5) printf 'SHIFT CTRL' ;; 8) printf 'ALT' ;; 9) printf 'SHIFT ALT' ;; 12) printf 'CTRL ALT' ;; 13) printf 'SHIFT CTRL ALT' ;; 64) printf 'SUPER' ;; 65) printf 'SUPER SHIFT' ;; 68) printf 'SUPER CTRL' ;; 69) printf 'SUPER SHIFT CTRL' ;; 72) printf 'SUPER ALT' ;; 73) printf 'SUPER SHIFT ALT' ;; 76) printf 'SUPER CTRL ALT' ;; 77) printf 'SUPER SHIFT CTRL ALT' ;; *) printf '%s' "$1" ;; esac } # Fetch dynamic keybindings from Hyprland. # # Also do some pre-processing: # - Fill missing Lua code:... keys and __lua dispatch metadata from the Lua source cache # - Remove standard Omarchy bin path prefix # - Map numeric modifier key mask to a textual rendition # - Output comma-separated values that the parser can understand dynamic_bindings() { local modmask key keycode description dispatcher arg modifiers cache_key # Parse the plain `hyprctl binds` output rather than `hyprctl -j binds`: # Hyprland 0.56.0 emits invalid JSON for binds (misaligned fields in # bindsRequest), and older versions broke on quotes in bind args. hyprctl binds | awk ' function emit() { if (!seen) return seen = 0 printf "%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\n", f["modmask"], f["key"], f["keycode"], f["description"], f["dispatcher"], f["arg"] } /^bind/ { emit(); seen = 1; delete f; next } seen && match($0, /^\t[a-z]+: /) { f[substr($0, 2, RLENGTH - 3)] = substr($0, RLENGTH + 1) } END { emit() } ' | while IFS=$'\x1f' read -r modmask key keycode description dispatcher arg; do # Lua binds report their full display key ("SUPER + code:20"); the # modifiers are already carried separately in modmask. key="${key##* + }" if [[ -z $key && $keycode != "0" ]]; then key="code:$keycode" fi if [[ -z $key && -n $description ]]; then key="${LUA_BIND_KEY_MAP["$modmask,$description"]}" fi if [[ $dispatcher == "__lua" && -n $description && -n $key ]]; then cache_key="$modmask,$description,$key" dispatcher="${LUA_BIND_DISPATCHER_MAP["$cache_key"]}" arg="${LUA_BIND_ARG_MAP["$cache_key"]}" fi [[ -z $description && $dispatcher == "__lua" ]] && continue # The Copilot key just duplicates an existing binding, so keep it hidden [[ $key == "code:201" ]] && continue case "$key" in comma) key="COMMA" ;; grave) key="~" ;; period) key="PERIOD" ;; minus) key="MINUS" ;; equal) key="EQUAL" ;; slash) key="SLASH" ;; esac modifiers=$(modmask_to_text "$modmask") arg="${arg//~\/.local\/share\/omarchy\/bin\//}" printf '%s,%s,%s,%s,%s\n' "$modifiers" "$key" "$description" "$dispatcher" "$arg" done } # Hardcoded bindings, like the copy-url extension and such static_bindings() { echo "SHIFT ALT,L,Copy URL from Web App,sendshortcut,SHIFT ALT,L," echo "SHIFT ALT,D,Download Video from Web App,sendshortcut,SHIFT ALT,D," } # Actions Omarchy binds to a second chord meant as an alternative, named one at # a time. A rule would be wrong here: Alt + Tab and Shift + Alt + Tab both say # "Reveal active window on top" while cycling opposite ways, and a media key is # nobody's idea of an alternative to a Super chord. Only chords a user reaches # for interchangeably belong on one row. alternative_chord_actions() { cat <<'ACTIONS' 关闭窗口 计算器 切换便签区 窗口移入便签区 ACTIONS } # Parse and format keybindings # # `awk` does the heavy lifting: # - Set the field separator to a comma ','. # - Joins the key combination (e.g., "SUPER + Q"). # - Joins the command that the key executes. # - Puts an action's alternative chord on the row with the first one. # - Prints display text and dispatch metadata as tab-separated fields. parse_binding_records() { awk -F, -v alternatives="$(alternative_chord_actions)" ' BEGIN { # The column every row pads its chords to. Nothing is allowed past it: the # menu renders in monospace, and a row that overruns pushes its arrow out # of a column the eye reads straight down. column = 35; split(alternatives, named, "\n"); for (i in named) { if (named[i] != "") shares_a_row[named[i]] = 1; } } { # Combine the modifier and key (first two fields) key_combo = $1 " + " $2; # Clean up: strip leading "+" if present, trim spaces gsub(/^[ \t]*\+?[ \t]*/, "", key_combo); gsub(/[ \t]+$/, "", key_combo); # Use description, if set action = $3; dispatcher = $4; # Reconstruct the dispatcher arg from the remaining fields arg = ""; for (i = 5; i <= NF; i++) { arg = arg $i (i < NF ? "," : ""); } if (action == "") { # Reconstruct the command from the remaining fields for (i = 4; i <= NF; i++) { action = action $i (i < NF ? "," : ""); } # Clean up trailing commas, remove leading "exec, ", and trim sub(/,$/, "", action); gsub(/(^|,)[[:space:]]*exec[[:space:]]*,?/, "", action); gsub(/(^|[[:space:]])uwsm(-app| app)[[:space:]]+--[[:space:]]+/, "", action); gsub(/^[ \t]+|[ \t]+$/, "", action); gsub(/[ \t]+/, " ", key_combo); # Collapse multiple spaces to one } # An alternative chord joins the row the first one opened: "SUPER + W / # SUPER + Q -> Close window". Both halves have to agree on what they run, # since a label is only what a chord is called; an unresolved dispatcher # says nothing at all, so it never counts as agreement. if (action != "") { action_key = action SUBSEP dispatcher SUBSEP arg; together = ""; if (action in shares_a_row && dispatcher != "" && action_key in leads) { together = chords[leads[action_key]] " / " key_combo; } if (together != "" && length(together) <= column) { chords[leads[action_key]] = together; } else { # Also the path a pair too wide for the column takes: two rows in # line beat one that juts out of it. entries++; chords[entries] = key_combo; actions[entries] = action; dispatchers[entries] = dispatcher; args[entries] = arg; leads[action_key] = entries; } } } END { for (entry = 1; entry <= entries; entry++) { printf "%-*s → %s\t%s\t%s\n", column, chords[entry], actions[entry], dispatchers[entry], args[entry]; } }' } prioritize_entries() { awk -F '\t' ' { # Alternative chords are display only. Classifying on them would read # "SUPER SHIFT + RETURN / SUPER SHIFT + B" as the alternate browser chord # and drop the browser 20 rows down its own list, so match the chord that # leads the row and ignore the rest. line = $1 sub(/ \/ [^→]*→/, " →", line) prio = 50 if (match(line, /快捷键/)) prio = 0 if (match(line, /Omarchy 菜单/)) prio = 1 if (match(line, /终端/)) prio = 2 if (match(line, /浏览器/) && !match(line, /浏览器(/) && !match(line, /SUPER SHIFT.*\+.*B.*→.*浏览器/)) prio = 3 if (match(line, /文件管理器/) && !match(line, /文件管理器(当前目录)/)) prio = 4 if (match(line, /Launch apps/)) prio = 5 if (match(line, /系统菜单/)) prio = 6 if (match(line, /主题菜单/)) prio = 7 if (match(line, /全屏/)) prio = 8 if (match(line, /全宽/)) prio = 9 if (match(line, /关闭窗口/)) prio = 10 if (match(line, /关闭所有窗口/)) prio = 11 if (match(line, /锁定系统/)) prio = 12 if (match(line, /切换窗口浮动/)) prio = 13 if (match(line, /切换窗口分割/)) prio = 14 if (match(line, /弹出窗口/)) prio = 15 if (match(line, /通用/)) prio = 16 if (match(line, /剪贴板/)) prio = 17 if (match(line, /Audio controls/)) prio = 18 if (match(line, /Bluetooth controls/)) prio = 19 if (match(line, /Wifi controls/)) prio = 20 if (match(line, /表情/)) prio = 21 if (match(line, /取色器/)) prio = 22 if (match(line, /截图/)) prio = 23 if (match(line, /录屏/)) prio = 24 if (match(line, /Tmux/)) prio = 25 if (match(line, /Herdr/)) prio = 26 if (match(line, /SUPER SHIFT.*\+.*B.*→.*浏览器/)) prio = 27 if (match(line, /文件管理器(当前目录)/)) prio = 28 if (match(line, /(切换到|下一|上一|上次)工作区/)) prio = 29 if (match(line, /窗口移到工作区/)) prio = 30 if (match(line, /窗口静默移到工作区/)) prio = 31 if (match(line, /互换/)) prio = 32 if (match(line, /聚焦/)) prio = 33 if (match(line, /移动窗口$/)) prio = 34 if (match(line, /调整窗口大小/)) prio = 35 if (match(line, /扩展窗口/)) prio = 36 if (match(line, /收缩窗口/)) prio = 37 if (match(line, /便签区/)) prio = 38 if (match(line, /通知/)) prio = 39 if (match(line, /切换窗口透明/)) prio = 40 if (match(line, /切换窗口间距/)) prio = 41 if (match(line, /切换夜间模式/)) prio = 42 if (match(line, /切换空闲锁定/)) prio = 43 if (match(line, /组内|分组/)) prio = 94 if (match(line, /工作区向(前|后)滚动/)) prio = 95 if (match(line, /Cycle to/)) prio = 96 if (match(line, /置顶显示/)) prio = 97 if (match(line, /Apple Display/)) prio = 98 if (match(line, /XF86/)) prio = 99 if (match(line, /Tmux 快捷键/)) prio = 100 if (match(line, /Herdr 快捷键/)) prio = 101 # print "priorityrecord" printf "%d\t%s\n", prio, $0 }' | sort -k1,1n -k2,2 | cut -f2- } # Drop repeated records while keeping the order Hyprland reported them in, so # the chord a user declared first is the one that leads a merged entry. dedupe_binding_records() { awk '!seen[$0]++' } output_binding_records_uncached() { local dynamic build_lua_bind_cache dynamic=$(dynamic_bindings) { [[ -n $dynamic ]] && printf '%s\n' "$dynamic" static_bindings } | dedupe_binding_records | parse_keycodes | parse_binding_records | prioritize_entries # Fail when Hyprland reported no binds so the fallout of a broken hyprctl # is never cached. [[ -n $dynamic ]] } keybindings_cache_key() { { printf 'v13\n' hyprctl devices 2>/dev/null | grep -F 'active keymap:' hyprctl binds 2>/dev/null } | sha256sum | awk '{ print $1 }' } refresh_keybindings_cache() { local cache_dir="$1" local cache_file="$2" local cache_name="$3" local tmp_file tmp_file=$(mktemp "$cache_dir/keybindings.XXXXXX") || return 1 if output_binding_records_uncached >"$tmp_file"; then mv "$tmp_file" "$cache_file" find "$cache_dir" -maxdepth 1 -type f -name 'keybindings-*.records' ! -name "$cache_name" -delete 2>/dev/null || true else rm -f "$tmp_file" return 1 fi } output_binding_records() { local cache_dir cache_file cache_name cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/omarchy" cache_name="keybindings-$(keybindings_cache_key).records" cache_file="$cache_dir/$cache_name" if [[ -s $cache_file ]]; then cat "$cache_file" elif mkdir -p "$cache_dir" 2>/dev/null && refresh_keybindings_cache "$cache_dir" "$cache_file" "$cache_name"; then cat "$cache_file" else output_binding_records_uncached fi } output_keybindings() { output_binding_records | cut -f1 } trim() { local value="$1" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" printf '%s\n' "$value" } lua_string() { jq -Rnr --arg value "$1" '$value | @json' } dispatch_lua_expression() { local expression="$1" local output status output=$(hyprctl dispatch "$expression" 2>&1) status=$? if (( status == 0 )) && [[ -z $output || $output == "ok" ]]; then [[ -n $output ]] && printf '%s\n' "$output" return 0 fi return 1 } dispatch_exec_binding() { local command="$1" dispatch_lua_expression "hl.dsp.exec_cmd($(lua_string "$command"))" || hyprctl dispatch exec "$command" } dispatch_sendshortcut_binding() { local arg="$1" local mods key window rest IFS=, read -r mods key window rest <<<"$arg" mods=$(trim "$mods") key=$(trim "$key") window=$(trim "$window") [[ -z $window ]] && window="activewindow" if [[ -n $key ]] && dispatch_lua_expression "hl.dsp.send_key_state({ mods = $(lua_string "$mods"), key = $(lua_string "$key"), state = \"down\", window = $(lua_string "$window") })"; then sleep 0.05 dispatch_lua_expression "hl.dsp.send_key_state({ mods = $(lua_string "$mods"), key = $(lua_string "$key"), state = \"up\", window = $(lua_string "$window") })" return fi hyprctl dispatch sendshortcut "$arg" } dispatch_binding() { local dispatcher="$1" local arg="$2" case "$dispatcher" in exec) [[ -n $arg ]] && dispatch_exec_binding "$arg" ;; sendshortcut) [[ -n $arg ]] && dispatch_sendshortcut_binding "$arg" ;; lua) [[ -n $arg ]] && hyprctl dispatch "$arg" ;; "") return 1 ;; *) if [[ -n $arg ]]; then hyprctl dispatch "$dispatcher" "$arg" else hyprctl dispatch "$dispatcher" fi ;; esac } if [[ $1 == "--print" || $1 == "-p" ]]; then output_keybindings else records=$(output_binding_records) selection=$(cut -f1 <<<"$records" | omarchy-menu-select 'Keybindings' -- --width 800 --height 500) if [[ -n $selection ]]; then record=$(awk -F '\t' -v selection="$selection" '$1 == selection { print; exit }' <<<"$records") dispatcher=$(cut -f2 <<<"$record") arg=$(cut -f3- <<<"$record") dispatch_binding "$dispatcher" "$arg" fi fi