input-remapper/inputremapper/configs/preset.py
Tobi de0dd8af8e
2.0.0-rc (#639)
Co-authored-by: jonasBoss <jonas.bosse@posteo.de>
2023-03-02 16:53:58 +01:00

333 lines
12 KiB
Python

# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# 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 pydantic import ValidationError
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping, UIMapping
from inputremapper.configs.paths import touch
from inputremapper.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
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
logger.error(
"Combination %s not found. Available: %s",
repr(combination),
list(
self._mappings.keys(),
),
)
return None
def dangerously_mapped_btn_left(self) -> bool:
"""Return True if this mapping disables BTN_Left."""
if InputCombination([InputConfig.btn_left()]) not in [
m.input_combination 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", 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