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>
425 lines
15 KiB
Python
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()
|