aboutsummaryrefslogtreecommitdiff
path: root/tests/layout-navigate/test_layout_navigate.py
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-24 07:36:47 -0500
committerCraig Jennings <c@cjennings.net>2026-04-24 07:36:47 -0500
commit5477bf4a366dd2038b144aa542ce3785f205f368 (patch)
tree4297b04984310e6b2f3730e5df8e6f9cca62ab4f /tests/layout-navigate/test_layout_navigate.py
parenta0e3a6ffadd867153587b77bcf8727fdd34c5f7a (diff)
downloadarchsetup-5477bf4a366dd2038b144aa542ce3785f205f368.tar.gz
archsetup-5477bf4a366dd2038b144aa542ce3785f205f368.zip
fix(hyprland): Escape special workspace on navigate
When focus is inside a special workspace (e.g. special:stash), layoutmsg cyclenext/cycleprev only operates within that workspace, trapping $mod+J inside the scratchpad overlay. Detect workspace name starting with "special:" on focus navigation (not move), toggle the overlay off first, re-read active window state, then fall through to the normal layout/floating branches. Add unit tests with a fake hyprctl harness in tests/layout-navigate/. ```
Diffstat (limited to 'tests/layout-navigate/test_layout_navigate.py')
-rw-r--r--tests/layout-navigate/test_layout_navigate.py219
1 files changed, 219 insertions, 0 deletions
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()