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

@ -233,11 +233,33 @@ class TestFocusService(unittest.TestCase):
# -- debounce / transient focus ---------------------------------------
def test_transient_empty_focus_is_ignored(self):
def test_empty_focus_stops_app_controlled(self):
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
service._reconcile(_event("firefox"))
self.daemon.calls["start_injecting"].clear()
service._on_focus(_event(app_id=""))
self.assertIsNone(service._pending_event)
self.assertIsNone(service._debounce_id)
service._flush()
self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"])
self.assertEqual(service._app_controlled, {})
self.assertEqual(self.daemon.calls["start_injecting"], [])
def test_focus_event_for_bound_app_starts_injecting(self):
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
service._on_focus(_event("firefox"))
service._flush()
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p1")])
self.assertEqual(service._app_controlled, {"devX": "p1"})
def test_focus_event_for_unbound_app_stops_app_controlled(self):
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
service._reconcile(_event("firefox"))
self.daemon.calls["start_injecting"].clear()
service._on_focus(_event("code"))
service._flush()
self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"])
self.assertEqual(service._app_controlled, {})
self.assertEqual(self.daemon.calls["start_injecting"], [])
def test_debounce_collapses_rapid_focus(self):