Files
InputRemapper/tests/unit/test_focus_watcher.py
Blomios af10ec8bd1 fix(focus): détecter correctement l'unfocus sur les backends Wayland
Le suivi du focus laissait les injections app-controlled actives lors d'une
perte de focus réelle (fermeture de la fenêtre courante, retour à un bureau
vide). On corrige la détection sur toute la chaîne :

- FocusService: un app_id vide est désormais traité comme un unfocus réel
  (et non un focus transitoire ignoré), ce qui stoppe les injections liées
  à l'application.
- Sway: on ignore les events 'new' (pas un changement de focus) et on relit
  le focus courant sur un event 'title' non focused.
- WlrForeignToplevelBackend: suit _current_handle, le clear sur stop() et
  émet un FocusEvent vide quand le toplevel actuellement focus se ferme.

Tests: test_focus_service + test_focus_watcher (67) OK; lot feature
historique (295) OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 00:42:15 +02:00

425 lines
15 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import json
import unittest
from unittest.mock import patch
from inputremapper.focus.backends.hyprland import HyprlandFocusBackend
from inputremapper.focus.backends.null import NullBackend
from inputremapper.focus.backends.sway import (
SwayFocusBackend,
_container_to_event,
_find_focused,
)
from inputremapper.focus.backends.wlr_foreign_toplevel import (
WlrForeignToplevelBackend,
)
from inputremapper.focus.backends.xorg import XorgFocusBackend
from inputremapper.focus.focus_backend import FocusEvent
from inputremapper.focus.focus_watcher import focus_backend_classes, select_backend
from tests.lib.test_setup import test_setup
@test_setup
class TestSelectBackend(unittest.TestCase):
def _patch_availability(self, available, exclude=()):
"""Patch is_available of every backend class from a {name: bool} map."""
patchers = []
excluded = set(exclude)
for backend_class in focus_backend_classes:
if backend_class.name in excluded:
continue
value = available.get(backend_class.name, False)
patcher = patch.object(
backend_class,
"is_available",
staticmethod(lambda v=value: v),
)
patcher.start()
patchers.append(patcher)
self.addCleanup(lambda: [p.stop() for p in patchers])
def test_priority_order(self):
# registry order: sway, hyprland, wlr, xorg, null
self.assertEqual(
[c.name for c in focus_backend_classes],
["sway", "hyprland", "wlr-foreign-toplevel", "xorg", "null"],
)
def test_selects_sway_first(self):
self._patch_availability({"sway": True, "xorg": True})
self.assertIsInstance(select_backend(), SwayFocusBackend)
def test_selects_hyprland_when_no_sway(self):
self._patch_availability({"hyprland": True, "xorg": True})
self.assertIsInstance(select_backend(), HyprlandFocusBackend)
def test_selects_xorg_when_only_xorg(self):
self._patch_availability({"xorg": True})
self.assertIsInstance(select_backend(), XorgFocusBackend)
def test_falls_back_to_null(self):
self._patch_availability({})
self.assertIsInstance(select_backend(), NullBackend)
def test_availability_exception_is_swallowed(self):
def boom():
raise RuntimeError("nope")
with patch.object(SwayFocusBackend, "is_available", staticmethod(boom)):
self._patch_availability({"xorg": True}, exclude={"sway"})
# sway raises -> skipped, xorg wins
self.assertIsInstance(select_backend(), XorgFocusBackend)
@test_setup
class TestNullBackend(unittest.TestCase):
def test_always_available(self):
self.assertTrue(NullBackend.is_available())
def test_get_current_is_none(self):
backend = NullBackend()
self.assertIsNone(backend.get_current())
def test_start_and_stop_do_nothing(self):
backend = NullBackend()
calls = []
backend.start(lambda event: calls.append(event))
backend.stop()
self.assertEqual(calls, [])
@test_setup
class TestSwayParsing(unittest.TestCase):
def test_container_to_event_app_id(self):
event = _container_to_event({"app_id": "Alacritty", "name": "shell"})
self.assertEqual(event.app_id, "Alacritty")
self.assertEqual(event.title, "shell")
self.assertEqual(event.backend, "sway")
def test_container_to_event_xwayland_class_fallback(self):
# XWayland windows have no app_id but a window_properties.class
event = _container_to_event(
{
"app_id": None,
"name": "Firefox",
"window_properties": {"class": "firefox"},
}
)
self.assertEqual(event.app_id, "firefox")
self.assertEqual(event.title, "Firefox")
def test_container_to_event_empty_container(self):
self.assertIsNone(_container_to_event({}))
def test_container_to_event_missing_fields(self):
event = _container_to_event({"app_id": "x"})
self.assertEqual(event.app_id, "x")
self.assertEqual(event.title, "")
def test_find_focused_nested(self):
tree = {
"focused": False,
"nodes": [
{"focused": False, "nodes": []},
{
"focused": False,
"nodes": [{"focused": True, "app_id": "target", "nodes": []}],
},
],
"floating_nodes": [],
}
found = _find_focused(tree)
self.assertIsNotNone(found)
self.assertEqual(found["app_id"], "target")
def test_find_focused_floating(self):
tree = {
"focused": False,
"nodes": [],
"floating_nodes": [{"focused": True, "app_id": "floater"}],
}
self.assertEqual(_find_focused(tree)["app_id"], "floater")
def test_find_focused_none(self):
tree = {"focused": False, "nodes": [{"focused": False}]}
self.assertIsNone(_find_focused(tree))
def test_handle_event_emits_on_focus_change(self):
backend = SwayFocusBackend()
received = []
backend._on_focus = received.append
payload = json.dumps(
{"change": "focus", "container": {"app_id": "kitty", "name": "term"}}
).encode()
backend._handle_event(payload)
self.assertEqual(len(received), 1)
self.assertEqual(received[0].app_id, "kitty")
def test_handle_event_ignores_new_window(self):
backend = SwayFocusBackend()
received = []
backend._on_focus = received.append
payload = json.dumps(
{"change": "new", "container": {"app_id": "firefox", "name": "Mozilla"}}
).encode()
backend._handle_event(payload)
self.assertEqual(received, [])
def test_handle_event_title_nonfocused_requeries_current_focus(self):
backend = SwayFocusBackend()
received = []
backend._on_focus = received.append
current = FocusEvent(app_id="code", title="editor", backend="sway")
with patch.object(backend, "get_current", return_value=current) as get_current:
payload = json.dumps(
{
"change": "title",
"container": {
"focused": False,
"app_id": "firefox",
"name": "Background Mozilla",
},
}
).encode()
backend._handle_event(payload)
get_current.assert_called_once_with()
self.assertEqual(received, [current])
def test_handle_event_title_focused_uses_payload(self):
backend = SwayFocusBackend()
received = []
backend._on_focus = received.append
payload = json.dumps(
{
"change": "title",
"container": {"focused": True, "app_id": "kitty", "name": "term"},
}
).encode()
backend._handle_event(payload)
self.assertEqual(len(received), 1)
self.assertEqual(received[0].app_id, "kitty")
def test_handle_event_ignores_unrelated_change(self):
backend = SwayFocusBackend()
received = []
backend._on_focus = received.append
payload = json.dumps(
{"change": "move", "container": {"app_id": "kitty"}}
).encode()
backend._handle_event(payload)
self.assertEqual(received, [])
def test_handle_event_invalid_json(self):
backend = SwayFocusBackend()
received = []
backend._on_focus = received.append
backend._handle_event(b"\xff not json")
self.assertEqual(received, [])
def test_is_available_without_env(self):
with patch.dict("os.environ", {}, clear=True):
self.assertFalse(SwayFocusBackend.is_available())
@test_setup
class TestHyprlandParsing(unittest.TestCase):
def test_handle_line_activewindow(self):
backend = HyprlandFocusBackend()
received = []
backend._on_focus = received.append
backend._handle_line("activewindow>>firefox,Mozilla Firefox")
self.assertEqual(len(received), 1)
self.assertEqual(received[0].app_id, "firefox")
self.assertEqual(received[0].title, "Mozilla Firefox")
self.assertEqual(received[0].backend, "hyprland")
def test_handle_line_title_with_comma(self):
backend = HyprlandFocusBackend()
received = []
backend._on_focus = received.append
backend._handle_line("activewindow>>code,file.py, project - VSCode")
self.assertEqual(received[0].app_id, "code")
# only the first comma splits app_id from title
self.assertEqual(received[0].title, "file.py, project - VSCode")
def test_handle_line_ignores_other_events(self):
backend = HyprlandFocusBackend()
received = []
backend._on_focus = received.append
backend._handle_line("workspace>>2")
backend._handle_line("openwindow>>0x1,1,firefox,Title")
self.assertEqual(received, [])
def test_handle_line_no_separator(self):
backend = HyprlandFocusBackend()
received = []
backend._on_focus = received.append
backend._handle_line("garbage line")
self.assertEqual(received, [])
def test_is_available_without_signature(self):
with patch.dict("os.environ", {}, clear=True):
self.assertFalse(HyprlandFocusBackend.is_available())
@test_setup
class TestXorgBackend(unittest.TestCase):
def test_is_available_without_display(self):
with patch.dict("os.environ", {}, clear=True):
self.assertFalse(XorgFocusBackend.is_available())
def test_is_available_without_xlib(self):
with patch.dict("os.environ", {"DISPLAY": ":0"}, clear=True):
with patch.dict("sys.modules", {"Xlib": None, "Xlib.display": None}):
# importing Xlib.display raises ImportError -> not available
self.assertFalse(XorgFocusBackend.is_available())
def test_get_current_without_display(self):
backend = XorgFocusBackend()
self.assertIsNone(backend.get_current())
def test_stop_without_start(self):
backend = XorgFocusBackend()
# must not raise even though nothing was started
backend.stop()
@test_setup
class TestWlrBackend(unittest.TestCase):
def test_is_available_without_wayland(self):
with patch.dict("os.environ", {}, clear=True):
self.assertFalse(WlrForeignToplevelBackend.is_available())
def test_is_available_without_pywayland(self):
# pywayland is not installed in the dev environment, so even with
# WAYLAND_DISPLAY set the backend must report itself unavailable cleanly.
with patch.dict("os.environ", {"WAYLAND_DISPLAY": "wayland-0"}, clear=True):
with patch(
"inputremapper.focus.backends.wlr_foreign_toplevel._import_protocol",
return_value=None,
):
self.assertFalse(WlrForeignToplevelBackend.is_available())
def test_get_current_initially_none(self):
backend = WlrForeignToplevelBackend()
self.assertIsNone(backend.get_current())
def test_toplevel_activation_flow(self):
backend = WlrForeignToplevelBackend()
received = []
backend._on_focus = received.append
handle = object()
backend._toplevels[handle] = {"app_id": "", "title": "", "activated": False}
backend._on_app_id(handle, "firefox")
backend._on_title(handle, "Mozilla")
backend._on_state(handle, [2]) # _STATE_ACTIVATED
backend._on_done(handle)
self.assertEqual(len(received), 1)
self.assertEqual(received[0].app_id, "firefox")
self.assertEqual(received[0].title, "Mozilla")
self.assertEqual(backend.get_current().app_id, "firefox")
self.assertIs(backend._current_handle, handle)
def test_done_without_activation_is_ignored(self):
backend = WlrForeignToplevelBackend()
received = []
backend._on_focus = received.append
handle = object()
backend._toplevels[handle] = {
"app_id": "x",
"title": "y",
"activated": False,
}
backend._on_done(handle)
self.assertEqual(received, [])
def test_closed_removes_toplevel(self):
backend = WlrForeignToplevelBackend()
handle = object()
backend._toplevels[handle] = {"app_id": "x", "title": "", "activated": True}
backend._on_closed(handle)
self.assertNotIn(handle, backend._toplevels)
def test_closed_current_toplevel_emits_unfocus(self):
backend = WlrForeignToplevelBackend()
received = []
backend._on_focus = received.append
handle = object()
backend._toplevels[handle] = {
"app_id": "firefox",
"title": "Mozilla",
"activated": True,
}
backend._on_done(handle)
received.clear()
backend._on_closed(handle)
self.assertNotIn(handle, backend._toplevels)
self.assertIsNone(backend._current_handle)
self.assertEqual(
backend.get_current(),
FocusEvent(app_id="", title="", backend="wlr-foreign-toplevel"),
)
self.assertEqual(received, [backend.get_current()])
def test_closed_non_current_toplevel_does_not_emit(self):
backend = WlrForeignToplevelBackend()
received = []
backend._on_focus = received.append
current_handle = object()
closed_handle = object()
backend._toplevels[current_handle] = {
"app_id": "code",
"title": "Editor",
"activated": True,
}
backend._toplevels[closed_handle] = {
"app_id": "firefox",
"title": "Mozilla",
"activated": False,
}
backend._on_done(current_handle)
current = backend.get_current()
received.clear()
backend._on_closed(closed_handle)
self.assertNotIn(closed_handle, backend._toplevels)
self.assertIs(backend._current_handle, current_handle)
self.assertEqual(backend.get_current(), current)
self.assertEqual(received, [])
def test_state_unknown_handle_ignored(self):
backend = WlrForeignToplevelBackend()
# must not raise for a handle we never registered
backend._on_app_id(object(), "x")
backend._on_state(object(), [2])
if __name__ == "__main__":
unittest.main()