diff --git a/README.md b/README.md index 57f0117..5778891 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -144 of them, under `tests/`. Run the lot, or a subset by pattern: +145 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/bin/panama-launch b/bin/panama-launch new file mode 100755 index 0000000..6a55028 --- /dev/null +++ b/bin/panama-launch @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +# Focus the window if it is already open; start it if it is not. +# +# panama-launch --class '^helium$' -- helium-browser-bin +# panama-launch --class '^kitty$' --title 'nvim' -- kitty nvim . +# +# This is what the application keys do on every other desktop. Pressing the +# browser key twice on macOS or Windows raises the browser; here it used to +# open a second one, which is the single most common "Linux feels wrong" +# moment and a twenty-line fix. +# +# Matching is a regular expression against the window class, optionally +# narrowed by title. Both halves matter: the terminal and the editor are both +# kitty on this desktop, and only the title tells them apart -- so a class-only +# match would make the editor key raise whatever terminal happened to be open. +# +# Anchor your patterns. `--class mail` would match `gmail-notifier`, and the +# key that should open Thunderbird would raise somebody's notifier instead. + +set -uo pipefail + +class_pattern="" +title_pattern="" + +while (( $# > 0 )); do + case "$1" in + --class) class_pattern="${2:-}"; shift 2 ;; + --title) title_pattern="${2:-}"; shift 2 ;; + --) shift; break ;; + *) break ;; + esac +done + +if [[ -z "$class_pattern" || $# -eq 0 ]]; then + echo 'usage: panama-launch --class [--title ] -- command [args...]' >&2 + exit 2 +fi + +launch() { + # setsid so the application outlives this script and is not a child of the + # compositor's exec, which would tie its lifetime to a shell that exits. + setsid "$@" >/dev/null 2>&1 & + exit 0 +} + +# No compositor, no window list: just start the thing. +command -v hyprctl >/dev/null 2>&1 || launch "$@" + +address="$(hyprctl clients -j 2>/dev/null | jq -r --arg class "$class_pattern" --arg title "$title_pattern" ' + [ .[] + | select(.mapped) + | select(.class | test($class)) + | select($title == "" or (.title | test($title))) + ] + # Most recently focused first: with several matches, raise the one the + # user was last in rather than whichever the compositor lists first. + | sort_by(-.focusHistoryID) + | .[0].address // empty +' 2>/dev/null)" + +if [[ -n "$address" ]]; then + # Hyprland 0.56 dispatches through Lua: `hyprctl dispatch focuswindow + # address:0x...` is parsed as Lua source and fails. The selector string is + # what hl.focus accepts; a table of the same fields is refused. + exec hyprctl dispatch "hl.dsp.focus({ window = \"address:$address\" })" +fi + +launch "$@" diff --git a/config/dot/hypr/keybinds.lua b/config/dot/hypr/keybinds.lua index b35b87b..824702c 100644 --- a/config/dot/hypr/keybinds.lua +++ b/config/dot/hypr/keybinds.lua @@ -136,13 +136,40 @@ 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. +-- +-- 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. +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 parts = { launcher_bin, "--class", shell_quote(class) } + if title then + parts[#parts + 1] = "--title" + parts[#parts + 1] = shell_quote(title) + end + parts[#parts + 1] = "--" + parts[#parts + 1] = command + return table.concat(parts, " ") +end + category("Applications") -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" }) +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 .. " + 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/scripts/panama-pick b/config/dot/quickshell/scripts/panama-pick index c6ae958..2941c45 100755 --- a/config/dot/quickshell/scripts/panama-pick +++ b/config/dot/quickshell/scripts/panama-pick @@ -41,7 +41,9 @@ case "${1:-}" in address="$(cut -f1 <<<"$picked")" pid="$(cut -f2 <<<"$picked")" if [[ "$1" == window ]]; then - exec hyprctl dispatch focuswindow "address:$address" + # The Lua dispatch form. `hyprctl dispatch focuswindow address:0x...` + # is read as Lua source on 0.56 and fails with a parse error. + exec hyprctl dispatch "hl.dsp.focus({ window = \"address:$address\" })" fi # TERM first; the window still being mapped a moment later means the app # ignored it, which is what force quit exists for. diff --git a/tests/quickshell/declared-dependencies-contract b/tests/quickshell/declared-dependencies-contract index e626685..888722a 100755 --- a/tests/quickshell/declared-dependencies-contract +++ b/tests/quickshell/declared-dependencies-contract @@ -36,7 +36,7 @@ SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|functio # authselect is on the list for the same reason: it manages Fedora's PAM and # nsswitch profiles and arrives with fprintd-pam, realmd and nss-mdns, so the # fingerprint aliases in config/bash can rely on it without declaring it. -BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|lsof|authselect)$' +BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|lsof|authselect|setsid|nohup)$' SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$' diff --git a/tests/setup/launch-or-focus-contract b/tests/setup/launch-or-focus-contract new file mode 100755 index 0000000..92ed118 --- /dev/null +++ b/tests/setup/launch-or-focus-contract @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +# Pressing the browser key twice should not give you two browsers. +# +# 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: +# +# 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 "launch_or_focus\(\"[^\"]+\"" "$keybinds" | sed 's/launch_or_focus("//; 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 'launch_or_focus("\^kitty\$", editor, "nvim")' "$keybinds" \ + || note 'the editor bind does not narrow by title, so it would raise whatever terminal is open' + +# ── 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' diff --git a/tests/setup/launcher-commands-contract b/tests/setup/launcher-commands-contract index f5da307..eda93a8 100755 --- a/tests/setup/launcher-commands-contract +++ b/tests/setup/launcher-commands-contract @@ -126,8 +126,8 @@ FIXTURE # Picking the second window focuses the second address. : >"$calls" DMENU_ANSWER=1 PATH="$stub_dir:$PATH" "$pick" window >/dev/null 2>&1 -grep -q 'hyprctl dispatch focuswindow address:0xbbb' "$calls" \ - || note 'picking the second window did not focus the second address' +grep -q 'hl.dsp.focus.*address:0xbbb' "$calls" \ + || note 'picking the second window did not focus the second address (Hyprland 0.56 needs the Lua dispatch form)' # Escape does nothing. : >"$calls"