diff --git a/bin/omarchy-menu-keybindings b/bin/omarchy-menu-keybindings index 4269a831f8..767b5dac98 100755 --- a/bin/omarchy-menu-keybindings +++ b/bin/omarchy-menu-keybindings @@ -38,6 +38,12 @@ parse_keycodes() { 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" @@ -305,6 +311,7 @@ dynamic_bindings() { case "$key" in comma) key="COMMA" ;; + grave) key="~" ;; period) key="PERIOD" ;; minus) key="MINUS" ;; equal) key="EQUAL" ;; @@ -324,15 +331,41 @@ static_bindings() { 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' +Close window +Calculator +Toggle scratchpad +Move window to scratchpad +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, ' + 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; @@ -365,8 +398,35 @@ parse_binding_records() { 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 != "") { - printf "%-35s → %s\t%s\t%s\n", key_combo, action, dispatcher, arg; + 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]; } }' } @@ -374,7 +434,13 @@ parse_binding_records() { 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, /Keybindings/)) prio = 0 if (match(line, /Omarchy menu/)) prio = 1 @@ -436,6 +502,12 @@ prioritize_entries() { 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 @@ -446,7 +518,7 @@ output_binding_records_uncached() { [[ -n $dynamic ]] && printf '%s\n' "$dynamic" static_bindings } | - sort -u | + dedupe_binding_records | parse_keycodes | parse_binding_records | prioritize_entries @@ -458,7 +530,7 @@ output_binding_records_uncached() { keybindings_cache_key() { { - printf 'v11\n' + printf 'v13\n' hyprctl devices 2>/dev/null | grep -F 'active keymap:' hyprctl binds 2>/dev/null } | sha256sum | awk '{ print $1 }' diff --git a/default/hypr/bindings/tiling.lua b/default/hypr/bindings/tiling.lua index 30a32982da..72721b51ae 100644 --- a/default/hypr/bindings/tiling.lua +++ b/default/hypr/bindings/tiling.lua @@ -1,4 +1,5 @@ o.bind("SUPER + W", "Close window", hl.dsp.window.close()) +o.bind("SUPER + Q", "Close window", hl.dsp.window.close()) o.bind("CTRL + ALT + DELETE", "Close all windows", "omarchy-hyprland-window-close-all") o.bind("SUPER + J", "Toggle window split", hl.dsp.layout("togglesplit")) diff --git a/manual/03-coming-from-mac-or-windows.md b/manual/03-coming-from-mac-or-windows.md index cb36d6e497..f18189a7c6 100644 --- a/manual/03-coming-from-mac-or-windows.md +++ b/manual/03-coming-from-mac-or-windows.md @@ -46,7 +46,7 @@ Updates come through one command — _Update > Omarchy_ — that updates Omarchy Software comes from a package manager, not from downloaded installers. -And when you close a window, the app actually quits. There's no macOS limbo where the program keeps running with no windows. `Super + W` means gone. +And when you close a window, the app actually quits. There's no macOS limbo where the program keeps running with no windows. `Super + W` — or `Super + Q`, if that's the finger memory you arrived with — means gone. ### On Mac hardware diff --git a/manual/04-navigation.md b/manual/04-navigation.md index 1d3bc1734c..549833bf32 100644 --- a/manual/04-navigation.md +++ b/manual/04-navigation.md @@ -22,7 +22,7 @@ If you hit `Super + Shift + 2`, you'll move the current focused application onto If you hold down `Super` and use the mouse to click on a window, you'll be able to rearrange where it sits. If you hold `Super` and use the right button on the mouse, you can freely resize the window. -You close a window on `Super + W` (and close all windows on `Ctrl + Alt + Delete`). +You close a window on `Super + W` or `Super + Q` (and close all windows on `Ctrl + Alt + Delete`). You can also go full screen with `Super + F` or even just full-width (keeping the top bar) with `Super + Alt + F` or full-screen within a window with `Super + Ctrl + F` (good for YouTube!). diff --git a/manual/07-hotkeys.md b/manual/07-hotkeys.md index 77b57e54a7..5a038ff612 100644 --- a/manual/07-hotkeys.md +++ b/manual/07-hotkeys.md @@ -10,7 +10,7 @@ You can see all the main keyboard bindings with `Super + K` (Tmux bindings with | `Super + Alt + Space` | Apps menu | | `Super + Escape` | System menu (suspend, restart, etc) | | `Super + Ctrl + L` | Lock computer | -| `Super + W` | Close window | +| `Super + W` or `Super + Q` | Close window | | `Ctrl + Alt + Del` | Close all windows | | `Super + T` | Toggle window between tiling/floating | | `Super + J` | Toggle window position (horizontal/vertical) | diff --git a/test/shell.d/keybindings-menu-test.sh b/test/shell.d/keybindings-menu-test.sh new file mode 100755 index 0000000000..3e0da2884c --- /dev/null +++ b/test/shell.d/keybindings-menu-test.sh @@ -0,0 +1,222 @@ +#!/bin/bash + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command lua +require_command xkbcli + +tmpdir=$(mktemp -d) && [[ -n $tmpdir && -d $tmpdir ]] || + fail "the test gets a temporary directory to stub Hyprland in" +trap 'rm -rf "$tmpdir"' EXIT + +home="$tmpdir/home" +stub_bin="$tmpdir/bin" +mkdir -p "$home/.config" "$stub_bin" +cp -r "$ROOT/config/hypr" "$home/.config/hypr" + +# The menu reads binds from Hyprland, which is not running here, so stand in for +# it. A Lua bind reports dispatcher __lua and no arg, and the menu recovers both +# from the Lua source; an exec bind carries its own command. Both shapes matter: +# what two chords dispatch is what decides whether they share a row. +lua_bind() { + printf 'bind\n\tmodmask: %s\n\tsubmap: \n\tkey: %s\n\tkeycode: 0\n\tcatchall: false\n\tdescription: %s\n\tdispatcher: __lua\n\targ: \n' "$1" "$2" "$3" +} + +exec_bind() { + printf 'bind\n\tmodmask: %s\n\tsubmap: \n\tkey: %s\n\tkeycode: 0\n\tcatchall: false\n\tdescription: %s\n\tdispatcher: exec\n\targ: %s\n' "$1" "$2" "$3" "$4" +} + +stub_hyprctl() { + { + echo '#!/bin/bash' + echo 'case "$1" in' + echo ' binds) cat <<'"'"'BINDS'"'"'' + cat + echo 'BINDS' + echo ' ;;' + echo ' devices) echo "active keymap: English (US)" ;;' + echo 'esac' + } >"$stub_bin/hyprctl" + chmod +x "$stub_bin/hyprctl" +} + +keybindings() { + env -i PATH="$stub_bin:$ROOT/bin:$PATH" HOME="$home" \ + XDG_CACHE_HOME="$tmpdir/cache" OMARCHY_PATH="$ROOT" \ + bash "$ROOT/bin/omarchy-menu-keybindings" --print +} + +# Closing a window and toggling the scratchpad are two of the actions Omarchy +# binds twice on purpose. The last bind carries the longest description Omarchy +# ships, which is what puts a row closest to the width the menu allows. +stub_hyprctl <= 2 )) || + fail "every action named as having an alternative is bound twice" "$action" +done +pass "every action named as having an alternative is bound twice"