chore(repo): baseline du fork input-remapper (upstream v2.2.1)
Le dépôt ne contenait que README.md ; ajout de l'intégralité du code fonctionnel du fork comme socle stable de la branche de release. L'état runtime d'orchestration (.ideai/) est exclu du suivi. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
325
inputremapper/configs/preset.py
Normal file
325
inputremapper/configs/preset.py
Normal file
@ -0,0 +1,325 @@
|
||||
# -*- 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/>.
|
||||
|
||||
"""Contains and manages mappings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import (
|
||||
Tuple,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Iterator,
|
||||
Type,
|
||||
TypeVar,
|
||||
Generic,
|
||||
overload,
|
||||
)
|
||||
|
||||
from evdev import ecodes
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import Mapping, UIMapping
|
||||
from inputremapper.configs.paths import PathUtils
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
MappingModel = TypeVar("MappingModel", bound=UIMapping)
|
||||
|
||||
|
||||
class Preset(Generic[MappingModel]):
|
||||
"""Contains and manages mappings of a single preset."""
|
||||
|
||||
# workaround for typing: https://github.com/python/mypy/issues/4236
|
||||
@overload
|
||||
def __init__(self: Preset[Mapping], path: Optional[os.PathLike] = None): ...
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self,
|
||||
path: Optional[os.PathLike] = None,
|
||||
mapping_factory: Type[MappingModel] = ...,
|
||||
): ...
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Optional[os.PathLike] = None,
|
||||
mapping_factory=Mapping,
|
||||
) -> None:
|
||||
self._mappings: Dict[InputCombination, MappingModel] = {}
|
||||
# a copy of mappings for keeping track of changes
|
||||
self._saved_mappings: Dict[InputCombination, MappingModel] = {}
|
||||
self._path: Optional[os.PathLike] = path
|
||||
|
||||
# the mapping class which is used by load()
|
||||
self._mapping_factory: Type[MappingModel] = mapping_factory
|
||||
|
||||
def __iter__(self) -> Iterator[MappingModel]:
|
||||
"""Iterate over Mapping objects."""
|
||||
return iter(self._mappings.copy().values())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._mappings)
|
||||
|
||||
def __bool__(self):
|
||||
# otherwise __len__ will be used which results in False for a preset
|
||||
# without mappings
|
||||
return True
|
||||
|
||||
def has_unsaved_changes(self) -> bool:
|
||||
"""Check if there are unsaved changed."""
|
||||
return self._mappings != self._saved_mappings
|
||||
|
||||
def remove(self, combination: InputCombination) -> None:
|
||||
"""Remove a mapping from the preset by providing the InputCombination."""
|
||||
|
||||
if not isinstance(combination, InputCombination):
|
||||
raise TypeError(
|
||||
f"combination must by of type InputCombination, got {type(combination)}"
|
||||
)
|
||||
|
||||
for permutation in combination.get_permutations():
|
||||
if permutation in self._mappings.keys():
|
||||
combination = permutation
|
||||
break
|
||||
try:
|
||||
mapping = self._mappings.pop(combination)
|
||||
mapping.remove_combination_changed_callback()
|
||||
except KeyError:
|
||||
logger.debug(
|
||||
"unable to remove non-existing mapping with combination = %s",
|
||||
combination,
|
||||
)
|
||||
pass
|
||||
|
||||
def add(self, mapping: MappingModel) -> None:
|
||||
"""Add a mapping to the preset."""
|
||||
for permutation in mapping.input_combination.get_permutations():
|
||||
if permutation in self._mappings:
|
||||
raise KeyError(
|
||||
"A mapping with this input_combination: "
|
||||
f"{permutation} already exists",
|
||||
)
|
||||
|
||||
mapping.set_combination_changed_callback(self._combination_changed_callback)
|
||||
self._mappings[mapping.input_combination] = mapping
|
||||
|
||||
def empty(self) -> None:
|
||||
"""Remove all mappings and custom configs without saving.
|
||||
note: self.has_unsaved_changes() will report True
|
||||
"""
|
||||
for mapping in self._mappings.values():
|
||||
mapping.remove_combination_changed_callback()
|
||||
self._mappings = {}
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all mappings and also self.path."""
|
||||
self.empty()
|
||||
self._saved_mappings = {}
|
||||
self.path = None
|
||||
|
||||
def load(self) -> None:
|
||||
"""Load from the mapping from the disc, clears all existing mappings."""
|
||||
logger.info('Loading preset from "%s"', self.path)
|
||||
|
||||
if not self.path or not os.path.exists(self.path):
|
||||
raise FileNotFoundError(f'Tried to load non-existing preset "{self.path}"')
|
||||
|
||||
self._saved_mappings = self._get_mappings_from_disc()
|
||||
self.empty()
|
||||
for mapping in self._saved_mappings.values():
|
||||
# use the public add method to make sure
|
||||
# the _combination_changed_callback is attached
|
||||
self.add(mapping.copy())
|
||||
|
||||
def _is_mapped_multiple_times(self, input_combination: InputCombination) -> bool:
|
||||
"""Check if the event combination maps to multiple mappings."""
|
||||
all_input_combinations = {mapping.input_combination for mapping in self}
|
||||
permutations = set(input_combination.get_permutations())
|
||||
union = permutations & all_input_combinations
|
||||
# if there are more than one matches, then there is a duplicate
|
||||
return len(union) > 1
|
||||
|
||||
def _has_valid_input_combination(self, mapping: UIMapping) -> bool:
|
||||
"""Check if the mapping has a valid input event combination."""
|
||||
is_a_combination = isinstance(mapping.input_combination, InputCombination)
|
||||
is_empty = mapping.input_combination == InputCombination.empty_combination()
|
||||
return is_a_combination and not is_empty
|
||||
|
||||
def save(self) -> None:
|
||||
"""Dump as JSON to self.path."""
|
||||
|
||||
if not self.path:
|
||||
logger.debug("unable to save preset without a path set Preset.path first")
|
||||
return
|
||||
|
||||
PathUtils.touch(self.path)
|
||||
if not self.has_unsaved_changes():
|
||||
logger.debug("Not saving unchanged preset")
|
||||
return
|
||||
|
||||
logger.info("Saving preset to %s", self.path)
|
||||
|
||||
preset_list = []
|
||||
saved_mappings = {}
|
||||
for mapping in self:
|
||||
if not mapping.is_valid():
|
||||
if not self._has_valid_input_combination(mapping):
|
||||
# we save invalid mappings except for those with an invalid
|
||||
# input_combination
|
||||
logger.debug("Skipping invalid mapping %s", mapping)
|
||||
continue
|
||||
|
||||
if self._is_mapped_multiple_times(mapping.input_combination):
|
||||
# todo: is this ever executed? it should not be possible to
|
||||
# reach this
|
||||
logger.debug(
|
||||
"skipping mapping with duplicate event combination %s",
|
||||
mapping,
|
||||
)
|
||||
continue
|
||||
|
||||
mapping_dict = mapping.dict(exclude_defaults=True)
|
||||
mapping_dict["input_combination"] = mapping.input_combination.to_config()
|
||||
combination = mapping.input_combination
|
||||
preset_list.append(mapping_dict)
|
||||
|
||||
saved_mappings[combination] = mapping.copy()
|
||||
saved_mappings[combination].remove_combination_changed_callback()
|
||||
|
||||
with open(self.path, "w") as file:
|
||||
json.dump(preset_list, file, indent=4)
|
||||
file.write("\n")
|
||||
|
||||
self._saved_mappings = saved_mappings
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
return False not in [mapping.is_valid() for mapping in self]
|
||||
|
||||
def get_mapping(
|
||||
self, combination: Optional[InputCombination]
|
||||
) -> Optional[MappingModel]:
|
||||
"""Return the Mapping that is mapped to this InputCombination."""
|
||||
if not combination:
|
||||
return None
|
||||
|
||||
if not isinstance(combination, InputCombination):
|
||||
raise TypeError(
|
||||
f"combination must by of type InputCombination, got {type(combination)}"
|
||||
)
|
||||
|
||||
for permutation in combination.get_permutations():
|
||||
existing = self._mappings.get(permutation)
|
||||
if existing is not None:
|
||||
return existing
|
||||
return None
|
||||
|
||||
def dangerously_mapped_btn_left(self) -> bool:
|
||||
"""Return True if this mapping disables BTN_Left."""
|
||||
if (ecodes.EV_KEY, ecodes.BTN_LEFT) not in [
|
||||
m.input_combination[0].type_and_code for m in self
|
||||
]:
|
||||
return False
|
||||
|
||||
values: List[str | Tuple[int, int] | None] = []
|
||||
for mapping in self:
|
||||
if mapping.output_symbol is None:
|
||||
continue
|
||||
values.append(mapping.output_symbol.lower())
|
||||
values.append(mapping.get_output_type_code())
|
||||
|
||||
return (
|
||||
"btn_left" not in values
|
||||
or InputConfig.btn_left().type_and_code not in values
|
||||
)
|
||||
|
||||
def _combination_changed_callback(
|
||||
self, new: InputCombination, old: InputCombination
|
||||
) -> None:
|
||||
for permutation in new.get_permutations():
|
||||
if permutation in self._mappings.keys() and permutation != old:
|
||||
raise KeyError("combination already exists in the preset")
|
||||
self._mappings[new] = self._mappings.pop(old)
|
||||
|
||||
def _update_saved_mappings(self) -> None:
|
||||
if self.path is None:
|
||||
return
|
||||
|
||||
if not os.path.exists(self.path):
|
||||
self._saved_mappings = {}
|
||||
return
|
||||
self._saved_mappings = self._get_mappings_from_disc()
|
||||
|
||||
def _get_mappings_from_disc(self) -> Dict[InputCombination, MappingModel]:
|
||||
mappings: Dict[InputCombination, MappingModel] = {}
|
||||
if not self.path:
|
||||
logger.debug("unable to read preset without a path set Preset.path first")
|
||||
return mappings
|
||||
|
||||
if os.stat(self.path).st_size == 0:
|
||||
logger.debug("got empty file")
|
||||
return mappings
|
||||
|
||||
with open(self.path, "r") as file:
|
||||
try:
|
||||
preset_list = json.load(file)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("unable to decode json file: %s", self.path)
|
||||
return mappings
|
||||
|
||||
for mapping_dict in preset_list:
|
||||
if not isinstance(mapping_dict, dict):
|
||||
logger.error(
|
||||
"Expected mapping to be a dict: %s %s",
|
||||
type(mapping_dict),
|
||||
mapping_dict,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
mapping = self._mapping_factory(**mapping_dict)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"failed to Validate mapping for %s: %s",
|
||||
mapping_dict.get("input_combination"),
|
||||
error,
|
||||
)
|
||||
continue
|
||||
|
||||
mappings[mapping.input_combination] = mapping
|
||||
return mappings
|
||||
|
||||
@property
|
||||
def path(self) -> Optional[os.PathLike]:
|
||||
return self._path
|
||||
|
||||
@path.setter
|
||||
def path(self, path: Optional[os.PathLike]):
|
||||
if path != self.path:
|
||||
self._path = path
|
||||
self._update_saved_mappings()
|
||||
|
||||
@property
|
||||
def name(self) -> Optional[str]:
|
||||
"""The name of the preset."""
|
||||
if self.path:
|
||||
return os.path.basename(self.path).split(".")[0]
|
||||
return None
|
||||
Reference in New Issue
Block a user