From 5477bf4a366dd2038b144aa542ce3785f205f368 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 24 Apr 2026 07:36:47 -0500 Subject: 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/. ``` --- tests/layout-navigate/test_layout_navigate.py | 219 ++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 tests/layout-navigate/test_layout_navigate.py (limited to 'tests/layout-navigate/test_layout_navigate.py') 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() -- cgit v1.2.3