Add Super + Q as a second chord for closing a window (#7767)

* Add Super + Q as a second chord for closing a window

Super + W stays the documented default. Super + Q is the chord people
arrive with from macOS, where Command + Q quits the app, and typing it
into Omarchy did nothing at all until now.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

* Put an action's alternative chord on one keybindings row

Super + W and Super + Q both read "Close window" in the menu, two rows
apart, with nothing to say they were the same thing -- and the
alternative sorted above the default. The scratchpad and the calculator
had the same trouble, each bound to a chord and to a second key.

Four actions are named as having an alternative, one at a time, and the
second chord joins the first one's row. 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. Both halves still have to agree on what they dispatch,
since a label is only what a chord is called, and an unresolved
dispatcher never counts as agreement.

Nothing is allowed past the 35-character column: a pair that would
overrun it stays as two rows rather than pushing its arrow out of line.
The menu elides a row that outgrows its card -- 754px of label, 78
monospace characters at the heading size -- and the longest entry already
sits at 74, so widening the column to fit the widest pair would have cost
two dozen rows the end of their description.

Priority ordering reads the rendered row, so the chord sharing it would
otherwise reclassify the entry: XF86Calculator alone belongs in the tail
kept for media keys, and it took the calculator down there with it.
Ranking now reads the chord that leads the row.

The key left of 1 reads as ~ rather than Hyprland's name for it, whether
a bind names it or reports the keycode for the keymap to resolve. Cached
records predate all of this, so the cache version moves with it.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-authored-by: Codex XHigh <noreply@openai.com>

---------

Co-authored-by: Codex XHigh <noreply@openai.com>
This commit is contained in:
Omarchybot
2026-08-22 16:55:01 +02:00
committed by GitHub
co-authored by Codex XHigh
parent 13a969e1ab
commit 07fccef41c
6 changed files with 302 additions and 7 deletions
+76 -4
View File
@@ -38,6 +38,12 @@ parse_keycodes() {
if (code != "" && symbol != "" && symbol != "NoSymbol") keycode_symbol[code] = toupper(symbol) 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["272"] = "LEFT MOUSE BUTTON"
mouse_symbol["273"] = "RIGHT MOUSE BUTTON" mouse_symbol["273"] = "RIGHT MOUSE BUTTON"
mouse_symbol["274"] = "MIDDLE MOUSE BUTTON" mouse_symbol["274"] = "MIDDLE MOUSE BUTTON"
@@ -305,6 +311,7 @@ dynamic_bindings() {
case "$key" in case "$key" in
comma) key="COMMA" ;; comma) key="COMMA" ;;
grave) key="~" ;;
period) key="PERIOD" ;; period) key="PERIOD" ;;
minus) key="MINUS" ;; minus) key="MINUS" ;;
equal) key="EQUAL" ;; equal) key="EQUAL" ;;
@@ -324,15 +331,41 @@ static_bindings() {
echo "SHIFT ALT,D,Download Video from Web App,sendshortcut,SHIFT ALT,D," 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 # Parse and format keybindings
# #
# `awk` does the heavy lifting: # `awk` does the heavy lifting:
# - Set the field separator to a comma ','. # - Set the field separator to a comma ','.
# - Joins the key combination (e.g., "SUPER + Q"). # - Joins the key combination (e.g., "SUPER + Q").
# - Joins the command that the key executes. # - 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. # - Prints display text and dispatch metadata as tab-separated fields.
parse_binding_records() { 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) # Combine the modifier and key (first two fields)
key_combo = $1 " + " $2; key_combo = $1 " + " $2;
@@ -365,8 +398,35 @@ parse_binding_records() {
gsub(/[ \t]+/, " ", key_combo); # Collapse multiple spaces to one 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 != "") { 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() { prioritize_entries() {
awk -F '\t' ' 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 line = $1
sub(/ \/ [^→]*→/, " →", line)
prio = 50 prio = 50
if (match(line, /Keybindings/)) prio = 0 if (match(line, /Keybindings/)) prio = 0
if (match(line, /Omarchy menu/)) prio = 1 if (match(line, /Omarchy menu/)) prio = 1
@@ -436,6 +502,12 @@ prioritize_entries() {
cut -f2- 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() { output_binding_records_uncached() {
local dynamic local dynamic
@@ -446,7 +518,7 @@ output_binding_records_uncached() {
[[ -n $dynamic ]] && printf '%s\n' "$dynamic" [[ -n $dynamic ]] && printf '%s\n' "$dynamic"
static_bindings static_bindings
} | } |
sort -u | dedupe_binding_records |
parse_keycodes | parse_keycodes |
parse_binding_records | parse_binding_records |
prioritize_entries prioritize_entries
@@ -458,7 +530,7 @@ output_binding_records_uncached() {
keybindings_cache_key() { keybindings_cache_key() {
{ {
printf 'v11\n' printf 'v13\n'
hyprctl devices 2>/dev/null | grep -F 'active keymap:' hyprctl devices 2>/dev/null | grep -F 'active keymap:'
hyprctl binds 2>/dev/null hyprctl binds 2>/dev/null
} | sha256sum | awk '{ print $1 }' } | sha256sum | awk '{ print $1 }'
+1
View File
@@ -1,4 +1,5 @@
o.bind("SUPER + W", "Close window", hl.dsp.window.close()) 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("CTRL + ALT + DELETE", "Close all windows", "omarchy-hyprland-window-close-all")
o.bind("SUPER + J", "Toggle window split", hl.dsp.layout("togglesplit")) o.bind("SUPER + J", "Toggle window split", hl.dsp.layout("togglesplit"))
+1 -1
View File
@@ -46,7 +46,7 @@ Updates come through one command — _Update > Omarchy_ — that updates Omarchy
Software comes from a package manager, not from downloaded installers. 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 ### On Mac hardware
+1 -1
View File
@@ -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. 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!). 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!).
+1 -1
View File
@@ -10,7 +10,7 @@ You can see all the main keyboard bindings with `Super + K` (Tmux bindings with
| `Super + Alt + Space` | Apps menu | | `Super + Alt + Space` | Apps menu |
| `Super + Escape` | System menu (suspend, restart, etc) | | `Super + Escape` | System menu (suspend, restart, etc) |
| `Super + Ctrl + L` | Lock computer | | `Super + Ctrl + L` | Lock computer |
| `Super + W` | Close window | | `Super + W` or `Super + Q` | Close window |
| `Ctrl + Alt + Del` | Close all windows | | `Ctrl + Alt + Del` | Close all windows |
| `Super + T` | Toggle window between tiling/floating | | `Super + T` | Toggle window between tiling/floating |
| `Super + J` | Toggle window position (horizontal/vertical) | | `Super + J` | Toggle window position (horizontal/vertical) |
+222
View File
@@ -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 <<BINDS
$(lua_bind 64 "SUPER + W" "Close window")
$(lua_bind 64 "SUPER + Q" "Close window")
$(lua_bind 64 "SUPER + F" "Full screen")
$(lua_bind 64 "SUPER + S" "Toggle scratchpad")
$(lua_bind 64 "SUPER + grave" "Toggle scratchpad")
$(exec_bind 73 "SUPER SHIFT ALT + 0" "Move window silently to workspace 10" "true")
BINDS
rendered=$(keybindings)
[[ -n $rendered ]] || fail "the keybindings menu renders with a stubbed Hyprland"
grep -q 'SUPER + F *→ Full screen' <<<"$rendered" ||
fail "a chord with no alternative renders on its own" "$rendered"
pass "the keybindings menu renders its entries"
(( $(grep -c '→ Close window$' <<<"$rendered") == 1 )) ||
fail "an alternative chord joins the row of the first one" "$rendered"
grep -q 'SUPER + W / SUPER + Q *→ Close window' <<<"$rendered" ||
fail "a shared row names both chords" "$rendered"
pass "an alternative chord joins the row of the first one"
# Which chord leads is the whole point of keeping Hyprland's order: SUPER + W is
# the documented default and SUPER + Q the alternative bound after it.
grep -q '^SUPER + W / SUPER + Q' <<<"$rendered" ||
fail "the chord declared first leads a shared row" "$rendered"
pass "the chord declared first leads a shared row"
# Hyprland calls the key left of 1 "grave". Nobody reads their keyboard that way.
grep -q 'SUPER + S / SUPER + ~ *→ Toggle scratchpad' <<<"$rendered" ||
fail "the grave key reads as the symbol printed on it" "$rendered"
! grep -q 'grave' <<<"$rendered" ||
fail "no entry still says grave" "$rendered"
pass "the grave key reads as the symbol printed on it"
# Monospace menu: every arrow sits in one column, and nothing is allowed past
# it. A row that overruns pushes its own arrow out of line.
[[ $(awk -F '→' '{ print length($1) }' <<<"$rendered" | sort -u) == "36" ]] ||
fail "every entry pads its chords to the same column" "$rendered"
pass "every entry pads its chords to the same column"
# The menu elides a row that outgrows its card: 754px of label, 78 monospace
# characters at the heading size. The longest entry Omarchy ships sits at 74, so
# a row has four characters of room and no more.
(( $(awk '{ print length($0) }' <<<"$rendered" | sort -rn | head -1) <= 78 )) ||
fail "no entry outgrows the width the menu gives it" "$rendered"
pass "no entry outgrows the width the menu gives it"
# Priority ordering reads the row, and the chord sharing it must not reclassify
# the entry: XF86Calculator alone belongs in the tail the menu keeps for media
# keys, while the calculator itself sits in the body of the list.
stub_hyprctl <<BINDS
$(exec_bind 68 "SUPER CTRL + Q" "Calculator" "omacalc")
$(exec_bind 0 "XF86Calculator" "Calculator" "omacalc")
$(exec_bind 8 "ALT + TAB" "Reveal active window on top" "true")
BINDS
rendered=$(keybindings)
(( $(grep -n '→ Calculator$' <<<"$rendered" | cut -d: -f1) <
$(grep -n '→ Reveal active window on top$' <<<"$rendered" | cut -d: -f1) )) ||
fail "a shared chord does not change where its entry ranks" "$rendered"
pass "a shared chord does not change where its entry ranks"
# The same key written as a keycode arrives by the other road: Hyprland reports
# the code and the keymap resolves it, after the rename above has run.
stub_hyprctl <<'BINDS'
bind
modmask: 64
submap:
key:
keycode: 49
catchall: false
description: Toggle scratchpad
dispatcher: exec
arg: true
BINDS
rendered=$(keybindings)
grep -q 'SUPER + ~ *→ Toggle scratchpad' <<<"$rendered" ||
fail "a keycode resolves to the symbol printed on the key too" "$rendered"
pass "a keycode resolves to the symbol printed on the key too"
# A chord refused for width opens a row of its own, and the next chord tries
# that row rather than reaching back past it and printing out of order.
stub_hyprctl <<BINDS
$(exec_bind 64 "SUPER + A" "Calculator" "omacalc")
$(exec_bind 77 "SUPER SHIFT CTRL ALT + BACKSPACE" "Calculator" "omacalc")
$(exec_bind 64 "SUPER + B" "Calculator" "omacalc")
BINDS
rendered=$(keybindings)
(( $(grep -c '→ Calculator$' <<<"$rendered") == 3 )) ||
fail "a refused chord does not let the next one jump the queue" "$rendered"
pass "a refused chord does not let the next one jump the queue"
# Sharing a row is something Omarchy names an action for, not something two
# chords earn by looking alike. Alt + Tab and Shift + Alt + Tab both read
# "Reveal active window on top" and cycle opposite ways.
stub_hyprctl <<BINDS
$(exec_bind 64 "SUPER + Y" "Zoom in" "omarchy-zoom in")
$(exec_bind 64 "SUPER + Z" "Zoom in" "omarchy-zoom in")
BINDS
rendered=$(keybindings)
(( $(grep -c '→ Zoom in$' <<<"$rendered") == 2 )) ||
fail "an action Omarchy did not name keeps its chords on separate rows" "$rendered"
pass "an action Omarchy did not name keeps its chords on separate rows"
# Even a named action gives up the shared row rather than overrun the column:
# two rows in line beat one that juts out of it.
stub_hyprctl <<BINDS
$(exec_bind 73 "SUPER SHIFT ALT + BACKSPACE" "Calculator" "omacalc")
$(exec_bind 69 "SUPER SHIFT CTRL + BACKSPACE" "Calculator" "omacalc")
BINDS
rendered=$(keybindings)
(( $(grep -c '→ Calculator$' <<<"$rendered") == 2 )) ||
fail "chords too wide to share a row stay on their own" "$rendered"
[[ $(awk -F '→' '{ print length($1) }' <<<"$rendered" | sort -u) == "36" ]] ||
fail "chords too wide to share a row leave the column alone" "$rendered"
pass "chords too wide to share a row stay on their own"
# A shared label is not a shared action. Two chords that merely read alike have
# to stay apart, or the menu hides one of them behind the other.
stub_hyprctl <<BINDS
$(lua_bind 64 "SUPER + W" "Close window")
$(exec_bind 64 "SUPER + X" "Close window" "omarchy-hyprland-window-close-all")
BINDS
rendered=$(keybindings)
(( $(grep -c '→ Close window$' <<<"$rendered") == 2 )) ||
fail "chords with the same label but different actions stay apart" "$rendered"
pass "chords with the same label but different actions stay apart"
# An unresolved Lua bind reports no dispatcher at all, so nothing says the two
# chords run the same thing, whatever their label promises.
stub_hyprctl <<BINDS
$(lua_bind 64 "SUPER + Y" "Close window")
$(lua_bind 64 "SUPER + Z" "Close window")
BINDS
rendered=$(keybindings)
(( $(grep -c '→ Close window$' <<<"$rendered") == 2 )) ||
fail "chords whose dispatch is unknown stay apart" "$rendered"
pass "chords whose dispatch is unknown stay apart"
# What the menu is expected to pair up, written out here rather than read from
# the script, so dropping an action from the list fails instead of shrinking
# what gets checked.
expected_alternatives=(
"Close window"
"Calculator"
"Toggle scratchpad"
"Move window to scratchpad"
)
eval "$(sed -n '/^alternative_chord_actions()/,/^}/p' "$ROOT/bin/omarchy-menu-keybindings")"
[[ $(alternative_chord_actions) == "$(printf '%s\n' "${expected_alternatives[@]}")" ]] ||
fail "the menu pairs up the actions Omarchy means it to" "$(alternative_chord_actions)"
pass "the menu pairs up the actions Omarchy means it to"
# A renamed description would leave an action named here matching nothing, and
# the row it was meant to share would quietly split in two. Only real binds
# count: a commented-out example is not a second chord.
for action in "${expected_alternatives[@]}"; do
(( $(grep -rhE '^[[:space:]]*o\.bind\(' "$ROOT/default/hypr/bindings" |
grep -cF ", \"$action\",") >= 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"