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

@ -21,10 +21,11 @@ import glob
import os
import re
import time
from typing import Optional, List, Tuple, Set
from typing import Optional, List, Tuple, Set, Dict
from gi.repository import GLib
from inputremapper.configs.app_binding import AppBinding
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import UIMapping, MappingData
@ -43,7 +44,10 @@ from inputremapper.gui.messages.message_data import (
GroupData,
PresetData,
CombinationUpdate,
AppBindingsData,
FocusAppData,
)
from inputremapper.gui.focus_service_client import FocusServiceClient
from inputremapper.gui.reader_client import ReaderClient
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.injector import (
@ -74,12 +78,14 @@ class DataManager:
daemon: DaemonProxy,
uinputs: GlobalUInputs,
keyboard_layout: KeyboardLayout,
focus_service: Optional[FocusServiceClient] = None,
):
self.message_broker = message_broker
self._reader_client = reader_client
self._daemon = daemon
self._uinputs = uinputs
self._keyboard_layout = keyboard_layout
self._focus_service = focus_service or FocusServiceClient()
uinputs.prepare_all()
self._config = config
@ -156,6 +162,88 @@ class DataManager:
self.message_broker.publish(InjectorStateMessage(self.get_state()))
def publish_app_bindings(self):
"""Publish the "app_bindings" message with the current service state."""
status = self._focus_service.get_status()
reachable = status is not None
backend = status.get("backend") if status else None
self.message_broker.publish(
AppBindingsData(
enabled=self._config.get_app_binding_enabled(),
bindings=tuple(self._config.get_app_bindings()),
backend=backend,
supported=reachable and backend is not None,
reachable=reachable,
)
)
def get_app_bindings(self) -> List[AppBinding]:
"""Get the configured application bindings."""
return self._config.get_app_bindings()
def set_app_bindings(self, bindings: List[AppBinding]):
"""Persist the application bindings and reconcile the focus-service.
Will send the "app_bindings" message on the MessageBroker.
"""
self._config.set_app_bindings(bindings)
self._focus_service.reload()
self.publish_app_bindings()
def get_app_binding_enabled(self) -> bool:
"""Whether focus-driven preset binding is enabled."""
return self._config.get_app_binding_enabled()
def set_app_binding_enabled(self, enabled: bool):
"""Enable or disable focus-driven preset binding.
Writes the config and toggles the running service. Will send the
"app_bindings" message on the MessageBroker.
"""
self._config.set_app_binding_enabled(enabled)
self._focus_service.set_enabled(enabled)
self.publish_app_bindings()
def start_app_detection(self):
"""Listen for focus changes to auto-fill an app_id.
Will send "focused_app" messages as the focus changes.
"""
self._focus_service.connect_focus_changed(self._on_focus_changed)
def stop_app_detection(self):
"""Stop listening for focus changes."""
self._focus_service.disconnect_focus_changed()
def _on_focus_changed(self, app: Dict[str, str]):
self.message_broker.publish(
FocusAppData(
app_id=app.get("app_id", ""),
title=app.get("title", ""),
)
)
def get_presets_for_group(self, group_key: str) -> Tuple[Name, ...]:
"""Get all preset names for an arbitrary group, sorted by age.
Unlike get_preset_names this does not depend on the active group, so it
can be used to populate the application-binding preset selectors.
"""
group = self._reader_client.groups.find(key=group_key)
if not group:
return tuple()
device_folder = PathUtils.get_preset_path(group.name)
PathUtils.mkdir(device_folder)
paths = glob.glob(os.path.join(glob.escape(device_folder), "*.json"))
presets = [
os.path.splitext(os.path.basename(path))[0]
for path in sorted(paths, key=os.path.getmtime)
]
presets.reverse()
return tuple(presets)
@property
def active_group(self) -> Optional[_Group]:
"""The currently loaded group."""