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>
365 lines
15 KiB
Python
365 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 typing import List, Optional
|
|
from unittest.mock import MagicMock
|
|
|
|
from inputremapper.configs.app_binding import AppBinding, BoundPreset, MatchType
|
|
from inputremapper.configs.global_config import GlobalConfig
|
|
from inputremapper.focus.focus_backend import FocusBackend, FocusEvent
|
|
from inputremapper.focus.focus_service import FocusService
|
|
from tests.lib.patches import FakeDaemonProxy
|
|
from tests.lib.test_setup import test_setup
|
|
|
|
|
|
def _event(app_id="firefox", title="", backend="fake") -> FocusEvent:
|
|
return FocusEvent(app_id=app_id, title=title, backend=backend)
|
|
|
|
|
|
class FakeBackend(FocusBackend):
|
|
name = "fake"
|
|
|
|
def __init__(self, current: Optional[FocusEvent] = None):
|
|
self._current = current
|
|
self.started = False
|
|
self.stopped = False
|
|
self._on_focus = None
|
|
self.calls = []
|
|
|
|
@staticmethod
|
|
def is_available() -> bool:
|
|
return True
|
|
|
|
def start(self, on_focus) -> None:
|
|
self.started = True
|
|
self._on_focus = on_focus
|
|
self.calls.append("start")
|
|
|
|
def stop(self) -> None:
|
|
self.stopped = True
|
|
|
|
def get_current(self) -> Optional[FocusEvent]:
|
|
self.calls.append("get_current")
|
|
return self._current
|
|
|
|
def emit(self, event: FocusEvent) -> None:
|
|
assert self._on_focus is not None
|
|
self._on_focus(event)
|
|
|
|
|
|
@test_setup
|
|
class TestFocusService(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.daemon = FakeDaemonProxy()
|
|
self.backend = FakeBackend()
|
|
|
|
def _make_service(
|
|
self,
|
|
bindings: Optional[List[AppBinding]] = None,
|
|
enabled: bool = True,
|
|
) -> FocusService:
|
|
config = GlobalConfig()
|
|
config.set_app_bindings(bindings or [])
|
|
config.set_app_binding_enabled(enabled)
|
|
service = FocusService(config, self.backend, self.daemon, debounce_ms=10)
|
|
# never touch the real session bus in tests
|
|
service.publish = MagicMock()
|
|
self.addCleanup(service.stop)
|
|
return service
|
|
|
|
@staticmethod
|
|
def _binding(app_id, presets, match=MatchType.wm_class) -> AppBinding:
|
|
return AppBinding(
|
|
app_id=app_id,
|
|
match=match,
|
|
presets=[BoundPreset(group_key=g, preset=p) for g, p in presets],
|
|
)
|
|
|
|
# -- reconciliation ----------------------------------------------------
|
|
|
|
def test_loads_config_on_init(self):
|
|
service = self._make_service(
|
|
[self._binding("firefox", [("devX", "p1")])], enabled=True
|
|
)
|
|
self.assertTrue(service._enabled)
|
|
self.assertEqual(len(service._bindings), 1)
|
|
|
|
def test_focus_bound_app_starts_injecting(self):
|
|
service = self._make_service(
|
|
[self._binding("firefox", [("devX", "p1"), ("devY", "p2")])]
|
|
)
|
|
service._reconcile(_event("firefox"))
|
|
self.assertCountEqual(
|
|
self.daemon.calls["start_injecting"],
|
|
[("devX", "p1"), ("devY", "p2")],
|
|
)
|
|
self.assertEqual(service._app_controlled, {"devX": "p1", "devY": "p2"})
|
|
|
|
def test_switching_apps_replaces_and_stops(self):
|
|
service = self._make_service(
|
|
[
|
|
self._binding("a", [("devX", "p1")]),
|
|
self._binding("b", [("devX", "p2"), ("devY", "p3")]),
|
|
self._binding("c", [("devY", "p3")]),
|
|
]
|
|
)
|
|
service._reconcile(_event("a"))
|
|
self.assertEqual(service._app_controlled, {"devX": "p1"})
|
|
|
|
# switch to b: devX replaced (p1->p2), devY added, nothing stopped
|
|
self.daemon.calls["start_injecting"].clear()
|
|
service._reconcile(_event("b"))
|
|
self.assertCountEqual(
|
|
self.daemon.calls["start_injecting"], [("devX", "p2"), ("devY", "p3")]
|
|
)
|
|
self.assertEqual(self.daemon.calls["stop_injecting"], [])
|
|
self.assertEqual(service._app_controlled, {"devX": "p2", "devY": "p3"})
|
|
|
|
# switch to c: only devY wanted (already correct), devX stopped
|
|
self.daemon.calls["start_injecting"].clear()
|
|
service._reconcile(_event("c"))
|
|
self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"])
|
|
# devY p3 already running -> not restarted
|
|
self.assertEqual(self.daemon.calls["start_injecting"], [])
|
|
self.assertEqual(service._app_controlled, {"devY": "p3"})
|
|
|
|
def test_focus_unbound_app_stops_everything(self):
|
|
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
|
service._reconcile(_event("firefox"))
|
|
self.daemon.calls["start_injecting"].clear()
|
|
|
|
service._reconcile(_event("someuncontrolledapp"))
|
|
self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"])
|
|
self.assertEqual(service._app_controlled, {})
|
|
|
|
def test_replace_does_not_restart_identical(self):
|
|
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
|
service._reconcile(_event("firefox"))
|
|
service._reconcile(_event("firefox"))
|
|
# only injected once even though reconciled twice
|
|
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p1")])
|
|
|
|
def test_start_injecting_failure_not_tracked(self):
|
|
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
|
self.daemon.start_injecting = MagicMock(return_value=False)
|
|
service._reconcile(_event("firefox"))
|
|
self.assertEqual(service._app_controlled, {})
|
|
|
|
# -- manual override ---------------------------------------------------
|
|
|
|
def test_manual_preset_is_not_stopped(self):
|
|
# a preset applied manually is not in _app_controlled, so focusing an
|
|
# unbound app must not stop it.
|
|
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
|
# devY has a manually applied preset, unknown to the service
|
|
service._reconcile(_event("unbound"))
|
|
self.assertEqual(self.daemon.calls["stop_injecting"], [])
|
|
|
|
def test_bound_app_reclaims_manually_used_device(self):
|
|
# another bound app taking focus replaces whatever is on the device
|
|
service = self._make_service([self._binding("firefox", [("devX", "p9")])])
|
|
service._reconcile(_event("firefox"))
|
|
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p9")])
|
|
|
|
# -- enable / disable --------------------------------------------------
|
|
|
|
def test_set_enabled_false_stops_app_controlled(self):
|
|
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
|
service._current_event = _event("firefox")
|
|
service._reconcile(service._current_event)
|
|
self.assertEqual(service._app_controlled, {"devX": "p1"})
|
|
|
|
service.set_enabled(False)
|
|
self.assertFalse(service._enabled)
|
|
self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"])
|
|
self.assertEqual(service._app_controlled, {})
|
|
|
|
def test_set_enabled_true_reconciles_current(self):
|
|
service = self._make_service(
|
|
[self._binding("firefox", [("devX", "p1")])], enabled=False
|
|
)
|
|
service._current_event = _event("firefox")
|
|
service.set_enabled(True)
|
|
self.assertTrue(service._enabled)
|
|
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p1")])
|
|
|
|
def test_set_enabled_true_without_current_event(self):
|
|
service = self._make_service(
|
|
[self._binding("firefox", [("devX", "p1")])], enabled=False
|
|
)
|
|
service.set_enabled(True)
|
|
self.assertEqual(self.daemon.calls["start_injecting"], [])
|
|
|
|
# -- reload ------------------------------------------------------------
|
|
|
|
def test_reload_picks_up_new_bindings(self):
|
|
service = self._make_service([], enabled=True)
|
|
service._current_event = _event("firefox")
|
|
|
|
service.global_config.set_app_bindings(
|
|
[self._binding("firefox", [("devX", "p1")])]
|
|
)
|
|
service.reload()
|
|
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p1")])
|
|
|
|
def test_reload_when_disabled_stops_app_controlled(self):
|
|
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
|
service._current_event = _event("firefox")
|
|
service._reconcile(service._current_event)
|
|
|
|
service.global_config.set_app_binding_enabled(False)
|
|
service.reload()
|
|
self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"])
|
|
self.assertEqual(service._app_controlled, {})
|
|
|
|
# -- debounce / transient focus ---------------------------------------
|
|
|
|
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=""))
|
|
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):
|
|
service = self._make_service(
|
|
[
|
|
self._binding("a", [("devX", "p1")]),
|
|
self._binding("b", [("devX", "p2")]),
|
|
]
|
|
)
|
|
service._on_focus(_event("a"))
|
|
service._on_focus(_event("b"))
|
|
# nothing reconciled until the debounce flushes
|
|
self.assertEqual(self.daemon.calls["start_injecting"], [])
|
|
self.assertEqual(service._pending_event, _event("b"))
|
|
|
|
service._flush()
|
|
# only the most recent focus is applied
|
|
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p2")])
|
|
self.assertEqual(service._current_event, _event("b"))
|
|
|
|
def test_flush_without_pending_is_noop(self):
|
|
service = self._make_service([])
|
|
self.assertFalse(service._flush())
|
|
|
|
def test_flush_emits_focus_changed_on_change(self):
|
|
service = self._make_service([])
|
|
service.focus_changed = MagicMock()
|
|
service._pending_event = _event("firefox", title="Mozilla")
|
|
service._flush()
|
|
service.focus_changed.assert_called_once()
|
|
payload = json.loads(service.focus_changed.call_args[0][0])
|
|
self.assertEqual(payload, {"app_id": "firefox", "title": "Mozilla"})
|
|
|
|
def test_flush_no_emit_when_unchanged(self):
|
|
service = self._make_service([])
|
|
service._current_event = _event("firefox")
|
|
service.focus_changed = MagicMock()
|
|
service._pending_event = _event("firefox")
|
|
service._flush()
|
|
service.focus_changed.assert_not_called()
|
|
|
|
# -- lifecycle / dbus surface -----------------------------------------
|
|
|
|
def test_start_reconciles_initial_focus(self):
|
|
self.backend = FakeBackend(current=_event("firefox"))
|
|
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
|
service.start()
|
|
self.assertTrue(self.backend.started)
|
|
self.assertEqual(self.backend.calls[:2], ["start", "get_current"])
|
|
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p1")])
|
|
|
|
def test_start_stops_on_publish_failure(self):
|
|
service = self._make_service([])
|
|
service.publish = MagicMock(side_effect=RuntimeError("dbus failed"))
|
|
with self.assertRaises(RuntimeError):
|
|
service.start()
|
|
self.assertFalse(self.backend.started)
|
|
|
|
def test_stop_releases_app_controlled(self):
|
|
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
|
service._reconcile(_event("firefox"))
|
|
service.stop()
|
|
self.assertTrue(self.backend.stopped)
|
|
self.assertIn("devX", self.daemon.calls["stop_injecting"])
|
|
self.assertEqual(service._app_controlled, {})
|
|
|
|
def test_get_focused_app_json(self):
|
|
service = self._make_service([])
|
|
self.assertEqual(
|
|
json.loads(service.get_focused_app()), {"app_id": "", "title": ""}
|
|
)
|
|
service._current_event = _event("firefox", title="Moz")
|
|
self.assertEqual(
|
|
json.loads(service.get_focused_app()),
|
|
{"app_id": "firefox", "title": "Moz"},
|
|
)
|
|
|
|
def test_get_status_json(self):
|
|
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
|
service._reconcile(_event("firefox", title="Moz"))
|
|
status = json.loads(service.get_status())
|
|
self.assertEqual(status["backend"], "fake")
|
|
self.assertTrue(status["enabled"])
|
|
self.assertEqual(status["focused"], {"app_id": "", "title": ""})
|
|
self.assertEqual(status["app_controlled"], {"devX": "p1"})
|
|
|
|
def test_compute_desired_merges_multiple_bindings(self):
|
|
# two bindings can match the same event (wm_class + title regex)
|
|
service = self._make_service(
|
|
[
|
|
self._binding("firefox", [("devX", "p1")]),
|
|
self._binding(
|
|
"Moz", [("devY", "p2")], match=MatchType.title_regex
|
|
),
|
|
]
|
|
)
|
|
desired = service._compute_desired(_event("firefox", title="Mozilla"))
|
|
self.assertEqual(desired, {"devX": "p1", "devY": "p2"})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|