Press the browser key twice, get one browser
The application keys ran their command unconditionally, so pressing the browser key twice gave you two browsers. That is the single most common "Linux feels wrong" moment and it is a twenty-line fix: match the window class, focus it if it is there, launch if it is not. Two details the obvious version gets wrong. Patterns are anchored, because an unanchored "mail" matches gmail-notifier and the mail key would raise somebody's notifier. And class alone is not enough here: the terminal and the editor are both kitty, so the editor bind narrows by title or it raises whatever terminal happened to be open. The contract's fixture contains both traps. Found and fixed a bug this shipped with earlier today. Hyprland 0.56 dispatches through Lua, so `hyprctl dispatch focuswindow address:0x...` is parsed as Lua source and fails with a syntax error -- which is what the launcher's Switch Window command had been doing since it landed. Its own contract stubbed hyprctl and recorded the arguments, so the call looked correct and never ran. Both now use the selector form hl.focus actually accepts, and the contract asserts the shape rather than only that something was dispatched.
This commit is contained in:
@@ -114,7 +114,7 @@ docs/ Settings reference, and the design specs behind the work
|
|||||||
|
|
||||||
## Tests
|
## 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
|
```sh
|
||||||
panama test # everything
|
panama test # everything
|
||||||
|
|||||||
Executable
+69
@@ -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 <regex> [--title <regex>] -- 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 "$@"
|
||||||
@@ -136,13 +136,40 @@ 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
|
||||||
|
-- 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")
|
category("Applications")
|
||||||
bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
|
bind(mod .. " + T", hl.dsp.exec_cmd(launch_or_focus("^kitty$", terminal)), { description = "Terminal" })
|
||||||
bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
|
bind(mod .. " + N", hl.dsp.exec_cmd(launch_or_focus("^kitty$", editor, "nvim")), { description = "Neovim" })
|
||||||
bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
|
bind(mod .. " + W", hl.dsp.exec_cmd(launch_or_focus("^helium", browser)), { description = "Browser" })
|
||||||
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
|
bind(mod .. " + F", hl.dsp.exec_cmd(launch_or_focus("^org\\.gnome\\.Nautilus$", files)), { description = "Files" })
|
||||||
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
|
bind(mod .. " + C", hl.dsp.exec_cmd(launch_or_focus("^org\\.gnome\\.Calculator$", calculator)), { description = "Calculator" })
|
||||||
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
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(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" })
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ case "${1:-}" in
|
|||||||
address="$(cut -f1 <<<"$picked")"
|
address="$(cut -f1 <<<"$picked")"
|
||||||
pid="$(cut -f2 <<<"$picked")"
|
pid="$(cut -f2 <<<"$picked")"
|
||||||
if [[ "$1" == window ]]; then
|
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
|
fi
|
||||||
# TERM first; the window still being mapped a moment later means the app
|
# TERM first; the window still being mapped a moment later means the app
|
||||||
# ignored it, which is what force quit exists for.
|
# ignored it, which is what force quit exists for.
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# 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)$'
|
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)$'
|
||||||
|
|
||||||
|
|||||||
Executable
+123
@@ -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" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
if [[ "\$1" == "clients" ]]; then cat "$work/clients.json"; exit 0; fi
|
||||||
|
printf 'hyprctl %s\n' "\$*" >>"$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'
|
||||||
@@ -126,8 +126,8 @@ FIXTURE
|
|||||||
# Picking the second window focuses the second address.
|
# Picking the second window focuses the second address.
|
||||||
: >"$calls"
|
: >"$calls"
|
||||||
DMENU_ANSWER=1 PATH="$stub_dir:$PATH" "$pick" window >/dev/null 2>&1
|
DMENU_ANSWER=1 PATH="$stub_dir:$PATH" "$pick" window >/dev/null 2>&1
|
||||||
grep -q 'hyprctl dispatch focuswindow address:0xbbb' "$calls" \
|
grep -q 'hl.dsp.focus.*address:0xbbb' "$calls" \
|
||||||
|| note 'picking the second window did not focus the second address'
|
|| note 'picking the second window did not focus the second address (Hyprland 0.56 needs the Lua dispatch form)'
|
||||||
|
|
||||||
# Escape does nothing.
|
# Escape does nothing.
|
||||||
: >"$calls"
|
: >"$calls"
|
||||||
|
|||||||
Reference in New Issue
Block a user