Open a new one with SUPER, go to the old one with SUPER+ALT

Making the plain application keys focus an existing window was the
wrong call. It reads well in a demo and it is what macOS does, but it
made "give me another terminal" the awkward case -- and on a tiling
desktop a second terminal beside the first is the normal way to work,
not an edge case. Reaching for the launcher to open a second file
manager is not an improvement on anything.

So the plain keys do what they always did, and SUPER+ALT is the new
capability rather than a tax on the old one: go to the terminal,
editor, browser, files, calculator or mail you already have, wherever
it is, and start one only if there is none.

ALT rather than SHIFT because SUPER+SHIFT is already the
window-manipulation space -- Files, Neovim and Settings would have
collided with Focus session, Taller and Shorter, and breaking two keys
out of the eight-key resize set to make room is the worse trade.

Also fixes a real trap found while using it. The Alt-Tab overlay
commits on SUPER release, which is a compositor bind running an IPC
call; if that call ever fails to land, the overlay stayed up with no
keyboard focus, no Escape handler and nothing clickable, so the only
way out was an IPC call typed into a terminal it was covering. Clicking
outside now dismisses it, clicking a row switches to that window --
which is the obvious thing to try and did nothing -- and an abandoned
switch closes itself after ten seconds. Keyboard focus still stays with
the compositor, because taking it mid-switch is what would break
stepping.
This commit is contained in:
Gabriel Brown
2026-08-22 07:22:09 -04:00
parent 68cbf892e9
commit 8d66247b7c
8 changed files with 224 additions and 28 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests ## Tests
147 of them, under `tests/`. Run the lot, or a subset by pattern: 148 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh ```sh
panama test # everything panama test # everything
+1
View File
@@ -285,6 +285,7 @@ The mental model is unchanged from Forge:
| `SUPER + SHIFT + P` | Color picker | | `SUPER + SHIFT + P` | Color picker |
| `CTRL + ALT + L` · `SUPER + Backspace` | Lock (SUPER+L is "focus right") | | `CTRL + ALT + L` · `SUPER + Backspace` | Lock (SUPER+L is "focus right") |
| `SUPER + /` | Every shortcut, on screen. Reads the live keymap, so a rebind shows here | | `SUPER + /` | Every shortcut, on screen. Reads the live keymap, so a rebind shows here |
| `SUPER + ALT + T/N/W/F/C/E` | Go to that application if it is open, rather than starting another |
| `CTRL + ALT + Delete` | Power menu | | `CTRL + ALT + Delete` | Power menu |
### Apps ### Apps
+41 -15
View File
@@ -136,23 +136,35 @@ local function write_categories()
file:close() file:close()
end end
-- Focus it if it is already open, start it if it is not -- which is what the -- SUPER opens a new one. SUPER+ALT goes to the one you already have.
-- application keys do on macOS and Windows, and what pressing the browser key --
-- twice ought to do. bin/panama-launch matches on window class, narrowed by -- That order matters and was chosen deliberately after trying the reverse.
-- title where the class alone cannot tell two things apart: the terminal and -- Making the plain key focus an existing window reads well in a demo and is
-- the editor are both kitty here, so only the title distinguishes them. -- what macOS does, but it makes "give me another terminal" the awkward case --
-- and on a tiling desktop, opening a second terminal beside the first is not
-- an edge case, it is the normal way to work. So the plain key keeps doing
-- what it has always done, and the modifier is the new capability rather than
-- a tax on the old one.
--
-- ALT rather than SHIFT because SUPER+SHIFT is already the window-manipulation
-- space: Files, Neovim and Settings would have collided with Focus session,
-- Taller and Shorter, and breaking two keys out of the eight-key resize set to
-- make room is a worse trade than borrowing a modifier.
--
-- The go-to binds still launch when nothing is open. A key that silently does
-- nothing is worse than one that does the obvious thing.
-- --
-- Patterns are regular expressions and are anchored. An unanchored "mail" -- Patterns are regular expressions and are anchored. An unanchored "mail"
-- would match gmail-notifier, and the mail key would raise somebody's notifier -- would match gmail-notifier, and the go-to-mail key would raise somebody's
-- instead. Single-quoted for the shell so a backslash reaches the matcher -- notifier instead. Single-quoted for the shell so a backslash reaches the
-- rather than being eaten on the way. -- matcher rather than being eaten on the way.
local function shell_quote(value) local function shell_quote(value)
return "'" .. value:gsub("'", "'\\''") .. "'" return "'" .. value:gsub("'", "'\\''") .. "'"
end end
local launcher_bin = "$HOME/.local/share/Panama/bin/panama-launch" local launcher_bin = "$HOME/.local/share/Panama/bin/panama-launch"
local function launch_or_focus(class, command, title) local function go_to(class, command, title)
local parts = { launcher_bin, "--class", shell_quote(class) } local parts = { launcher_bin, "--class", shell_quote(class) }
if title then if title then
parts[#parts + 1] = "--title" parts[#parts + 1] = "--title"
@@ -164,12 +176,26 @@ local function launch_or_focus(class, command, title)
end end
category("Applications") category("Applications")
bind(mod .. " + T", hl.dsp.exec_cmd(launch_or_focus("^kitty$", terminal)), { description = "Terminal" }) bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
bind(mod .. " + N", hl.dsp.exec_cmd(launch_or_focus("^kitty$", editor, "nvim")), { description = "Neovim" }) bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
bind(mod .. " + W", hl.dsp.exec_cmd(launch_or_focus("^helium", browser)), { description = "Browser" }) bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind(mod .. " + F", hl.dsp.exec_cmd(launch_or_focus("^org\\.gnome\\.Nautilus$", files)), { description = "Files" }) bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
bind(mod .. " + C", hl.dsp.exec_cmd(launch_or_focus("^org\\.gnome\\.Calculator$", calculator)), { description = "Calculator" }) bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind(mod .. " + E", hl.dsp.exec_cmd(launch_or_focus("^org\\.mozilla\\.thunderbird", mail)), { description = "Mail" }) bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
-- Go to the one already open, or start it if there is none.
bind(mod .. " + ALT + T", hl.dsp.exec_cmd(go_to("^kitty$", terminal)),
{ description = "Go to terminal" })
bind(mod .. " + ALT + N", hl.dsp.exec_cmd(go_to("^kitty$", editor, "nvim")),
{ description = "Go to Neovim" })
bind(mod .. " + ALT + W", hl.dsp.exec_cmd(go_to("^helium", browser)),
{ description = "Go to browser" })
bind(mod .. " + ALT + F", hl.dsp.exec_cmd(go_to("^org\\.gnome\\.Nautilus$", files)),
{ description = "Go to files" })
bind(mod .. " + ALT + C", hl.dsp.exec_cmd(go_to("^org\\.gnome\\.Calculator$", calculator)),
{ description = "Go to calculator" })
bind(mod .. " + ALT + E", hl.dsp.exec_cmd(go_to("^org\\.mozilla\\.thunderbird", mail)),
{ description = "Go to mail" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" }) bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" }) bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
@@ -14,6 +14,7 @@ once.
| `Super` | The window you are looking at | | `Super` | The window you are looking at |
| `Alt` | Workspaces | | `Alt` | Workspaces |
| `Super + Ctrl` | The layout itself: splitting, floating, swapping | | `Super + Ctrl` | The layout itself: splitting, floating, swapping |
| `Super + Alt` | The application you already have open |
Almost every shortcut follows from this. `Super + H` moves focus left. Almost every shortcut follows from this. `Super + H` moves focus left.
`Alt + H` moves to the workspace on the left. `Super + Ctrl + H` swaps the `Alt + H` moves to the workspace on the left. `Super + Ctrl + H` swaps the
@@ -28,12 +29,25 @@ would rather not.
Add `Shift` to move the window rather than the focus. `Super + Shift + L` Add `Shift` to move the window rather than the focus. `Super + Shift + L`
takes the current window and moves it to the right. takes the current window and moves it to the right.
## Opening versus going to
`Super + T` opens a terminal. It opens another one every time, which on a
tiling desktop is the normal way to work rather than an accident.
`Super + Alt + T` goes to a terminal you already have, wherever it is, and
opens one only if there is none. The same pair works for the editor, browser,
files, calculator and mail.
So the plain key means "give me one" and adding `Alt` means "take me to the
one I have".
## The handful worth memorising first ## The handful worth memorising first
| Keys | What it does | | Keys | What it does |
|---|---| |---|---|
| `Super + Space` | Find and open anything | | `Super + Space` | Find and open anything |
| `Super + T` | Terminal | | `Super + T` | Terminal (another one) |
| `Super + Alt + T` | The terminal you already have |
| `Super + Q` | Close the window | | `Super + Q` | Close the window |
| `Super + backtick` | Every window, every workspace | | `Super + backtick` | Every window, every workspace |
| `Super + I` | Settings | | `Super + I` | Settings |
@@ -34,15 +34,37 @@ Loader {
// Overlay so it sits above the focused window it is describing. // Overlay so it sits above the focused window it is describing.
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "qs-switcher" WlrLayershell.namespace: "qs-switcher"
// Nothing here is clickable: the gesture is driven entirely from the // Keyboard focus stays with the compositor: the gesture is driven by
// keyboard, and taking input would steal focus from the compositor // binds, and taking focus mid-switch is the one thing that would break
// mid-switch, which is the one thing that would break it. // stepping. Pointer input is a different matter -- see below.
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
color: "transparent" color: "transparent"
anchors { top: true; bottom: true; left: true; right: true } anchors { top: true; bottom: true; left: true; right: true }
// Anywhere outside the card puts the switcher away.
//
// This is recovery, not decoration. The gesture commits on SUPER
// release, which is a compositor bind running `qs ipc call` -- and if
// that call ever fails to land, the overlay used to stay up with no
// keyboard focus, no Escape handler and nothing clickable, which meant
// the only way out was an IPC call from a terminal the overlay was
// covering.
MouseArea {
anchors.fill: parent
onClicked: WindowSwitcherState.cancel()
}
// A switch nobody finished. Ten seconds is far longer than the gesture
// takes and far shorter than "forever", so a lost commit costs a pause
// rather than the session.
Timer {
running: WindowSwitcherState.open
interval: 10000
onTriggered: WindowSwitcherState.cancel()
}
Rectangle { Rectangle {
anchors.centerIn: parent anchors.centerIn: parent
width: Math.min(560, parent.width - 96) width: Math.min(560, parent.width - 96)
@@ -78,7 +100,18 @@ Loader {
height: 44 height: 44
radius: 10 radius: 10
border.width: 0 border.width: 0
color: row.current ? Theme.alpha(Theme.accent, 0.20) : "transparent" color: rowHover.hovered || row.current
? Theme.alpha(Theme.accent, row.current ? 0.20 : 0.10)
: "transparent"
// Pointing at a window and clicking it is the obvious
// thing to try, and it did nothing.
HoverHandler { id: rowHover }
MouseArea {
anchors.fill: parent
onClicked: WindowSwitcherState.selectAt(row.index)
}
Image { Image {
id: icon id: icon
@@ -106,6 +106,15 @@ Singleton {
target.wayland.activate(); target.wayland.activate();
} }
// Commit to a specific entry rather than the one stepping landed on.
// Clicking a row is the obvious thing to try with a list on screen.
function selectAt(position: int): void {
if (!root.open || position < 0 || position >= root.windows.length)
return;
root.index = position;
root.commit();
}
function cancel(): void { function cancel(): void {
root.open = false; root.open = false;
root.windows = []; root.windows = [];
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# The Alt-Tab overlay, and getting out of it.
#
# The gesture is driven by compositor binds: SUPER+Tab steps, and a bind on
# SUPER RELEASE commits by running `qs ipc call switcher commit`. That works,
# but it has a failure mode nobody had thought about -- if the commit call ever
# fails to land, the overlay stayed up with no keyboard focus, no Escape
# handler and nothing clickable. The only way out was an IPC call typed into a
# terminal the overlay was covering.
#
# So the properties here are about recovery, not about the happy path:
#
# 1. Clicking outside the card puts it away.
# 2. Clicking a row switches to that window. It is the obvious thing to try
# with a list of windows on screen, and it did nothing.
# 3. An abandoned switch closes itself. A lost commit should cost a pause,
# not the session.
# 4. Keyboard focus stays with the compositor. Taking it mid-switch is the
# one thing that would break stepping, so the fixes above must not.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
window="$repo_dir/config/dot/quickshell/modules/switcher/WindowSwitcher.qml"
state="$repo_dir/config/dot/quickshell/services/WindowSwitcherState.qml"
findings=()
note() { findings+=("$1"); }
[[ -r "$window" ]] || { printf 'switcher contract: %s is missing\n' "$window" >&2; exit 1; }
# ── 1 & 2. It responds to a pointer ─────────────────────────────────────────
grep -q 'onClicked: WindowSwitcherState.cancel()' "$window" \
|| note 'clicking outside the switcher does not dismiss it'
grep -q 'onClicked: WindowSwitcherState.selectAt' "$window" \
|| note 'clicking a window in the list does not switch to it'
grep -q 'function selectAt' "$state" \
|| note 'the switcher cannot commit to a specific entry, only to the one stepping landed on'
# selectAt must refuse an index that is not there, or a stale click after the
# list shrinks would activate whatever happens to be at that position.
grep -q 'position >= root.windows.length' "$state" \
|| note 'selectAt does not bound-check its index'
# ── 3. An abandoned switch closes itself ────────────────────────────────────
python3 - "$window" <<'PY' || note 'there is no timeout, so a lost commit leaves the overlay up forever'
import re, sys
text = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r"Timer\s*\{[^}]*running:\s*WindowSwitcherState\.open(.*?)\}", text, re.S)
if not match:
raise SystemExit(1)
interval = re.search(r"interval:\s*(\d+)", match.group(1))
if not interval:
raise SystemExit(1)
value = int(interval.group(1))
# Long enough that a deliberate pause mid-gesture is not cut short, short
# enough that a stuck overlay is an annoyance rather than a reason to reboot.
if not (3000 <= value <= 30000):
print(f"the switcher timeout is {value}ms", file=sys.stderr)
raise SystemExit(1)
PY
grep -q 'onTriggered: WindowSwitcherState.cancel()' "$window" \
|| note 'the switcher timeout does not cancel the switch'
# ── 4. The gesture itself is untouched ──────────────────────────────────────
grep -q 'WlrKeyboardFocus.None' "$window" \
|| note 'the switcher takes keyboard focus, which breaks stepping mid-switch'
# ── Live ────────────────────────────────────────────────────────────────────
if command -v qs >/dev/null 2>&1 && qs ipc call switcher cancel >/dev/null 2>&1; then
qs ipc call switcher next >/dev/null 2>&1
sleep 0.6
mapped="$(hyprctl layers -j 2>/dev/null | grep -c 'qs-switcher' || true)"
(( mapped > 0 )) || note 'stepping did not map the switcher'
qs ipc call switcher commit >/dev/null 2>&1
sleep 0.6
mapped="$(hyprctl layers -j 2>/dev/null | grep -c 'qs-switcher' || true)"
(( mapped == 0 )) || note 'committing did not close the switcher'
# Leave nothing behind whatever happened above.
qs ipc call switcher cancel >/dev/null 2>&1
fi
if (( ${#findings[@]} > 0 )); then
printf 'switcher contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'switcher contract: PASS\n'
+23 -7
View File
@@ -1,10 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Pressing the browser key twice should not give you two browsers. # Going to the window you already have, without giving up opening another.
# #
# It is what the application keys do on macOS and Windows, and it was the # SUPER opens a new one; SUPER+ALT goes to the one that exists. That order was
# single most common "Linux feels wrong" moment this desktop still had. The # chosen after trying the reverse: making the plain key focus reads well in a
# properties worth pinning: # demo, but it makes "give me another terminal" the awkward case, and on a
# tiling desktop a second terminal beside the first is the normal way to work
# rather than an edge case.
#
# The properties worth pinning:
# #
# 1. A window that is already open is focused, not duplicated. # 1. A window that is already open is focused, not duplicated.
# 2. A window that is not open is launched. # 2. A window that is not open is launched.
@@ -90,7 +94,7 @@ while read -r pattern; do
[[ -n "$pattern" ]] || continue [[ -n "$pattern" ]] || continue
[[ "$pattern" == \^* ]] \ [[ "$pattern" == \^* ]] \
|| note "the launch pattern '$pattern' is not anchored, so it can match an unrelated window" || note "the launch pattern '$pattern' is not anchored, so it can match an unrelated window"
done < <(grep -oE "launch_or_focus\(\"[^\"]+\"" "$keybinds" | sed 's/launch_or_focus("//; s/"$//') done < <(grep -oE "go_to\(\"[^\"]+\"" "$keybinds" | sed 's/go_to("//; s/"$//')
# ── 4. Class plus title ───────────────────────────────────────────────────── # ── 4. Class plus title ─────────────────────────────────────────────────────
@@ -104,8 +108,20 @@ grep -q 'address:0xaaa' "$calls" \
# The editor bind must narrow by title, or it is indistinguishable from the # The editor bind must narrow by title, or it is indistinguishable from the
# terminal bind. # terminal bind.
grep -q 'launch_or_focus("\^kitty\$", editor, "nvim")' "$keybinds" \ grep -q 'go_to("\^kitty\$", editor, "nvim")' "$keybinds" \
|| note 'the editor bind does not narrow by title, so it would raise whatever terminal is open' || note 'the go-to-editor bind does not narrow by title, so it would raise whatever terminal is open'
# The plain application keys must still open a new window. This is the whole
# point of the split, and the easiest thing to lose by accident.
for app in terminal editor browser files calculator mail; do
grep -qE "bind\(mod \.\. \" \+ [A-Z]\", hl\.dsp\.exec_cmd\($app\)" "$keybinds" \
|| note "the plain $app key no longer opens a new window"
done
# And every go-to bind is on SUPER+ALT, not somewhere that collides with the
# window-manipulation space.
count="$(grep -c 'bind(mod .. " + ALT + ' "$keybinds" || true)"
(( count >= 6 )) || note "only $count go-to binds are on SUPER+ALT; there should be one per application key"
# ── The same dispatch bug must not come back elsewhere ────────────────────── # ── The same dispatch bug must not come back elsewhere ──────────────────────