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,108 @@
# -*- 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/>.
"""Models describing which presets should be applied for which application.
These models are persisted as part of the global config (config.json) and are
consumed by the input-remapper-focus-service to decide which presets to inject
depending on the currently focused window.
"""
from __future__ import annotations
import enum
import re
from typing import List, TYPE_CHECKING
try:
from pydantic.v1 import BaseModel, Field, root_validator, validator
except ImportError:
from pydantic import ( # type: ignore[assignment, no-redef]
BaseModel,
Field,
root_validator,
validator,
)
from inputremapper.configs.validation_errors import (
EmptyAppIdError,
InvalidTitleRegexError,
)
if TYPE_CHECKING:
from inputremapper.focus.focus_watcher import FocusEvent
class MatchType(str, enum.Enum):
"""How an AppBinding decides whether it applies to a focused window."""
wm_class = "wm_class"
title_regex = "title_regex"
def normalize_app_id(app_id: str) -> str:
"""Normalize a window identifier for case-insensitive comparison."""
return app_id.strip().lower()
class BoundPreset(BaseModel):
"""A (device, preset) pair that should be injected for an application."""
group_key: str
preset: str
@validator("group_key", "preset")
def _not_empty(cls, value: str) -> str: # noqa: N805
if not value or not value.strip():
raise ValueError("group_key and preset must not be empty")
return value
class AppBinding(BaseModel):
"""Binds a focused application to a list of presets to inject."""
app_id: str
match: MatchType = MatchType.wm_class
presets: List[BoundPreset] = Field(default_factory=list)
@validator("app_id")
def _app_id_not_empty(cls, value: str) -> str: # noqa: N805
if not value or not value.strip():
raise EmptyAppIdError()
return value
@root_validator(skip_on_failure=True)
def _valid_regex(cls, values: dict) -> dict: # noqa: N805
if values.get("match") == MatchType.title_regex:
app_id = values.get("app_id", "")
try:
re.compile(app_id)
except re.error as error:
raise InvalidTitleRegexError(app_id, str(error))
return values
def matches(self, event: "FocusEvent") -> bool:
"""Whether this binding applies to the given focus event."""
if self.match == MatchType.title_regex:
try:
return re.search(self.app_id, event.title) is not None
except re.error:
return False
return normalize_app_id(self.app_id) == normalize_app_id(event.app_id)

View File

@ -23,8 +23,9 @@ from __future__ import annotations
import copy
import json
import os
from typing import Optional
from typing import List, Optional
from inputremapper.configs.app_binding import AppBinding
from inputremapper.configs.paths import PathUtils
from inputremapper.logging.logger import logger, VERSION
from inputremapper.user import UserUtils
@ -37,6 +38,8 @@ NONE = "none"
INITIAL_CONFIG = {
"version": VERSION,
"autoload": {},
"app_binding_enabled": False,
"app_bindings": [],
}
@ -87,6 +90,32 @@ class GlobalConfig:
return self._config.get("autoload", {}).get(group_key) == preset
def get_app_binding_enabled(self) -> bool:
"""Whether focus-driven preset binding is enabled."""
return bool(self._config.get("app_binding_enabled", False))
def set_app_binding_enabled(self, enabled: bool) -> None:
"""Enable or disable focus-driven preset binding."""
self._config["app_binding_enabled"] = bool(enabled)
self._save_config()
def get_app_bindings(self) -> List[AppBinding]:
"""Get the configured application bindings as model objects."""
bindings = []
for raw in self._config.get("app_bindings", []):
try:
bindings.append(AppBinding(**raw))
except (TypeError, ValueError) as error:
logger.error("Ignoring invalid app_binding %s: %s", raw, str(error))
return bindings
def set_app_bindings(self, bindings: List[AppBinding]) -> None:
"""Persist the given application bindings."""
self._config["app_bindings"] = [
json.loads(binding.json()) for binding in bindings
]
self._save_config()
def load_config(self, path: Optional[str] = None):
"""Load the config from the file system.
Parameters

View File

@ -98,6 +98,9 @@ class Migrations:
if v < version.parse("1.6.0-beta"):
self._convert_to_individual_mappings()
if v < version.parse("2.3.0"):
self._add_app_binding_config()
# add new migrations here
if v < version.parse(VERSION):
@ -212,6 +215,35 @@ class Migrations:
logger.info('Updating version in config to "%s"', VERSION)
json.dump(config, file, indent=4)
def _add_app_binding_config(self):
"""Add the focus-driven app-binding keys to config.json if missing.
This is an additive, backwards-compatible change: presets and autoload
configuration are left untouched.
"""
config_file = os.path.join(PathUtils.config_path(), "config.json")
if not os.path.exists(config_file):
return
with open(config_file, "r") as file:
config = json.load(file)
changed = False
if "app_binding_enabled" not in config:
config["app_binding_enabled"] = False
changed = True
if "app_bindings" not in config:
config["app_bindings"] = []
changed = True
if not changed:
return
with open(config_file, "w") as file:
logger.info("Adding app-binding defaults to config")
json.dump(config, file, indent=4)
file.write("\n")
def _rename_to_input_remapper(self):
"""Rename .config/key-mapper to .config/input-remapper."""
old_config_path = os.path.join(UserUtils.home, ".config/key-mapper")

View File

@ -116,6 +116,16 @@ class MissingOutputAxisError(ValueError):
)
class EmptyAppIdError(ValueError):
def __init__(self):
super().__init__("app_id must not be empty")
class InvalidTitleRegexError(ValueError):
def __init__(self, pattern: str, reason: str):
super().__init__(f'"{pattern}" is not a valid title regex: {reason}')
class MacroError(ValueError):
"""Macro syntax errors."""