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>
This commit is contained in:
2026-06-20 00:42:15 +02:00
parent 53fe0cc369
commit af10ec8bd1
5 changed files with 139 additions and 11 deletions

View File

@ -33,6 +33,7 @@ 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
@ -173,6 +174,52 @@ class TestSwayParsing(unittest.TestCase):
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 = []
@ -294,6 +341,7 @@ class TestWlrBackend(unittest.TestCase):
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()
@ -315,6 +363,56 @@ class TestWlrBackend(unittest.TestCase):
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