aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xdotfiles/hyprland/.local/bin/layout-navigate20
-rwxr-xr-xtests/layout-navigate/fake-hyprctl48
-rw-r--r--tests/layout-navigate/test_layout_navigate.py219
-rw-r--r--todo.org18
4 files changed, 304 insertions, 1 deletions
diff --git a/dotfiles/hyprland/.local/bin/layout-navigate b/dotfiles/hyprland/.local/bin/layout-navigate
index 352db79..89af45f 100755
--- a/dotfiles/hyprland/.local/bin/layout-navigate
+++ b/dotfiles/hyprland/.local/bin/layout-navigate
@@ -6,9 +6,27 @@
DIR="$1"
MOVE="$2"
-FLOATING=$(hyprctl activewindow -j | jq -r '.floating')
+
+ACTIVE_JSON=$(hyprctl activewindow -j)
+FLOATING=$(echo "$ACTIVE_JSON" | jq -r '.floating')
+WS_NAME=$(echo "$ACTIVE_JSON" | jq -r '.workspace.name')
LAYOUT=$(hyprctl getoption general:layout -j | jq -r '.str')
+# If the active window is in a special workspace (scratchpad overlay) and we
+# are navigating focus (not moving), hide the overlay first. layoutmsg/cyclenext
+# cannot cross the overlay→regular boundary, so without this the $mod+J key
+# gets trapped inside the scratchpad.
+case "$WS_NAME" in
+ special:*)
+ if [ "$MOVE" != "move" ]; then
+ hyprctl dispatch togglespecialworkspace "${WS_NAME#special:}"
+ # Re-read state: focus has moved to the regular workspace.
+ ACTIVE_JSON=$(hyprctl activewindow -j)
+ FLOATING=$(echo "$ACTIVE_JSON" | jq -r '.floating')
+ fi
+ ;;
+esac
+
# If current window is floating, use cyclenext to reach tiled windows
if [ "$FLOATING" = "true" ] && [ "$MOVE" != "move" ]; then
if [ "$DIR" = "next" ]; then
diff --git a/tests/layout-navigate/fake-hyprctl b/tests/layout-navigate/fake-hyprctl
new file mode 100755
index 0000000..701f397
--- /dev/null
+++ b/tests/layout-navigate/fake-hyprctl
@@ -0,0 +1,48 @@
+#!/bin/sh
+# Fake hyprctl for testing layout-navigate.
+#
+# State files live in $FAKE_HYPR_DIR:
+# activewindow.json - first activewindow call returns this
+# activewindow.1.json - second call (after togglespecialworkspace) returns this, if present
+# layout.json - getoption general:layout returns this
+# dispatch.log - every "dispatch" invocation appended here (one line)
+# call-count - internal counter for activewindow calls
+
+: "${FAKE_HYPR_DIR:?FAKE_HYPR_DIR must be set}"
+
+cmd="$1"
+shift
+
+case "$cmd" in
+ activewindow)
+ # Count calls so tests can provide a post-toggle state
+ count_file="$FAKE_HYPR_DIR/call-count"
+ count=$(cat "$count_file" 2>/dev/null || echo 0)
+ next=$((count + 1))
+ echo "$next" > "$count_file"
+
+ if [ "$count" -eq 0 ]; then
+ cat "$FAKE_HYPR_DIR/activewindow.json"
+ else
+ # Try numbered file; fall back to original
+ numbered="$FAKE_HYPR_DIR/activewindow.$count.json"
+ if [ -f "$numbered" ]; then
+ cat "$numbered"
+ else
+ cat "$FAKE_HYPR_DIR/activewindow.json"
+ fi
+ fi
+ ;;
+ getoption)
+ cat "$FAKE_HYPR_DIR/layout.json"
+ ;;
+ dispatch)
+ # Log the entire dispatch invocation as one line
+ echo "dispatch $*" >> "$FAKE_HYPR_DIR/dispatch.log"
+ echo "ok"
+ ;;
+ *)
+ echo "fake-hyprctl: unknown command '$cmd'" >&2
+ exit 1
+ ;;
+esac
diff --git a/tests/layout-navigate/test_layout_navigate.py b/tests/layout-navigate/test_layout_navigate.py
new file mode 100644
index 0000000..41294b7
--- /dev/null
+++ b/tests/layout-navigate/test_layout_navigate.py
@@ -0,0 +1,219 @@
+"""Tests for dotfiles/hyprland/.local/bin/layout-navigate.
+
+The script is a sh wrapper around `hyprctl` that chooses the right dispatch
+command for the active layout (master/dwindle/scrolling) and for the active
+window's state (floating vs tiled, regular workspace vs special overlay).
+
+Tests invoke the real script with a faked `hyprctl` on PATH. The fake reads
+canned JSON for activewindow/getoption queries and records each dispatch
+call to a log file. Assertions compare the dispatch log to the expected
+sequence for the given scenario — we test behavior (what hyprctl calls
+the script emits), not implementation.
+"""
+
+import json
+import os
+import stat
+import subprocess
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+SCRIPT = os.path.join(REPO_ROOT, "dotfiles/hyprland/.local/bin/layout-navigate")
+FAKE_HYPRCTL = os.path.join(os.path.dirname(__file__), "fake-hyprctl")
+
+
+def make_activewindow(floating=False, workspace_name="1", workspace_id=1):
+ return {
+ "address": "0xabc",
+ "floating": bool(floating),
+ "workspace": {"id": workspace_id, "name": workspace_name},
+ "class": "test",
+ "title": "test",
+ }
+
+
+def make_layout(name):
+ return {"str": name}
+
+
+class LayoutNavigateHarness(unittest.TestCase):
+ """Shared harness: run layout-navigate with fake hyprctl, read dispatch log."""
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="layout-navigate-test-")
+ # Ensure the fake hyprctl is executable
+ os.chmod(FAKE_HYPRCTL, os.stat(FAKE_HYPRCTL).st_mode | stat.S_IEXEC)
+
+ # Create a bin dir with a symlink to fake-hyprctl named "hyprctl"
+ self.bin_dir = os.path.join(self.tmp, "bin")
+ os.makedirs(self.bin_dir)
+ os.symlink(FAKE_HYPRCTL, os.path.join(self.bin_dir, "hyprctl"))
+
+ # Initialize dispatch log
+ self.dispatch_log = os.path.join(self.tmp, "dispatch.log")
+ open(self.dispatch_log, "w").close()
+
+ def tearDown(self):
+ import shutil
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def set_state(self, activewindow, layout, next_activewindow=None):
+ with open(os.path.join(self.tmp, "activewindow.json"), "w") as f:
+ json.dump(activewindow, f)
+ with open(os.path.join(self.tmp, "layout.json"), "w") as f:
+ json.dump(layout, f)
+ if next_activewindow is not None:
+ with open(os.path.join(self.tmp, "activewindow.1.json"), "w") as f:
+ json.dump(next_activewindow, f)
+
+ def run_script(self, *args):
+ # Preserve current PATH so jq, sh, etc. are reachable
+ env = os.environ.copy()
+ env["PATH"] = self.bin_dir + os.pathsep + env.get("PATH", "")
+ env["FAKE_HYPR_DIR"] = self.tmp
+ result = subprocess.run(
+ [SCRIPT] + list(args),
+ env=env,
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ return result
+
+ def dispatches(self):
+ with open(self.dispatch_log) as f:
+ return [line.rstrip("\n") for line in f if line.strip()]
+
+
+class TestTiledMasterLayout(LayoutNavigateHarness):
+ """Characterization: existing behavior for master/dwindle layout on a regular workspace."""
+
+ def test_layout_navigate_master_tiled_next_focus_emits_cyclenext(self):
+ self.set_state(make_activewindow(), make_layout("master"))
+ self.run_script("next")
+ self.assertEqual(self.dispatches(), ["dispatch layoutmsg cyclenext"])
+
+ def test_layout_navigate_master_tiled_prev_focus_emits_cycleprev(self):
+ self.set_state(make_activewindow(), make_layout("master"))
+ self.run_script("prev")
+ self.assertEqual(self.dispatches(), ["dispatch layoutmsg cycleprev"])
+
+ def test_layout_navigate_master_tiled_next_move_emits_swapnext(self):
+ self.set_state(make_activewindow(), make_layout("master"))
+ self.run_script("next", "move")
+ self.assertEqual(self.dispatches(), ["dispatch layoutmsg swapnext"])
+
+ def test_layout_navigate_master_tiled_prev_move_emits_swapprev(self):
+ self.set_state(make_activewindow(), make_layout("master"))
+ self.run_script("prev", "move")
+ self.assertEqual(self.dispatches(), ["dispatch layoutmsg swapprev"])
+
+
+class TestScrollingLayout(LayoutNavigateHarness):
+ """Characterization: existing behavior for scrolling layout on a regular workspace."""
+
+ def test_layout_navigate_scrolling_next_focus_emits_focus_l(self):
+ self.set_state(make_activewindow(), make_layout("scrolling"))
+ self.run_script("next")
+ self.assertEqual(self.dispatches(), ["dispatch layoutmsg focus l"])
+
+ def test_layout_navigate_scrolling_next_move_emits_swapwindow_l(self):
+ self.set_state(make_activewindow(), make_layout("scrolling"))
+ self.run_script("next", "move")
+ self.assertEqual(self.dispatches(), ["dispatch swapwindow l"])
+
+
+class TestFloatingOnRegularWorkspace(LayoutNavigateHarness):
+ """Characterization: floating window on a regular workspace short-circuits to cyclenext tiled."""
+
+ def test_layout_navigate_floating_regular_next_focus_emits_cyclenext_tiled(self):
+ self.set_state(make_activewindow(floating=True), make_layout("master"))
+ self.run_script("next")
+ self.assertEqual(self.dispatches(), ["dispatch cyclenext tiled"])
+
+ def test_layout_navigate_floating_regular_prev_focus_emits_cyclenext_prev_tiled(self):
+ self.set_state(make_activewindow(floating=True), make_layout("master"))
+ self.run_script("prev")
+ self.assertEqual(self.dispatches(), ["dispatch cyclenext prev tiled"])
+
+
+class TestTiledInSpecialWorkspace(LayoutNavigateHarness):
+ """New behavior: tiled window in a special workspace toggles overlay off, then cycles.
+
+ The special:stash overlay (or any special workspace) hides the underlying regular
+ workspace. Cycling with layoutmsg only operates within the current workspace, so
+ without toggling first, $mod+J gets trapped inside the overlay. Fix: hide the
+ overlay, then dispatch the normal cycle — one keypress does both.
+ """
+
+ def test_layout_navigate_tiled_special_next_focus_toggles_then_cyclenext(self):
+ active = make_activewindow(workspace_name="special:stash", workspace_id=-92)
+ # Post-toggle, focus lands on a tiled window on regular ws 1
+ post = make_activewindow(workspace_name="1", workspace_id=1)
+ self.set_state(active, make_layout("master"), next_activewindow=post)
+ self.run_script("next")
+ self.assertEqual(
+ self.dispatches(),
+ [
+ "dispatch togglespecialworkspace stash",
+ "dispatch layoutmsg cyclenext",
+ ],
+ )
+
+ def test_layout_navigate_tiled_special_prev_focus_toggles_then_cycleprev(self):
+ active = make_activewindow(workspace_name="special:stash", workspace_id=-92)
+ post = make_activewindow(workspace_name="1", workspace_id=1)
+ self.set_state(active, make_layout("master"), next_activewindow=post)
+ self.run_script("prev")
+ self.assertEqual(
+ self.dispatches(),
+ [
+ "dispatch togglespecialworkspace stash",
+ "dispatch layoutmsg cycleprev",
+ ],
+ )
+
+ def test_layout_navigate_tiled_special_scrolling_toggles_then_focus_l(self):
+ """Toggle-then-cycle must honor the active layout, not hard-code master."""
+ active = make_activewindow(workspace_name="special:stash", workspace_id=-92)
+ post = make_activewindow(workspace_name="1", workspace_id=1)
+ self.set_state(active, make_layout("scrolling"), next_activewindow=post)
+ self.run_script("next")
+ self.assertEqual(
+ self.dispatches(),
+ [
+ "dispatch togglespecialworkspace stash",
+ "dispatch layoutmsg focus l",
+ ],
+ )
+
+ def test_layout_navigate_tiled_special_next_move_does_not_toggle(self):
+ """MOVE variant should NOT auto-toggle — moving a window out of a scratchpad
+ is a separate UX we don't want triggered by the common navigate key."""
+ active = make_activewindow(workspace_name="special:stash", workspace_id=-92)
+ self.set_state(active, make_layout("master"))
+ self.run_script("next", "move")
+ self.assertEqual(self.dispatches(), ["dispatch layoutmsg swapnext"])
+
+ def test_layout_navigate_floating_special_next_focus_toggles_first(self):
+ """Floating scratchpad (e.g. special:S-term foot) should also toggle off.
+ After the toggle, the re-read state determines whether to take the
+ floating branch or fall through to the layout branch."""
+ active = make_activewindow(floating=True, workspace_name="special:S-term", workspace_id=-98)
+ # After toggling S-term off, focus lands on a tiled window on ws 1
+ post = make_activewindow(floating=False, workspace_name="1", workspace_id=1)
+ self.set_state(active, make_layout("master"), next_activewindow=post)
+ self.run_script("next")
+ self.assertEqual(
+ self.dispatches(),
+ [
+ "dispatch togglespecialworkspace S-term",
+ "dispatch layoutmsg cyclenext",
+ ],
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/todo.org b/todo.org
index 4cfc5e0..b2b9f29 100644
--- a/todo.org
+++ b/todo.org
@@ -375,6 +375,24 @@ Detect NVIDIA GPU and warn user about potential Wayland issues:
- Document required env vars (LIBVA_DRIVER_NAME, GBM_BACKEND, etc.)
- Prompt to continue or abort if NVIDIA detected
+** DONE [#B] Extend layout-navigate to escape special workspaces
+CLOSED: [2026-04-19 Sun]
+With the =special:stash= overlay visible and focus on a window inside it,
+=$mod+J= was trapped because =layoutmsg cyclenext= only operates within the
+current workspace. The 2026-04-09 fix handled floating→tiled but not
+special-workspace→regular.
+
+Fix in =dotfiles/hyprland/.local/bin/layout-navigate=: when the active
+window's =workspace.name= begins with =special:= and the user is navigating
+focus (not moving), dispatch =togglespecialworkspace <name>= first, re-read
+activewindow state, then fall through to the existing floating/layout
+branches. Move variant (=$mod SHIFT J=) is intentionally left untouched so
+moving a window out of a scratchpad remains a deliberate separate action.
+
+Unit tests live in =tests/layout-navigate/= (stdlib =unittest=, fakes
+=hyprctl= via PATH). Run with:
+=python3 -m unittest tests.layout-navigate.test_layout_navigate=
+
** TODO [#B] Add org-capture popup frame on keyboard shortcut
Set up a quick-capture popup using emacsclient that opens a small floating
org-capture frame, with Hyprland window rules to float, size, and center it.