feat(app-binding): lier les presets aux applications

Ajoute la configuration des associations application/preset, le service de suivi du focus, l'intégration GUI et les fichiers d'installation nécessaires.

Ajoute les tests unitaires ciblés des bindings, du service de focus, des migrations et des composants GUI associés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 23:52:34 +02:00
parent 0ab77d2a64
commit 9ef2c1439c
42 changed files with 4353 additions and 12 deletions

View File

@ -0,0 +1,342 @@
#!/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_transient_empty_focus_is_ignored(self):
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
service._on_focus(_event(app_id=""))
self.assertIsNone(service._pending_event)
self.assertIsNone(service._debounce_id)
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()