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:
108
inputremapper/configs/app_binding.py
Normal file
108
inputremapper/configs/app_binding.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user