diff --git a/bin/omarchy-launch-screensaver b/bin/omarchy-launch-screensaver index c66c41da..78cf7571 100755 --- a/bin/omarchy-launch-screensaver +++ b/bin/omarchy-launch-screensaver @@ -29,7 +29,8 @@ hypr_exec() { local command printf -v command '%q ' "$@" - hyprctl dispatch "hl.dsp.exec_cmd([[$command]])" >/dev/null 2>&1 || hyprctl dispatch exec -- bash -lc "$command" >/dev/null + hyprctl dispatch "hl.dsp.exec_cmd([[[workspace $workspace] $command]])" >/dev/null 2>&1 || + hyprctl dispatch exec -- "[workspace $workspace]" bash -lc "$command" >/dev/null } SOCKET="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" @@ -49,8 +50,10 @@ wait_for_screensaver_window() { done } -for m in $(hyprctl monitors -j | jq -r '.[] | .name'); do +while read -r m special; do hypr_focus_monitor "$m" + # Share a special workspace that is already showing, like the scratchpad, so it is still open afterwards. + workspace=${special:-special:screensaver-$m} case $terminal in *Alacritty*) @@ -68,6 +71,16 @@ for m in $(hyprctl monitors -j | jq -r '.[] | .name'); do esac wait_for_screensaver_window -done +done < <(hyprctl monitors -j | jq -r '.[] | "\(.name) \(.specialWorkspace.name)"') hypr_focus_monitor "$focused" + +# Emptying a special workspace focuses its monitor, so the last screensaver to close would take focus +# with it. Hand focus back to the monitor that had it once they are all gone. +# The class goes through the environment, since in argv the screensaver's pgrep and pkill would match it. +( + while hyprctl clients -j | SCREENSAVER=org.omarchy.screensaver jq -e 'any(.[]; .mapped and .class == env.SCREENSAVER)' >/dev/null; do + IFS= read -r -u "$events" line || exit + done + hypr_focus_monitor "$focused" +) >/dev/null 2>&1 & diff --git a/default/hypr/apps/system.lua b/default/hypr/apps/system.lua index 3235e7b9..043fe12c 100644 --- a/default/hypr/apps/system.lua +++ b/default/hypr/apps/system.lua @@ -33,6 +33,9 @@ o.window("omacalc", { float = true }) o.window("org.omarchy.screensaver", { fullscreen = true }) o.window("org.omarchy.screensaver", { float = true }) o.window("org.omarchy.screensaver", { animation = "slide" }) +-- The launcher picks each screensaver's workspace. A terminal mapped again as it closes lands out of sight instead, +-- where its fullscreen rule cannot take fullscreen from a window. +o.window("org.omarchy.screensaver", { workspace = "special:screensaver silent" }) -- No transparency on media windows. o.window( diff --git a/test/shell.d/screensaver-workspace-test.sh b/test/shell.d/screensaver-workspace-test.sh new file mode 100644 index 00000000..9583535d --- /dev/null +++ b/test/shell.d/screensaver-workspace-test.sh @@ -0,0 +1,110 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +require_command jq +require_command lua + +tmpdir=$(mktemp -d) +cleanup() { + [[ -f $tmpdir/socat.pid ]] && kill "$(<"$tmpdir/socat.pid")" 2>/dev/null || true + rm -rf "$tmpdir" +} +trap cleanup EXIT + +mkdir -p "$tmpdir/bin" +mkfifo "$tmpdir/events" +# Held open for writing so the event reader never sees end of file between events. +exec {events}<>"$tmpdir/events" + +cat >"$tmpdir/bin/hyprctl" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$TEST_DIR/calls" +case "$*" in + 'monitors -j') + printf '[{"name":"DP-1","specialWorkspace":{"name":""}},{"name":"DP-2","specialWorkspace":{"name":"special:scratchpad"}}]\n' + ;; + 'clients -j') + cat "$TEST_DIR/clients.json" + ;; + *exec_cmd*) + count=$(($(wc -l <"$TEST_DIR/spawned") + 1)) + printf '%s\n' "$count" >>"$TEST_DIR/spawned" + printf 'openwindow>>%s,1,org.omarchy.screensaver,foot\n' "$count" >"$TEST_DIR/events" + ;; +esac +SH +cat >"$tmpdir/bin/socat" <<'SH' +#!/bin/bash +printf '%s\n' "$$" >"$TEST_DIR/socat.pid" +exec cat "$TEST_DIR/events" +SH +printf '#!/bin/bash\nexit 1\n' >"$tmpdir/bin/pgrep" +printf '#!/bin/bash\nexit 1\n' >"$tmpdir/bin/omarchy-toggle-enabled" +printf '#!/bin/bash\necho DP-1\n' >"$tmpdir/bin/omarchy-hyprland-monitor-focused" +printf '#!/bin/bash\necho foot.desktop\n' >"$tmpdir/bin/xdg-terminal-exec" +chmod +x "$tmpdir/bin/"* + +: >"$tmpdir/calls" +: >"$tmpdir/spawned" +printf '[{"class":"org.omarchy.screensaver","mapped":true}]\n' >"$tmpdir/clients.json" + +PATH="$tmpdir/bin:$PATH" TEST_DIR="$tmpdir" XDG_RUNTIME_DIR="$tmpdir" HYPRLAND_INSTANCE_SIGNATURE=test \ + timeout 10 "$ROOT/bin/omarchy-launch-screensaver" force + +mapfile -t spawns < <(grep exec_cmd "$tmpdir/calls") +(( ${#spawns[@]} == 2 )) || fail "a screensaver opens on each monitor" "$(<"$tmpdir/calls")" +[[ ${spawns[0]} == *"[workspace special:screensaver-DP-1]"* ]] || + fail "the screensaver opens on its own special workspace, leaving a fullscreen window alone" "${spawns[0]}" +pass "the screensaver opens on its own special workspace, leaving a fullscreen window alone" +[[ ${spawns[1]} == *"[workspace special:scratchpad]"* ]] || + fail "the screensaver shares a special workspace that is already showing" "${spawns[1]}" +pass "the screensaver shares a special workspace that is already showing" + +# Emptying a special workspace focuses its monitor; the last screensaver to close must not keep focus. +: >"$tmpdir/calls" +printf 'closewindow>>1\n' >&"$events" +sleep 0.5 +grep -q 'hl.dsp.focus' "$tmpdir/calls" && fail "focus waits until the last screensaver has closed" "$(<"$tmpdir/calls")" +pass "focus waits until the last screensaver has closed" +printf '[{"class":"org.omarchy.screensaver","mapped":false}]\n' >"$tmpdir/clients.json" +printf 'closewindow>>2\n' >&"$events" +for (( attempt = 0; attempt < 100; attempt++ )); do + grep -q 'hl.dsp.focus({ monitor = "DP-1" })' "$tmpdir/calls" && break + sleep 0.05 +done +grep -q 'hl.dsp.focus({ monitor = "DP-1" })' "$tmpdir/calls" || + fail "focus returns to the monitor that had it once the screensaver closes" "$(<"$tmpdir/calls")" +pass "focus returns to the monitor that had it once the screensaver closes" + +# Screensavers can close while the launcher is still waiting on another monitor, consuming their events. +kill "$(<"$tmpdir/socat.pid")" +: >"$tmpdir/calls" +: >"$tmpdir/spawned" +PATH="$tmpdir/bin:$PATH" TEST_DIR="$tmpdir" XDG_RUNTIME_DIR="$tmpdir" HYPRLAND_INSTANCE_SIGNATURE=test \ + timeout 10 "$ROOT/bin/omarchy-launch-screensaver" force +for (( attempt = 0; attempt < 100; attempt++ )); do + (( $(grep -c 'hl.dsp.focus({ monitor = "DP-1" })' "$tmpdir/calls") == 3 )) && break + sleep 0.05 +done +(( $(grep -c 'hl.dsp.focus({ monitor = "DP-1" })' "$tmpdir/calls") == 3 )) || + fail "focus returns without waiting for a close that has already happened" "$(<"$tmpdir/calls")" +pass "focus returns without waiting for a close that has already happened" + +# The launcher's workspace only holds for the first map. A terminal mapped again as it closes falls back to +# the class rule, which must keep it off the regular workspaces where its fullscreen rule would take over. +fallback=$(OMARCHY_PATH="$ROOT" lua <<'LUA' +package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path +hl = setmetatable({ + window_rule = function(rule) + if rule.match.class == "org.omarchy.screensaver" and rule.workspace then print(rule.workspace) end + end, +}, { __index = function() return function() return {} end end }) +require("default.hypr.helpers") +require("default.hypr.apps.system") +LUA +) +[[ $fallback == "special:screensaver silent" ]] || + fail "a screensaver mapped again as it closes stays off the regular workspaces" "workspace rule: ${fallback:-none}" +pass "a screensaver mapped again as it closes stays off the regular workspaces"