diff --git a/README.md b/README.md index 5696d72..1ed8ee3 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ docs/ Settings reference, and the design specs behind the work ## 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 panama test # everything diff --git a/config/dot/hypr/README.md b/config/dot/hypr/README.md index d3b0b8c..a762f76 100644 --- a/config/dot/hypr/README.md +++ b/config/dot/hypr/README.md @@ -285,6 +285,7 @@ The mental model is unchanged from Forge: | `SUPER + SHIFT + P` | Color picker | | `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 + ALT + T/N/W/F/C/E` | Go to that application if it is open, rather than starting another | | `CTRL + ALT + Delete` | Power menu | ### Apps diff --git a/config/dot/hypr/keybinds.lua b/config/dot/hypr/keybinds.lua index 824702c..6e61cf9 100644 --- a/config/dot/hypr/keybinds.lua +++ b/config/dot/hypr/keybinds.lua @@ -136,23 +136,35 @@ local function write_categories() file:close() end --- Focus it if it is already open, start it if it is not -- which is what the --- 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 --- title where the class alone cannot tell two things apart: the terminal and --- the editor are both kitty here, so only the title distinguishes them. +-- SUPER opens a new one. SUPER+ALT goes to the one you already have. +-- +-- That order matters and was chosen deliberately after trying the reverse. +-- Making the plain key focus an existing window reads well in a demo and is +-- 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" --- would match gmail-notifier, and the mail key would raise somebody's notifier --- instead. Single-quoted for the shell so a backslash reaches the matcher --- rather than being eaten on the way. +-- would match gmail-notifier, and the go-to-mail key would raise somebody's +-- notifier instead. Single-quoted for the shell so a backslash reaches the +-- matcher rather than being eaten on the way. local function shell_quote(value) return "'" .. value:gsub("'", "'\\''") .. "'" end 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) } if title then parts[#parts + 1] = "--title" @@ -164,12 +176,26 @@ local function launch_or_focus(class, command, title) end category("Applications") -bind(mod .. " + T", hl.dsp.exec_cmd(launch_or_focus("^kitty$", terminal)), { description = "Terminal" }) -bind(mod .. " + N", hl.dsp.exec_cmd(launch_or_focus("^kitty$", editor, "nvim")), { description = "Neovim" }) -bind(mod .. " + W", hl.dsp.exec_cmd(launch_or_focus("^helium", browser)), { description = "Browser" }) -bind(mod .. " + F", hl.dsp.exec_cmd(launch_or_focus("^org\\.gnome\\.Nautilus$", files)), { description = "Files" }) -bind(mod .. " + C", hl.dsp.exec_cmd(launch_or_focus("^org\\.gnome\\.Calculator$", calculator)), { description = "Calculator" }) -bind(mod .. " + E", hl.dsp.exec_cmd(launch_or_focus("^org\\.mozilla\\.thunderbird", mail)), { description = "Mail" }) +bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" }) +bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" }) +bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" }) +bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" }) +bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" }) +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("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" }) diff --git a/config/dot/quickshell/manual/02-the-keyboard.md b/config/dot/quickshell/manual/02-the-keyboard.md index bf5b974..15ecef8 100644 --- a/config/dot/quickshell/manual/02-the-keyboard.md +++ b/config/dot/quickshell/manual/02-the-keyboard.md @@ -14,6 +14,7 @@ once. | `Super` | The window you are looking at | | `Alt` | Workspaces | | `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. `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` 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 | Keys | What it does | |---|---| | `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 + backtick` | Every window, every workspace | | `Super + I` | Settings | diff --git a/config/dot/quickshell/modules/switcher/WindowSwitcher.qml b/config/dot/quickshell/modules/switcher/WindowSwitcher.qml index 0733ad7..67cb18a 100644 --- a/config/dot/quickshell/modules/switcher/WindowSwitcher.qml +++ b/config/dot/quickshell/modules/switcher/WindowSwitcher.qml @@ -34,15 +34,37 @@ Loader { // Overlay so it sits above the focused window it is describing. WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "qs-switcher" - // Nothing here is clickable: the gesture is driven entirely from the - // keyboard, and taking input would steal focus from the compositor - // mid-switch, which is the one thing that would break it. + // Keyboard focus stays with the compositor: the gesture is driven by + // binds, and taking focus mid-switch is the one thing that would break + // stepping. Pointer input is a different matter -- see below. WlrLayershell.keyboardFocus: WlrKeyboardFocus.None exclusionMode: ExclusionMode.Ignore color: "transparent" 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 { anchors.centerIn: parent width: Math.min(560, parent.width - 96) @@ -78,7 +100,18 @@ Loader { height: 44 radius: 10 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 { id: icon diff --git a/config/dot/quickshell/services/WindowSwitcherState.qml b/config/dot/quickshell/services/WindowSwitcherState.qml index 3d763e7..fe2a678 100644 --- a/config/dot/quickshell/services/WindowSwitcherState.qml +++ b/config/dot/quickshell/services/WindowSwitcherState.qml @@ -106,6 +106,15 @@ Singleton { 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 { root.open = false; root.windows = []; diff --git a/tests/quickshell/switcher-contract b/tests/quickshell/switcher-contract new file mode 100755 index 0000000..fecb7e3 --- /dev/null +++ b/tests/quickshell/switcher-contract @@ -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' diff --git a/tests/setup/launch-or-focus-contract b/tests/setup/launch-or-focus-contract index 92ed118..5bbcf3b 100755 --- a/tests/setup/launch-or-focus-contract +++ b/tests/setup/launch-or-focus-contract @@ -1,10 +1,14 @@ #!/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 -# single most common "Linux feels wrong" moment this desktop still had. The -# properties worth pinning: +# SUPER opens a new one; SUPER+ALT goes to the one that exists. That order was +# chosen after trying the reverse: making the plain key focus reads well in a +# 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. # 2. A window that is not open is launched. @@ -90,7 +94,7 @@ while read -r pattern; do [[ -n "$pattern" ]] || continue [[ "$pattern" == \^* ]] \ || 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 ───────────────────────────────────────────────────── @@ -104,8 +108,20 @@ grep -q 'address:0xaaa' "$calls" \ # The editor bind must narrow by title, or it is indistinguishable from the # terminal bind. -grep -q 'launch_or_focus("\^kitty\$", editor, "nvim")' "$keybinds" \ - || note 'the editor bind does not narrow by title, so it would raise whatever terminal is open' +grep -q 'go_to("\^kitty\$", editor, "nvim")' "$keybinds" \ + || 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 ──────────────────────