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)