#!/usr/bin/env bash # Going to the window you already have, without giving up opening another. # # 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. # 3. Patterns are anchored. An unanchored "mail" matches gmail-notifier, and # the mail key would raise somebody's notifier. # 4. Class alone is not always enough. The terminal and the editor are both # kitty on this desktop; only the title tells them apart, and a # class-only match would make the editor key raise a terminal. # 5. The focus dispatch is the Lua form. On Hyprland 0.56 `hyprctl dispatch # focuswindow address:0x...` is parsed as Lua source and fails, which is # a failure the window switcher shipped with because its own contract # stubbed hyprctl. # # Driven against a stub hyprctl, so no real window is touched. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" launcher="$repo_dir/bin/panama-launch" keybinds="$repo_dir/config/dot/hypr/keybinds.lua" pick="$repo_dir/config/dot/quickshell/scripts/panama-pick" findings=() note() { findings+=("$1"); } [[ -x "$launcher" ]] || { printf 'launch-or-focus contract: %s is not executable\n' "$launcher" >&2; exit 1; } work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT calls="$work/calls" stub="$work/bin" mkdir -p "$stub" # Two windows, both kitty: a terminal and an editor. This is the fixture that # matters, because it is the case a class-only match gets wrong. cat >"$work/clients.json" <<'JSON' [ {"address":"0xaaa","class":"kitty","title":"gib@desktop:~","mapped":true,"focusHistoryID":1}, {"address":"0xbbb","class":"kitty","title":"nvim ~/src","mapped":true,"focusHistoryID":0}, {"address":"0xccc","class":"org.gnome.Nautilus","title":"docs","mapped":true,"focusHistoryID":2}, {"address":"0xddd","class":"gmail-notifier","title":"Mail","mapped":true,"focusHistoryID":3} ] JSON cat >"$stub/hyprctl" <>"$calls" STUB chmod +x "$stub/hyprctl" run() { : >"$calls"; PATH="$stub:$PATH" "$launcher" "$@" >/dev/null 2>&1; } # ── 1 & 5. An open window is focused, through the Lua dispatch form ───────── run --class '^org\.gnome\.Nautilus$' -- some-file-manager grep -q 'hl.dsp.focus' "$calls" \ || note 'an already-open window was not focused' grep -q 'address:0xccc' "$calls" \ || note 'the wrong window was focused' grep -q 'dispatch focuswindow' "$calls" \ && note 'the legacy dispatch form reached hyprctl; Hyprland 0.56 parses it as Lua and fails' # ── 2. An absent window is launched ───────────────────────────────────────── run --class '^not-running$' -- true grep -q 'hl.dsp.focus' "$calls" \ && note 'a window that is not open was focused anyway' # ── 3. Anchoring ──────────────────────────────────────────────────────────── # # The fixture includes gmail-notifier for exactly this. An unanchored pattern # for the mail client matches it. run --class '^org\.mozilla\.thunderbird' -- some-mail-client grep -q 'address:0xddd' "$calls" \ && note 'the mail pattern matched gmail-notifier' grep -q 'hl.dsp.focus' "$calls" \ && note 'a mail client that is not running was treated as running' # Every shipped pattern is anchored at the start. 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 "go_to\(\"[^\"]+\"" "$keybinds" | sed 's/go_to("//; s/"$//') # ── 4. Class plus title ───────────────────────────────────────────────────── run --class '^kitty$' --title 'nvim' -- kitty nvim . grep -q 'address:0xbbb' "$calls" \ || note 'a title-narrowed match focused the wrong kitty window, so the editor key would raise a terminal' run --class '^kitty$' -- kitty grep -q 'address:0xaaa' "$calls" \ || note 'a class-only match did not focus the most recently used window' # The editor bind must narrow by title, or it is indistinguishable from the # terminal bind. 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 ────────────────────── # Comments stripped first: both files explain the legacy form in prose in order # to warn against it, and matching that is matching documentation. grep -v '^[[:space:]]*#' "$pick" | grep -q 'dispatch focuswindow' \ && note 'panama-pick uses the legacy dispatch form, which fails on Hyprland 0.56' if (( ${#findings[@]} > 0 )); then printf 'launch-or-focus contract: %d finding(s)\n' "${#findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2 exit 1 fi printf 'launch-or-focus contract: PASS\n'