# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # 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 . """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)