From 03b825f59a743ef27e92cdfc25fe5a41b192413d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 18:22:25 +0200 Subject: [PATCH] Show Herdr keybindings with SUPER + CTRL + K Mirrors the Tmux keybindings menu on SUPER + ALT + K. Herdr has no CLI that dumps resolved bindings, so the action list and its defaults come from `herdr --default-config`, where every action appears as a commented assignment, and the user config overrides what it sets. Prose in that default config can read like an assignment, as in `# type = "popup" opens a session-modal terminal`, so a line counts only when its value is a bare string or an array of them. Both TOML quote characters open a string, and with no config file at all the listing shows Herdr's own defaults rather than the Omarchy seed config Herdr never loaded. --- bin/omarchy-menu-herdr-keybindings | 231 ++++++++++++++++++++++++++++ default/hypr/bindings/utilities.lua | 1 + 2 files changed, 232 insertions(+) create mode 100755 bin/omarchy-menu-herdr-keybindings diff --git a/bin/omarchy-menu-herdr-keybindings b/bin/omarchy-menu-herdr-keybindings new file mode 100755 index 00000000..7b293605 --- /dev/null +++ b/bin/omarchy-menu-herdr-keybindings @@ -0,0 +1,231 @@ +#!/bin/bash + +# omarchy:summary=Display annotated Herdr keybindings using an interactive search menu. +# omarchy:args=[--print|-p] [--config ] + +print_only=false +config_file="${HERDR_CONFIG_PATH:-${XDG_CONFIG_HOME:-$HOME/.config}/herdr/config.toml}" + +while (($#)); do + case "$1" in + --print|-p) + print_only=true + ;; + --config) + shift + config_file="$1" + ;; + *) + config_file="$1" + ;; + esac + + shift +done + +# With no config file, Herdr runs on its own built-in defaults. Substituting the +# Omarchy seed config here would describe bindings Herdr never loaded. +[[ -f $config_file ]] || config_file="/dev/null" + +# Herdr has no CLI that dumps resolved keybindings, so the action list and its +# defaults come from `herdr --default-config`, where every action appears as a +# commented `# action = "binding"` line, and the user config overrides those. +output_keybindings() ( + set -o pipefail + herdr --default-config | awk -v config_file="$config_file" ' +# TOML literal strings are as valid as basic ones, so both quote characters +# open and close a value here. +function strip_comment(value, i, ch, quote, out) { + quote = "" + out = "" + + for (i = 1; i <= length(value); i++) { + ch = substr(value, i, 1) + + if (quote == "" && (ch == "\"" || ch == "'\''")) quote = ch + else if (ch == quote) quote = "" + if (ch == "#" && quote == "") break + + out = out ch + } + + return out +} + +# Removes every quoted run, leaving only what surrounds the strings. An +# unterminated quote returns a sentinel no valid value can produce. +function without_quoted(value, i, ch, quote, out) { + quote = "" + out = "" + + for (i = 1; i <= length(value); i++) { + ch = substr(value, i, 1) + + if (quote == "") { + if (ch == "\"" || ch == "'"'"'") quote = ch + else out = out ch + } else if (ch == quote) { + quote = "" + } + } + + return quote == "" ? out : "\001" +} + +# Prose in the default config can read like an assignment, as in +# `# type = "popup" opens a session-modal terminal`, so a value counts only when +# it is a bare string or an array of them. +function is_binding_value(value, rest) { + value = strip_comment(value) + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + if (value == "") return 0 + + rest = without_quoted(value) + + # A bare string leaves nothing behind; an array leaves only its brackets, + # commas, and whitespace. + return rest == "" || rest ~ /^\[[[:space:],]*\]$/ +} + +function key_text(key, count, parts, i, text) { + count = split(key, parts, "+") + text = "" + + for (i = 1; i <= count; i++) { + text = text (text == "" ? "" : " + ") toupper(parts[i]) + } + + return text +} + +# Renders every alternate binding for one action as "A / B". +function combo_text(value, i, ch, quote, part, text) { + value = strip_comment(value) + quote = "" + part = "" + text = "" + + for (i = 1; i <= length(value); i++) { + ch = substr(value, i, 1) + + if (quote == "") { + if (ch == "\"" || ch == "'"'"'") { quote = ch; part = "" } + continue + } + + if (ch == quote) { + quote = "" + if (part != "") text = text (text == "" ? "" : " / ") key_text(part) + continue + } + + part = part ch + } + + return text +} + +function describe(action, text) { + sub(/^navigate_/, "", action) + gsub(/_/, " ", action) + text = toupper(substr(action, 1, 1)) substr(action, 2) + + return text +} + +function remember(action) { + if (action in seen) return + seen[action] = 1 + order[++count] = action +} + +function bind(action, value, combo) { + combo = combo_text(value) + if (combo == "") return + + if (action ~ /^navigate_/) combo = "NAVIGATE + " combo + remember(action) + combos[action] = combo +} + +# Pass 1: default config on stdin, for the action order and default bindings. +# Every line there is commented out, so section markers are too. +FNR == NR { + line = $0 + sub(/^[[:space:]]*#[[:space:]]*/, "", line) + + if (line ~ /^\[\[keys\.command\]\]/) { in_keys = 0; next } + if (line ~ /^\[/) { in_keys = (line ~ /^\[keys\]/); next } + if (!in_keys) next + if (line !~ /^[a-z_]+[[:space:]]*=/) next + + action = line + sub(/[[:space:]]*=.*$/, "", action) + value = line + sub(/^[^=]*=[[:space:]]*/, "", value) + if (!is_binding_value(value)) next + + # Actions Herdr leaves unbound still hold their place in the listing order, + # for when the user config binds them. + remember(action) + bind(action, value) + next +} + +# Pass 2: the user config, which overrides the defaults it sets. +{ + if ($0 ~ /^\[\[keys\.command\]\]/) { in_user_keys = 0; next } + if ($0 ~ /^\[/) { in_user_keys = ($0 ~ /^\[keys\]/); next } + if (!in_user_keys) next + if ($0 ~ /^[[:space:]]*#/) next + if ($0 !~ /^[[:space:]]*[a-z_]+[[:space:]]*=/) next + + line = $0 + sub(/^[[:space:]]*/, "", line) + action = line + sub(/[[:space:]]*=.*$/, "", action) + value = line + sub(/^[^=]*=[[:space:]]*/, "", value) + if (!is_binding_value(value)) next + + # An action bound to nothing in the user config is unbound, not defaulted. + if (combo_text(value) == "") { + delete combos[action] + next + } + + bind(action, value) +} + +END { + if ("prefix" in combos) { + printf "%-32s → %s\n", "PREFIX", combos["prefix"] + } + + for (i = 1; i <= count; i++) { + action = order[i] + if (action == "prefix") continue + if (!(action in combos)) continue + + printf "%-32s → %s\n", combos[action], describe(action) + } +} +' - "$config_file" +) + +if [[ $print_only == "true" ]]; then + output_keybindings + exit 0 +fi + +records=$(output_keybindings) + +monitor_height=$(hyprctl monitors -j 2>/dev/null | jq -r '.[] | select(.focused == true) | .height' 2>/dev/null) + +if [[ ! $monitor_height =~ ^[0-9]+$ ]] || ((monitor_height <= 0)); then + monitor_height=900 +fi + +menu_height=$((monitor_height * 40 / 100)) +printf '%s\n' "$records" | omarchy-menu-select 'Herdr keybindings' -- --width 800 --height "$menu_height" >/dev/null || true diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 38a4de18..73e0addd 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -9,6 +9,7 @@ o.bind("SUPER + ESCAPE", "System menu", "omarchy-menu toggle system") o.bind("XF86PowerOff", "Power menu", "omarchy-menu toggle system", { locked = true }) o.bind("SUPER + K", "Show key bindings", "omarchy-menu-keybindings") o.bind("SUPER + ALT + K", "Show Tmux key bindings", "omarchy-menu-tmux-keybindings") +o.bind("SUPER + CTRL + K", "Show Herdr key bindings", "omarchy-menu-herdr-keybindings") o.bind("SUPER + CTRL + Q", "Calculator", "omacalc") o.bind("XF86Calculator", "Calculator", "omacalc")