From ddab35b5b9de4bdcbc2f123711aadc01154bad67 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Thu, 29 Oct 2026 17:05:26 -0700 Subject: [PATCH] feat(nix): the focus_follows_mouse scenario, with a pointer With focus follows mouse on, the pointer over a window gives it the focus, and the daemon follows; turned off, it moves nothing; turned on again, it follows again. The scenario needs a pointer and is skipped without one. The toggle passes --implementation windows, which kfw's subcommand requires. drag-resize runs it after resize. --- .../core/scenarios/focus_follows_mouse.py | 78 +++++++++++++++++++ nix/tests/vm/lib/tests/fake_platform.py | 10 +++ nix/tests/vm/lib/tests/test_focus_moving.py | 30 +++++++ nix/tests/vm/suites/drag-resize/test.py | 5 +- 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 nix/tests/vm/lib/komorebi_vm/core/scenarios/focus_follows_mouse.py diff --git a/nix/tests/vm/lib/komorebi_vm/core/scenarios/focus_follows_mouse.py b/nix/tests/vm/lib/komorebi_vm/core/scenarios/focus_follows_mouse.py new file mode 100644 index 00000000..44f03f54 --- /dev/null +++ b/nix/tests/vm/lib/komorebi_vm/core/scenarios/focus_follows_mouse.py @@ -0,0 +1,78 @@ +"""With focus follows mouse on, the window under the pointer has the focus; with it off, not.""" + +from komorebi_vm.core.checks import Checks +from komorebi_vm.core.platform import Client, Platform + + +def _middle(platform: Platform, client: Client) -> tuple[int, int] | None: + window = platform.os_window(client) + if window is None: + return None + return window.frame.x + window.frame.w // 2, window.frame.y + window.frame.h // 2 + + +def _focused(platform: Platform, client: Client) -> bool: + window = platform.os_window(client) + return window is not None and window.focused + + +def _settle(platform: Platform) -> None: + """Gives a platform the time to take up a setting: KWin reads its focus policy about 200 ms + after it is written, and a pointer that is moved before that is moved under the old one.""" + platform.wait_for(lambda: False, timeout=1) + + +def run(platform: Platform, checks: Checks, first: Client, second: Client) -> None: + """Turns focus follows mouse on, moves the pointer over `first` and `second` in turn, turns it + off, and on again, and checks where the focus is. + + `first` and `second` are two tiled windows in view, not on each other. The setting is left off + at the end. The platform needs a pointer; without one the scenario says so and stops. + """ + if not platform.has("pointer"): + print(" skipped: this platform has no pointer") + return + a, b = _middle(platform, first), _middle(platform, second) + checks.check("both windows have a middle for the pointer", a is not None and b is not None) + if a is None or b is None: + return + + platform.komorebic("focus-follows-mouse", "enable") + _settle(platform) + platform.pointer.move(*a) + checks.check( + "the pointer over the first window gives it the focus", + platform.wait_for(lambda: _focused(platform, first), timeout=10), + ) + platform.pointer.move(*b) + checks.check( + "over the second, the second", + platform.wait_for(lambda: _focused(platform, second), timeout=10), + ) + platform.pointer.move(*a) + checks.check("and back", platform.wait_for(lambda: _focused(platform, first), timeout=10)) + checks.check( + "the daemon follows: it has the first window focused", + platform.wait_for(lambda: platform.state().focused_title() == first.title, timeout=10), + ) + + platform.komorebic("toggle-focus-follows-mouse", "--implementation", "windows") + _settle(platform) + platform.pointer.move(*b) + checks.check( + "turned off, the pointer over the second window no longer moves the focus", + not platform.wait_for(lambda: _focused(platform, second), timeout=3) + and _focused(platform, first), + ) + + platform.komorebic("focus-follows-mouse", "enable") + _settle(platform) + # the pointer rests over the second window: it has to go into a window to be followed + platform.pointer.move(*a) + platform.wait_for(lambda: _focused(platform, first), timeout=10) + platform.pointer.move(*b) + checks.check( + "on again, the pointer over the second window gives it the focus", + platform.wait_for(lambda: _focused(platform, second), timeout=10), + ) + platform.komorebic("focus-follows-mouse", "disable") diff --git a/nix/tests/vm/lib/tests/fake_platform.py b/nix/tests/vm/lib/tests/fake_platform.py index e0a4dec8..d444b54c 100644 --- a/nix/tests/vm/lib/tests/fake_platform.py +++ b/nix/tests/vm/lib/tests/fake_platform.py @@ -31,6 +31,10 @@ class FakePointer: def move(self, x: int, y: int) -> None: self.at = (x, y) + if self.platform.follows_mouse: + for i, tile in enumerate(self.platform.tiles[: len(self.platform.containers)]): + if tile.x <= x < tile.x + tile.w and tile.y <= y < tile.y + tile.h: + self.platform.focus = i def down(self) -> None: pass @@ -68,6 +72,8 @@ class FakePlatform: self.rules: dict[str, int] = {} self.ignored: set[str] = set() self.placing = False + self.follows_mouse = False + self.mouse_ignored = False self.prefix_rules: list[tuple[str, int]] = [] self.unmanaged: list[str] = [] self.display_count = 2 @@ -284,6 +290,10 @@ class FakePlatform: match args: case ("stop",): self.running = False + case ("focus-follows-mouse", "enable" | "disable" as state): + self.follows_mouse = state == "enable" and not self.mouse_ignored + case ("toggle-focus-follows-mouse", *_): + self.follows_mouse = not self.follows_mouse and not self.mouse_ignored case ("focus", "right"): self.focus = min(self.focus + 1, n - 1) case ("focus", "left"): diff --git a/nix/tests/vm/lib/tests/test_focus_moving.py b/nix/tests/vm/lib/tests/test_focus_moving.py index 662aaea6..ffad76ed 100644 --- a/nix/tests/vm/lib/tests/test_focus_moving.py +++ b/nix/tests/vm/lib/tests/test_focus_moving.py @@ -5,6 +5,7 @@ from komorebi_vm.core.scenarios import ( closing, commands, focus, + focus_follows_mouse, hotplug, lifecycle, monocle, @@ -338,3 +339,32 @@ def test_a_daemon_that_ignores_the_rules_of_its_file_fails_startup_rules() -> No checks = Checks() startup_rules.run(platform, checks) assert checks.failed >= 3, checks.failures + + +def test_a_daemon_that_follows_the_mouse_passes_focus_follows_mouse() -> None: + platform = FakePlatform() + checks = Checks() + clients = opened(platform, checks) + base = checks.passed + focus_follows_mouse.run(platform, checks, clients[0], clients[1]) + assert checks.failed == 0, checks.failures + assert checks.passed - base == 7 + assert not platform.follows_mouse + + +def test_a_daemon_that_does_not_follow_the_mouse_fails_focus_follows_mouse() -> None: + platform = FakePlatform() + platform.mouse_ignored = True + checks = Checks() + clients = opened(platform, checks) + focus_follows_mouse.run(platform, checks, clients[0], clients[1]) + assert checks.failed >= 2, checks.failures + + +def test_focus_follows_mouse_is_skipped_without_a_pointer() -> None: + platform = FakePlatform(pointer=False) + checks = Checks() + clients = opened(platform, checks) + base = checks.passed + focus_follows_mouse.run(platform, checks, clients[0], clients[1]) + assert checks.failed == 0 and checks.passed == base diff --git a/nix/tests/vm/suites/drag-resize/test.py b/nix/tests/vm/suites/drag-resize/test.py index 7041364e..680124b3 100644 --- a/nix/tests/vm/suites/drag-resize/test.py +++ b/nix/tests/vm/suites/drag-resize/test.py @@ -19,7 +19,7 @@ import time from komorebi_vm import Checks, Kde, Pointer, daemon from komorebi_vm.core.platform import Client -from komorebi_vm.core.scenarios import resize +from komorebi_vm.core.scenarios import focus_follows_mouse, resize from komorebi_vm.kde_platform import KdePlatform # the module's unit, which the session starts when it logs in: the daemon the suite drags against @@ -274,4 +274,7 @@ resize.run( [Client(f"win{n}") for n in range(1, WINDOWS + 1)], ) +print("== focus follows mouse, through the neutral platform") +focus_follows_mouse.run(KdePlatform(kde, (W, H), pointer), checks, Client("win1"), Client("win3")) + checks.finish()