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,290 @@
#!/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 unittest
from unittest.mock import MagicMock
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("GLib", "2.0")
from gi.repository import Gtk, GLib
from inputremapper.configs.app_binding import AppBinding, BoundPreset, MatchType
from inputremapper.gui.components.app_bindings import (
AppBindingsEditor,
_BindingRow,
)
from inputremapper.gui.controller import Controller
from inputremapper.gui.messages.message_broker import MessageBroker, MessageType
from inputremapper.gui.messages.message_data import (
AppBindingsData,
FocusAppData,
GroupsData,
)
from inputremapper.gui.utils import gtk_iteration
from tests.lib.test_setup import test_setup
@test_setup
class TestAppBindingsEditor(unittest.TestCase):
def setUp(self) -> None:
self.message_broker = MessageBroker()
self.controller_mock: Controller = MagicMock()
self.controller_mock.get_presets_for_group.return_value = ("preset1", "preset2")
self.enabled_switch = Gtk.Switch()
self.status_label = Gtk.Label()
self.listbox = Gtk.ListBox()
self.add_button = Gtk.Button()
self.editor = AppBindingsEditor(
self.message_broker,
self.controller_mock,
self.enabled_switch,
self.status_label,
self.listbox,
self.add_button,
)
def tearDown(self) -> None:
super().tearDown()
self.message_broker.signal(MessageType.terminate)
for widget in (
self.enabled_switch,
self.status_label,
self.listbox,
self.add_button,
):
GLib.timeout_add(0, widget.destroy)
GLib.timeout_add(0, Gtk.main_quit)
Gtk.main()
def _publish_bindings(self, bindings=(), enabled=False, **kwargs):
defaults = dict(backend="xorg", supported=True, reachable=True)
defaults.update(kwargs)
self.message_broker.publish(
AppBindingsData(enabled=enabled, bindings=tuple(bindings), **defaults)
)
gtk_iteration()
def _binding(self, app_id="firefox", presets=()):
return AppBinding(
app_id=app_id,
presets=[BoundPreset(group_key=g, preset=p) for g, p in presets],
)
# -- enable toggle -----------------------------------------------------
def test_toggle_enabled_calls_controller(self):
self.enabled_switch.emit("state-set", True)
gtk_iteration()
self.controller_mock.set_app_binding_enabled.assert_called_with(True)
def test_app_bindings_message_updates_switch_without_callback(self):
# the incoming state must not echo back into the controller
self.controller_mock.set_app_binding_enabled.reset_mock()
self._publish_bindings(enabled=True)
self.assertTrue(self.enabled_switch.get_active())
self.controller_mock.set_app_binding_enabled.assert_not_called()
# -- add / remove binding ----------------------------------------------
def test_add_binding(self):
self.add_button.emit("clicked")
gtk_iteration()
self.assertEqual(len(self.listbox.get_children()), 1)
self.assertEqual(len(self.editor._rows), 1)
self.controller_mock.update_app_bindings.assert_called()
def test_rebuild_from_message(self):
self._publish_bindings(
[
self._binding("firefox", [("Foo Device", "preset1")]),
self._binding("kitty"),
],
enabled=True,
)
self.assertEqual(len(self.listbox.get_children()), 2)
self.assertEqual(len(self.editor._rows), 2)
def test_remove_binding(self):
self._publish_bindings([self._binding("firefox")], enabled=True)
row = self.editor._rows[0]
self.controller_mock.update_app_bindings.reset_mock()
self.editor.remove_row(row)
gtk_iteration()
self.assertEqual(len(self.editor._rows), 0)
self.assertEqual(len(self.listbox.get_children()), 0)
self.controller_mock.update_app_bindings.assert_called()
def test_editing_guard_prevents_rebuild(self):
self._publish_bindings([self._binding("firefox")], enabled=True)
self.assertEqual(len(self.editor._rows), 1)
# simulate being mid-save: the echo of our own write must not rebuild
self.editor._editing = True
self._publish_bindings([self._binding("a"), self._binding("b")], enabled=True)
self.assertEqual(len(self.editor._rows), 1)
# -- preset rows -------------------------------------------------------
def test_add_and_remove_preset(self):
self._publish_bindings([self._binding("firefox")], enabled=True)
row: _BindingRow = self.editor._rows[0]
self.assertEqual(len(row._preset_rows), 0)
row._on_add_preset_clicked()
gtk_iteration()
self.assertEqual(len(row._preset_rows), 1)
preset_row = row._preset_rows[0]
row.remove_preset(preset_row)
gtk_iteration()
self.assertEqual(len(row._preset_rows), 0)
def test_binding_to_model(self):
self._publish_bindings(
[self._binding("firefox", [("Foo Device", "preset1")])], enabled=True
)
row: _BindingRow = self.editor._rows[0]
model = row.to_model()
self.assertIsNotNone(model)
self.assertEqual(model.app_id, "firefox")
self.assertEqual(model.match, MatchType.wm_class)
self.assertEqual(model.presets[0].group_key, "Foo Device")
def test_empty_app_id_row_raises(self):
self.add_button.emit("clicked")
gtk_iteration()
row: _BindingRow = self.editor._rows[0]
row.set_app_id("")
with self.assertRaises(ValueError):
row.to_model()
def test_save_rejects_empty_app_id_with_visible_feedback(self):
self.add_button.emit("clicked")
gtk_iteration()
row: _BindingRow = self.editor._rows[0]
row.set_app_id("")
self.controller_mock.update_app_bindings.reset_mock()
self.editor.save()
gtk_iteration()
self.controller_mock.update_app_bindings.assert_not_called()
self.assertTrue(row._error_label.get_visible())
self.assertIn("identifier", row._error_label.get_text())
def test_save_rejects_invalid_title_regex_with_visible_feedback(self):
self._publish_bindings([self._binding("firefox")], enabled=True)
row: _BindingRow = self.editor._rows[0]
row.set_app_id("(")
row._match_combo.set_active_id(MatchType.title_regex.value)
self.controller_mock.update_app_bindings.reset_mock()
self.editor.save()
gtk_iteration()
self.controller_mock.update_app_bindings.assert_not_called()
self.assertTrue(row._error_label.get_visible())
self.assertIn("valid title regex", row._error_label.get_text())
def test_save_rejects_partial_preset_with_visible_feedback(self):
self.message_broker.publish(GroupsData({"Foo Device": []}))
gtk_iteration()
self._publish_bindings([self._binding("firefox")], enabled=True)
row: _BindingRow = self.editor._rows[0]
row._on_add_preset_clicked()
preset_row = row._preset_rows[0]
preset_row._device_combo.set_active_id("Foo Device")
self.controller_mock.update_app_bindings.reset_mock()
self.editor.save()
gtk_iteration()
self.controller_mock.update_app_bindings.assert_not_called()
self.assertTrue(row._error_label.get_visible())
self.assertIn("device and a preset", row._error_label.get_text())
# -- detection ---------------------------------------------------------
def test_detect_flow(self):
self._publish_bindings([self._binding("firefox")], enabled=True)
row: _BindingRow = self.editor._rows[0]
self.editor.request_detection(row)
self.controller_mock.start_app_detection.assert_called_once()
self.assertIs(self.editor._detecting_row, row)
# a transient (empty) focus is ignored, detection keeps running
self.message_broker.publish(FocusAppData(app_id="", title=""))
gtk_iteration()
self.assertIs(self.editor._detecting_row, row)
# a real focus fills the app_id and stops detection
self.controller_mock.update_app_bindings.reset_mock()
self.message_broker.publish(FocusAppData(app_id="kitty", title="Terminal"))
gtk_iteration()
self.assertIsNone(self.editor._detecting_row)
self.controller_mock.stop_app_detection.assert_called()
self.controller_mock.update_app_bindings.assert_called()
self.assertEqual(row.to_model().app_id, "kitty")
def test_detect_toggle_off(self):
self._publish_bindings([self._binding("firefox")], enabled=True)
row: _BindingRow = self.editor._rows[0]
self.editor.request_detection(row)
# requesting again on the same row toggles detection off
self.editor.request_detection(row)
self.assertIsNone(self.editor._detecting_row)
self.controller_mock.stop_app_detection.assert_called()
# -- status label ------------------------------------------------------
def test_status_label_disabled(self):
self._publish_bindings(enabled=False)
self.assertIn("Enable application bindings", self.status_label.get_text())
def test_status_label_starting(self):
self._publish_bindings(enabled=True, reachable=False, supported=False)
self.assertIn("Starting", self.status_label.get_text())
def test_status_label_unsupported(self):
self._publish_bindings(
enabled=True, reachable=True, supported=False, backend=None
)
self.assertIn("not supported", self.status_label.get_text())
def test_status_label_active(self):
self._publish_bindings(
enabled=True, reachable=True, supported=True, backend="xorg"
)
self.assertIn("xorg", self.status_label.get_text())
# -- groups ------------------------------------------------------------
def test_groups_message_updates_available_groups(self):
self.message_broker.publish(GroupsData({"Foo Device": [], "Bar": []}))
gtk_iteration()
self.assertEqual(self.editor.groups, ("Foo Device", "Bar"))
if __name__ == "__main__":
unittest.main()