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:
0
inputremapper/configs/__init__.py
Normal file
0
inputremapper/configs/__init__.py
Normal file
33
inputremapper/configs/data.py
Normal file
33
inputremapper/configs/data.py
Normal file
@ -0,0 +1,33 @@
|
||||
# -*- 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/>.
|
||||
|
||||
|
||||
"""Get stuff from /usr/share/input-remapper, depending on the prefix."""
|
||||
|
||||
|
||||
import os
|
||||
|
||||
from inputremapper.installation_info import DATA_DIR
|
||||
|
||||
|
||||
def get_data_path(filename=""):
|
||||
"""Depending on the installation prefix, return the data dir."""
|
||||
# This was more complicated in the past. This wrapper is kept for now, but feel
|
||||
# free to remove it at a later point.
|
||||
return os.path.join(DATA_DIR, filename)
|
||||
142
inputremapper/configs/global_config.py
Normal file
142
inputremapper/configs/global_config.py
Normal file
@ -0,0 +1,142 @@
|
||||
# -*- 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/>.
|
||||
"""Store which presets should be enabled for which device on login."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from inputremapper.configs.paths import PathUtils
|
||||
from inputremapper.logging.logger import logger, VERSION
|
||||
from inputremapper.user import UserUtils
|
||||
|
||||
MOUSE = "mouse"
|
||||
WHEEL = "wheel"
|
||||
BUTTONS = "buttons"
|
||||
NONE = "none"
|
||||
|
||||
INITIAL_CONFIG = {
|
||||
"version": VERSION,
|
||||
"autoload": {},
|
||||
}
|
||||
|
||||
|
||||
class GlobalConfig:
|
||||
"""Configures stuff like autoloading in ~/.config/input-remapper-2/config.json."""
|
||||
|
||||
def __init__(self):
|
||||
self.path = os.path.join(PathUtils.config_path(), "config.json")
|
||||
self._config = copy.deepcopy(INITIAL_CONFIG)
|
||||
|
||||
def get_dir(self) -> str:
|
||||
"""The folder containing this config."""
|
||||
return os.path.split(self.path)[0]
|
||||
|
||||
def get_autoload_preset(self, group_key: str) -> Optional[str]:
|
||||
# modifications are only allowed via the setter, because it needs to write
|
||||
# the config file too. Therefore return a copy to prevent inconsistencies.
|
||||
return copy.deepcopy(self._config["autoload"].get(group_key))
|
||||
|
||||
def set_autoload_preset(self, group_key: str, preset: Optional[str]):
|
||||
"""Set a preset to be automatically applied on start.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
group_key
|
||||
the unique identifier of the group. This is used instead of the
|
||||
name to enable autoloading two different presets when two similar
|
||||
devices are connected.
|
||||
preset
|
||||
if None, don't autoload something for this device.
|
||||
"""
|
||||
if preset is not None:
|
||||
self._config["autoload"][group_key] = preset
|
||||
else:
|
||||
logger.info('Not injecting for "%s" automatically anmore', group_key)
|
||||
del self._config["autoload"][group_key]
|
||||
|
||||
self._save_config()
|
||||
|
||||
def iterate_autoload_presets(self):
|
||||
"""Get tuples of (device, preset)."""
|
||||
return self._config.get("autoload", {}).items()
|
||||
|
||||
def is_autoloaded(self, group_key: Optional[str], preset: Optional[str]):
|
||||
"""Should this preset be loaded automatically?"""
|
||||
if group_key is None or preset is None:
|
||||
raise ValueError("Expected group_key and preset to not be None")
|
||||
|
||||
return self._config.get("autoload", {}).get(group_key) == preset
|
||||
|
||||
def load_config(self, path: Optional[str] = None):
|
||||
"""Load the config from the file system.
|
||||
Parameters
|
||||
----------
|
||||
path
|
||||
If set, will change the path to load from and save to.
|
||||
"""
|
||||
if path is not None:
|
||||
if not os.path.exists(path):
|
||||
logger.error('Config at "%s" not found', path)
|
||||
return
|
||||
|
||||
self.path = path
|
||||
|
||||
self._clear_config()
|
||||
|
||||
if not os.path.exists(self.path):
|
||||
# treated like an empty config
|
||||
logger.debug('Config "%s" doesn\'t exist yet', self.path)
|
||||
self._clear_config()
|
||||
self._config = copy.deepcopy(INITIAL_CONFIG)
|
||||
self._save_config()
|
||||
return
|
||||
|
||||
with open(self.path, "r") as file:
|
||||
try:
|
||||
self._config.update(json.load(file))
|
||||
logger.info('Loaded config from "%s"', self.path)
|
||||
except json.decoder.JSONDecodeError as error:
|
||||
logger.error(
|
||||
'Failed to parse config "%s": %s. Using defaults',
|
||||
self.path,
|
||||
str(error),
|
||||
)
|
||||
# uses the default configuration when the config object
|
||||
# is empty automatically
|
||||
|
||||
def _save_config(self):
|
||||
"""Save the config to the file system."""
|
||||
if UserUtils.user == "root":
|
||||
logger.debug("Skipping config file creation for the root user")
|
||||
return
|
||||
|
||||
PathUtils.touch(self.path)
|
||||
|
||||
with open(self.path, "w") as file:
|
||||
json.dump(self._config, file, indent=4)
|
||||
logger.info("Saved config to %s", self.path)
|
||||
file.write("\n")
|
||||
|
||||
def _clear_config(self):
|
||||
"""Remove all configurations in memory."""
|
||||
self._config = {}
|
||||
485
inputremapper/configs/input_config.py
Normal file
485
inputremapper/configs/input_config.py
Normal file
@ -0,0 +1,485 @@
|
||||
# -*- 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/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from typing import Tuple, Iterable, Union, List, Dict, Optional, Hashable
|
||||
|
||||
from evdev import ecodes
|
||||
from inputremapper.configs.paths import PathUtils
|
||||
from inputremapper.input_event import InputEvent
|
||||
|
||||
try:
|
||||
from pydantic.v1 import BaseModel, root_validator, validator
|
||||
except ImportError:
|
||||
from pydantic import BaseModel, root_validator, validator
|
||||
|
||||
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||
from inputremapper.gui.messages.message_types import MessageType
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.utils import get_evdev_constant_name, DeviceHash
|
||||
|
||||
# having shift in combinations modifies the configured output,
|
||||
# ctrl might not work at all
|
||||
DIFFICULT_COMBINATIONS = [
|
||||
ecodes.KEY_LEFTSHIFT,
|
||||
ecodes.KEY_RIGHTSHIFT,
|
||||
ecodes.KEY_LEFTCTRL,
|
||||
ecodes.KEY_RIGHTCTRL,
|
||||
ecodes.KEY_LEFTALT,
|
||||
ecodes.KEY_RIGHTALT,
|
||||
]
|
||||
|
||||
EMPTY_TYPE = 99
|
||||
|
||||
# "Button 11" (10 + 0x110 "BTN_LEFT") is the highest mouse button simulatable
|
||||
# at time of writing using Piper 0.8 and a Logi G502X.
|
||||
# Please update if a way to simulate higher button-presses is found.
|
||||
MAX_BTN_MOUSE_ECODE = 0x11A
|
||||
|
||||
# Beware, this is without sign! Movement to the left needs this to be negative
|
||||
DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE = 30
|
||||
|
||||
|
||||
class InputConfig(BaseModel):
|
||||
"""Describes a single input within a combination, to configure mappings."""
|
||||
|
||||
message_type = MessageType.selected_event
|
||||
|
||||
type: int
|
||||
code: int
|
||||
|
||||
# origin_hash is a hash to identify a specific /dev/input/eventXX device.
|
||||
# This solves a number of bugs when multiple devices have overlapping capabilities.
|
||||
# see utils.get_device_hash for the exact hashing function
|
||||
origin_hash: Optional[DeviceHash] = None
|
||||
|
||||
# At which point is an analog input treated as "pressed". In percent (-99 to 99)
|
||||
# of the delta between a resting joystick and a maxed-out joystick.
|
||||
# Should be None if no analog input is configured.
|
||||
analog_threshold: Optional[int] = None
|
||||
|
||||
def __str__(self):
|
||||
return f"InputConfig {get_evdev_constant_name(self.type, self.code)}"
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<InputConfig {self.type_and_code} "
|
||||
f"{get_evdev_constant_name(*self.type_and_code)}, "
|
||||
f"{self.analog_threshold}, "
|
||||
f"{self.origin_hash}, "
|
||||
f"at {hex(id(self))}>"
|
||||
)
|
||||
|
||||
@property
|
||||
def input_match_hash(self) -> Hashable:
|
||||
"""a Hashable object which is intended to match the InputConfig with a
|
||||
InputEvent.
|
||||
|
||||
InputConfig itself is hashable, but can not be used to match InputEvent's
|
||||
because its hash includes the analog_threshold
|
||||
"""
|
||||
return self.type, self.code, self.origin_hash
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.type == EMPTY_TYPE
|
||||
|
||||
@property
|
||||
def defines_analog_input(self) -> bool:
|
||||
"""Whether this defines an analog input."""
|
||||
return not self.analog_threshold and self.type != ecodes.EV_KEY
|
||||
|
||||
@property
|
||||
def type_and_code(self) -> Tuple[int, int]:
|
||||
"""Event type, code."""
|
||||
return self.type, self.code
|
||||
|
||||
@classmethod
|
||||
def btn_left(cls):
|
||||
return cls(type=ecodes.EV_KEY, code=ecodes.BTN_LEFT)
|
||||
|
||||
@classmethod
|
||||
def from_input_event(cls, event: InputEvent) -> InputConfig:
|
||||
"""create an input confing from the given InputEvent, uses the value as
|
||||
analog threshold"""
|
||||
return cls(
|
||||
type=event.type,
|
||||
code=event.code,
|
||||
origin_hash=event.origin_hash,
|
||||
analog_threshold=event.value,
|
||||
)
|
||||
|
||||
def description(self, exclude_threshold=False, exclude_direction=False) -> str:
|
||||
"""Get a human-readable description of the event."""
|
||||
return (
|
||||
f"{self._get_name()} "
|
||||
f"{self._get_direction() if not exclude_direction else ''} "
|
||||
f"{self._get_threshold_value() if not exclude_threshold else ''}".strip()
|
||||
)
|
||||
|
||||
def _get_mouse_button_name(self) -> Optional[str]:
|
||||
"""Get a human-readable description of a mouse-button. Only the first 7
|
||||
mouse buttons are in evdev and they often have misleading names there
|
||||
(eg it calls buttons 6 & 7 forward/back but usually that's buttons 5 & 4).
|
||||
Returns None if not a mouse button."""
|
||||
|
||||
if self.type == ecodes.EV_KEY:
|
||||
if ecodes.BTN_MOUSE <= self.code <= ecodes.BTN_MIDDLE:
|
||||
# button is left/right/middle button
|
||||
key_name: str = get_evdev_constant_name(self.type, self.code)
|
||||
return key_name.replace(
|
||||
"BTN_", "Mouse Button "
|
||||
) # eg "Mouse Button LEFT"
|
||||
elif ecodes.BTN_MIDDLE < self.code <= MAX_BTN_MOUSE_ECODE:
|
||||
# button is a higher-number mouse button like side-buttons.
|
||||
# This calculation assumes left mouse button is button 1, so side buttons start at 4.
|
||||
button_number: int = self.code - ecodes.BTN_MOUSE + 1
|
||||
return f"Mouse Button {button_number}" # eg "Mouse Button 7"
|
||||
|
||||
return None
|
||||
|
||||
def _get_name(self) -> Optional[str]:
|
||||
"""Human-readable name (e.g. KEY_A) of the specified input event."""
|
||||
|
||||
# prevent logging warnings for new/empty configs
|
||||
if self.is_empty:
|
||||
return None
|
||||
|
||||
# must check if it's a mouse button *before* ecodes
|
||||
# because not all mouse buttons are in ecodes.
|
||||
mouse_button_name: Optional[str] = self._get_mouse_button_name()
|
||||
if mouse_button_name is not None:
|
||||
return mouse_button_name
|
||||
|
||||
if self.type not in ecodes.bytype:
|
||||
logger.warning("Unknown type for %s", self)
|
||||
return f"unknown {self.type, self.code}"
|
||||
|
||||
if self.code not in ecodes.bytype[self.type]:
|
||||
logger.warning("Unknown code for %s", self)
|
||||
return f"unknown {self.type, self.code}"
|
||||
|
||||
key_name = None
|
||||
|
||||
# first try to find the name in xmodmap to not display wrong
|
||||
# names due to the keyboard layout
|
||||
if self.type == ecodes.EV_KEY:
|
||||
key_name = keyboard_layout.get_name(self.code)
|
||||
|
||||
if key_name is None:
|
||||
# if no result, look in the linux combination constants. On a german
|
||||
# keyboard for example z and y are switched, which will therefore
|
||||
# cause the wrong letter to be displayed.
|
||||
key_name = get_evdev_constant_name(self.type, self.code)
|
||||
|
||||
key_name = key_name.replace("ABS_Z", "Trigger Left")
|
||||
key_name = key_name.replace("ABS_RZ", "Trigger Right")
|
||||
|
||||
key_name = key_name.replace("ABS_HAT0X", "DPad-X")
|
||||
key_name = key_name.replace("ABS_HAT0Y", "DPad-Y")
|
||||
key_name = key_name.replace("ABS_HAT1X", "DPad-2-X")
|
||||
key_name = key_name.replace("ABS_HAT1Y", "DPad-2-Y")
|
||||
key_name = key_name.replace("ABS_HAT2X", "DPad-3-X")
|
||||
key_name = key_name.replace("ABS_HAT2Y", "DPad-3-Y")
|
||||
|
||||
key_name = key_name.replace("ABS_X", "Joystick-X")
|
||||
key_name = key_name.replace("ABS_Y", "Joystick-Y")
|
||||
key_name = key_name.replace("ABS_RX", "Joystick-RX")
|
||||
key_name = key_name.replace("ABS_RY", "Joystick-RY")
|
||||
|
||||
key_name = key_name.replace("BTN_", "Button ")
|
||||
key_name = key_name.replace("KEY_", "")
|
||||
|
||||
key_name = key_name.replace("REL_", "")
|
||||
key_name = key_name.replace("HWHEEL", "Wheel")
|
||||
key_name = key_name.replace("WHEEL", "Wheel")
|
||||
|
||||
key_name = key_name.replace("_", " ")
|
||||
key_name = key_name.replace(" ", " ")
|
||||
return key_name
|
||||
|
||||
def _get_direction(self) -> str:
|
||||
"""human-readable direction description for the analog_threshold"""
|
||||
if self.type == ecodes.EV_KEY or self.defines_analog_input:
|
||||
return ""
|
||||
|
||||
assert self.analog_threshold
|
||||
threshold_direction = self.analog_threshold // abs(self.analog_threshold)
|
||||
return {
|
||||
# D-Pad
|
||||
(ecodes.ABS_HAT0X, -1): "Left",
|
||||
(ecodes.ABS_HAT0X, 1): "Right",
|
||||
(ecodes.ABS_HAT0Y, -1): "Up",
|
||||
(ecodes.ABS_HAT0Y, 1): "Down",
|
||||
(ecodes.ABS_HAT1X, -1): "Left",
|
||||
(ecodes.ABS_HAT1X, 1): "Right",
|
||||
(ecodes.ABS_HAT1Y, -1): "Up",
|
||||
(ecodes.ABS_HAT1Y, 1): "Down",
|
||||
(ecodes.ABS_HAT2X, -1): "Left",
|
||||
(ecodes.ABS_HAT2X, 1): "Right",
|
||||
(ecodes.ABS_HAT2Y, -1): "Up",
|
||||
(ecodes.ABS_HAT2Y, 1): "Down",
|
||||
# joystick
|
||||
(ecodes.ABS_X, 1): "Right",
|
||||
(ecodes.ABS_X, -1): "Left",
|
||||
(ecodes.ABS_Y, 1): "Down",
|
||||
(ecodes.ABS_Y, -1): "Up",
|
||||
(ecodes.ABS_RX, 1): "Right",
|
||||
(ecodes.ABS_RX, -1): "Left",
|
||||
(ecodes.ABS_RY, 1): "Down",
|
||||
(ecodes.ABS_RY, -1): "Up",
|
||||
# wheel
|
||||
(ecodes.REL_WHEEL, -1): "Down",
|
||||
(ecodes.REL_WHEEL, 1): "Up",
|
||||
(ecodes.REL_HWHEEL, -1): "Left",
|
||||
(ecodes.REL_HWHEEL, 1): "Right",
|
||||
}.get((self.code, threshold_direction)) or (
|
||||
"+" if threshold_direction > 0 else "-"
|
||||
)
|
||||
|
||||
def _get_threshold_value(self) -> str:
|
||||
"""human-readable value of the analog_threshold e.g. '20%'"""
|
||||
if self.analog_threshold is None:
|
||||
return ""
|
||||
return {
|
||||
ecodes.EV_REL: f"{abs(self.analog_threshold)}",
|
||||
ecodes.EV_ABS: f"{abs(self.analog_threshold)}%",
|
||||
}.get(self.type) or ""
|
||||
|
||||
def modify(
|
||||
self,
|
||||
type_: Optional[int] = None,
|
||||
code: Optional[int] = None,
|
||||
origin_hash: Optional[str] = None,
|
||||
analog_threshold: Optional[int] = None,
|
||||
) -> InputConfig:
|
||||
"""Return a new modified event."""
|
||||
return InputConfig(
|
||||
type=type_ if type_ is not None else self.type,
|
||||
code=code if code is not None else self.code,
|
||||
origin_hash=origin_hash if origin_hash is not None else self.origin_hash,
|
||||
analog_threshold=(
|
||||
analog_threshold
|
||||
if analog_threshold is not None
|
||||
else self.analog_threshold
|
||||
),
|
||||
)
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.type, self.code, self.origin_hash, self.analog_threshold))
|
||||
|
||||
@validator("analog_threshold")
|
||||
def _ensure_analog_threshold_is_none(cls, analog_threshold):
|
||||
"""ensure the analog threshold is None, not zero."""
|
||||
if analog_threshold == 0 or analog_threshold is None:
|
||||
# the sign of the threshold defines the direction the joystick has to move.
|
||||
# 0 is invalid, it has no sign. And for triggers, what does a threshold of
|
||||
# 0 mean? Would it always be active?
|
||||
return None
|
||||
|
||||
return analog_threshold
|
||||
|
||||
@root_validator
|
||||
def _remove_analog_threshold_for_key_input(cls, values):
|
||||
"""remove the analog threshold if the type is a EV_KEY"""
|
||||
type_ = values.get("type")
|
||||
if type_ == ecodes.EV_KEY:
|
||||
values["analog_threshold"] = None
|
||||
return values
|
||||
|
||||
@root_validator(pre=True)
|
||||
def validate_origin_hash(cls, values):
|
||||
origin_hash = values.get("origin_hash")
|
||||
if origin_hash is None:
|
||||
# For new presets, origin_hash should be set. For old ones, it can
|
||||
# be still missing. A lot of tests didn't set an origin_hash.
|
||||
if values.get("type") != EMPTY_TYPE:
|
||||
logger.warning("No origin_hash set for %s", values)
|
||||
|
||||
return values
|
||||
|
||||
values["origin_hash"] = origin_hash.lower()
|
||||
return values
|
||||
|
||||
class Config:
|
||||
allow_mutation = False
|
||||
underscore_attrs_are_private = True
|
||||
|
||||
|
||||
InputCombinationInit = Union[
|
||||
Iterable[Dict[str, Union[str, int]]],
|
||||
Iterable[InputConfig],
|
||||
]
|
||||
|
||||
|
||||
class InputCombination(Tuple[InputConfig, ...]):
|
||||
"""One or more InputConfigs used to trigger a mapping."""
|
||||
|
||||
# tuple is immutable, therefore we need to override __new__()
|
||||
# https://jfine-python-classes.readthedocs.io/en/latest/subclass-tuple.html
|
||||
def __new__(cls, configs: InputCombinationInit) -> InputCombination:
|
||||
"""Create a new InputCombination.
|
||||
|
||||
Examples
|
||||
--------
|
||||
InputCombination([InputConfig, ...])
|
||||
InputCombination([{type: ..., code: ..., value: ...}, ...])
|
||||
"""
|
||||
if not isinstance(configs, Iterable):
|
||||
raise TypeError("InputCombination requires a list of InputConfigs.")
|
||||
|
||||
if isinstance(configs, InputConfig):
|
||||
# wrap the argument in square brackets
|
||||
raise TypeError("InputCombination requires a list of InputConfigs.")
|
||||
|
||||
validated_configs = []
|
||||
for config in configs:
|
||||
if isinstance(configs, InputEvent):
|
||||
raise TypeError("InputCombinations require InputConfigs, not Events.")
|
||||
|
||||
if isinstance(config, InputConfig):
|
||||
validated_configs.append(config)
|
||||
elif isinstance(config, dict):
|
||||
validated_configs.append(InputConfig(**config))
|
||||
else:
|
||||
raise TypeError(f'Can\'t handle "{config}"')
|
||||
|
||||
if len(validated_configs) == 0:
|
||||
raise ValueError(f"failed to create InputCombination with {configs = }")
|
||||
|
||||
# mypy bug: https://github.com/python/mypy/issues/8957
|
||||
# https://github.com/python/mypy/issues/8541
|
||||
return super().__new__(cls, validated_configs) # type: ignore
|
||||
|
||||
def __str__(self):
|
||||
return f'Combination ({" + ".join(str(event) for event in self)})'
|
||||
|
||||
def __repr__(self):
|
||||
combination = ", ".join(repr(event) for event in self)
|
||||
return f"<InputCombination ({combination}) at {hex(id(self))}>"
|
||||
|
||||
@classmethod
|
||||
def __get_validators__(cls):
|
||||
"""Used by pydantic to create InputCombination objects."""
|
||||
yield cls.validate
|
||||
|
||||
@classmethod
|
||||
def validate(cls, init_arg) -> InputCombination:
|
||||
"""The only valid option is from_config"""
|
||||
if isinstance(init_arg, InputCombination):
|
||||
return init_arg
|
||||
return cls(init_arg)
|
||||
|
||||
def to_config(self) -> Tuple[Dict[str, int], ...]:
|
||||
"""Turn the object into a tuple of dicts."""
|
||||
return tuple(input_config.dict(exclude_defaults=True) for input_config in self)
|
||||
|
||||
@classmethod
|
||||
def empty_combination(cls) -> InputCombination:
|
||||
"""A combination that has default invalid (to evdev) values.
|
||||
|
||||
Useful for the UI to indicate that this combination is not set
|
||||
"""
|
||||
return cls([{"type": EMPTY_TYPE, "code": 99, "analog_threshold": 99}])
|
||||
|
||||
@classmethod
|
||||
def from_tuples(cls, *tuples):
|
||||
"""Construct an InputCombination from (type, code, analog_threshold) tuples."""
|
||||
dicts = []
|
||||
for tuple_ in tuples:
|
||||
if len(tuple_) == 3:
|
||||
dicts.append(
|
||||
{
|
||||
"type": tuple_[0],
|
||||
"code": tuple_[1],
|
||||
"analog_threshold": tuple_[2],
|
||||
}
|
||||
)
|
||||
elif len(tuple_) == 2:
|
||||
dicts.append(
|
||||
{
|
||||
"type": tuple_[0],
|
||||
"code": tuple_[1],
|
||||
}
|
||||
)
|
||||
else:
|
||||
raise TypeError
|
||||
|
||||
return cls(dicts)
|
||||
|
||||
def is_problematic(self) -> bool:
|
||||
"""Is this combination going to work properly on all systems?"""
|
||||
if len(self) <= 1:
|
||||
return False
|
||||
|
||||
for input_config in self:
|
||||
if input_config.type != ecodes.EV_KEY:
|
||||
continue
|
||||
|
||||
if input_config.code in DIFFICULT_COMBINATIONS:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def defines_analog_input(self) -> bool:
|
||||
"""Check if there is any analog input in self."""
|
||||
return True in tuple(i.defines_analog_input for i in self)
|
||||
|
||||
def find_analog_input_config(
|
||||
self, type_: Optional[int] = None
|
||||
) -> Optional[InputConfig]:
|
||||
"""Return the first event that defines an analog input."""
|
||||
for input_config in self:
|
||||
if input_config.defines_analog_input and (
|
||||
type_ is None or input_config.type == type_
|
||||
):
|
||||
return input_config
|
||||
return None
|
||||
|
||||
def get_permutations(self) -> List[InputCombination]:
|
||||
"""Get a list of EventCombinations representing all possible permutations.
|
||||
|
||||
combining a + b + c should have the same result as b + a + c.
|
||||
Only the last combination remains the same in the returned result.
|
||||
"""
|
||||
if len(self) <= 2:
|
||||
return [self]
|
||||
|
||||
if len(self) > 6:
|
||||
logger.warning(
|
||||
"Your input combination has a length of %d. Long combinations might "
|
||||
'freeze the process. Edit the configuration files in "%s" to fix it.',
|
||||
len(self),
|
||||
PathUtils.get_config_path(),
|
||||
)
|
||||
|
||||
permutations = []
|
||||
for permutation in itertools.permutations(self[:-1]):
|
||||
permutations.append(InputCombination((*permutation, self[-1])))
|
||||
|
||||
return permutations
|
||||
|
||||
def beautify(self) -> str:
|
||||
"""Get a human-readable string representation."""
|
||||
if self == InputCombination.empty_combination():
|
||||
return "empty_combination"
|
||||
return " + ".join(event.description(exclude_threshold=True) for event in self)
|
||||
221
inputremapper/configs/keyboard_layout.py
Normal file
221
inputremapper/configs/keyboard_layout.py
Normal file
@ -0,0 +1,221 @@
|
||||
# -*- 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/>.
|
||||
"""Make the systems/environments mapping of keys and codes accessible."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Optional, List, Iterable, Tuple
|
||||
|
||||
import evdev
|
||||
|
||||
from inputremapper.configs.paths import PathUtils
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.utils import is_service
|
||||
|
||||
DISABLE_NAME = "disable"
|
||||
|
||||
DISABLE_CODE = -1
|
||||
|
||||
# xkb uses keycodes that are 8 higher than those from evdev
|
||||
XKB_KEYCODE_OFFSET = 8
|
||||
|
||||
XMODMAP_FILENAME = "xmodmap.json"
|
||||
|
||||
LAZY_LOAD = None
|
||||
|
||||
|
||||
class KeyboardLayout:
|
||||
"""Stores information about all available keycodes."""
|
||||
|
||||
_mapping: Optional[dict] = LAZY_LOAD
|
||||
_xmodmap: Optional[List[Tuple[str, str]]] = LAZY_LOAD
|
||||
_case_insensitive_mapping: Optional[dict] = LAZY_LOAD
|
||||
|
||||
def __getattribute__(self, wanted: str):
|
||||
"""To lazy load keyboard_layout info only when needed.
|
||||
|
||||
For example, this helps to keep logs of input-remapper-control clear when it
|
||||
doesn't need it the information.
|
||||
"""
|
||||
lazy_loaded_attributes = ["_mapping", "_xmodmap", "_case_insensitive_mapping"]
|
||||
for lazy_loaded_attribute in lazy_loaded_attributes:
|
||||
if wanted != lazy_loaded_attribute:
|
||||
continue
|
||||
|
||||
if object.__getattribute__(self, lazy_loaded_attribute) is LAZY_LOAD:
|
||||
# initialize _mapping and such with an empty dict, for populate
|
||||
# to write into
|
||||
object.__setattr__(self, lazy_loaded_attribute, {})
|
||||
object.__getattribute__(self, "populate")()
|
||||
|
||||
return object.__getattribute__(self, wanted)
|
||||
|
||||
def list_names(self, codes: Optional[Iterable[int]] = None) -> List[str]:
|
||||
"""Get all possible names in the mapping, optionally filtered by codes.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
codes: list of event codes
|
||||
"""
|
||||
if not codes:
|
||||
return self._mapping.keys()
|
||||
|
||||
return [name for name, code in self._mapping.items() if code in codes]
|
||||
|
||||
def correct_case(self, symbol: str):
|
||||
"""Return the correct casing for a symbol."""
|
||||
if symbol in self._mapping:
|
||||
return symbol
|
||||
# only if not e.g. both "a" and "A" are in the mapping
|
||||
return self._case_insensitive_mapping.get(symbol.lower(), symbol)
|
||||
|
||||
def _use_xmodmap_symbols(self):
|
||||
"""Look up xmodmap -pke, write xmodmap.json, and get the symbols."""
|
||||
try:
|
||||
xmodmap = subprocess.check_output(
|
||||
["xmodmap", "-pke"],
|
||||
stderr=subprocess.STDOUT,
|
||||
).decode()
|
||||
except FileNotFoundError:
|
||||
logger.info("Optional `xmodmap` command not found. This is not critical.")
|
||||
return
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error('Call to `xmodmap -pke` failed with "%s"', e)
|
||||
return
|
||||
|
||||
self._xmodmap = re.findall(r"(\d+) = (.+)\n", xmodmap + "\n")
|
||||
xmodmap_dict = self._find_legit_mappings()
|
||||
if len(xmodmap_dict) == 0:
|
||||
logger.info("`xmodmap -pke` did not yield any symbol")
|
||||
return
|
||||
|
||||
# Write this stuff into the input-remapper config directory, because
|
||||
# the systemd service won't know the user sessions xmodmap.
|
||||
path = PathUtils.get_config_path(XMODMAP_FILENAME)
|
||||
PathUtils.touch(path)
|
||||
with open(path, "w") as file:
|
||||
logger.debug('Writing "%s"', path)
|
||||
json.dump(xmodmap_dict, file, indent=4)
|
||||
|
||||
for name, code in xmodmap_dict.items():
|
||||
self._set(name, code)
|
||||
|
||||
def _use_linux_evdev_symbols(self):
|
||||
"""Look up the evdev constant names and use them."""
|
||||
for name, ecode in evdev.ecodes.ecodes.items():
|
||||
if name.startswith("KEY") or name.startswith("BTN"):
|
||||
self._set(name, ecode)
|
||||
|
||||
def populate(self):
|
||||
"""Get a mapping of all available names to their keycodes."""
|
||||
logger.debug("Gathering available keycodes")
|
||||
self.clear()
|
||||
|
||||
if not is_service():
|
||||
# xmodmap is only available from within the login session.
|
||||
# The service that runs via systemd can't use this.
|
||||
self._use_xmodmap_symbols()
|
||||
|
||||
self._use_linux_evdev_symbols()
|
||||
|
||||
self._set(DISABLE_NAME, DISABLE_CODE)
|
||||
|
||||
def update(self, mapping: dict):
|
||||
"""Update this with new keys.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mapping
|
||||
maps from name to code. Make sure your keys are lowercase.
|
||||
"""
|
||||
len_before = len(self._mapping)
|
||||
for name, code in mapping.items():
|
||||
self._set(name, code)
|
||||
|
||||
logger.debug(
|
||||
"Updated keycodes with %d new ones", len(self._mapping) - len_before
|
||||
)
|
||||
|
||||
def _set(self, name: str, code: int):
|
||||
"""Map name to code."""
|
||||
self._mapping[str(name)] = code
|
||||
self._case_insensitive_mapping[str(name).lower()] = name
|
||||
|
||||
def get(self, name: str) -> Optional[int]:
|
||||
"""Return the code mapped to the key."""
|
||||
# the correct casing should be shown when asking the keyboard_layout
|
||||
# for stuff. indexing case insensitive to support old presets.
|
||||
if name not in self._mapping:
|
||||
# only if not e.g. both "a" and "A" are in the mapping
|
||||
name = self._case_insensitive_mapping.get(str(name).lower())
|
||||
|
||||
return self._mapping.get(name)
|
||||
|
||||
def clear(self):
|
||||
"""Remove all mapped keys. Only needed for tests."""
|
||||
keys = list(self._mapping.keys())
|
||||
for key in keys:
|
||||
del self._mapping[key]
|
||||
|
||||
def get_name(self, code: int):
|
||||
"""Get the first matching name for the code."""
|
||||
for entry in self._xmodmap:
|
||||
if int(entry[0]) - XKB_KEYCODE_OFFSET == code:
|
||||
return entry[1].split()[0]
|
||||
|
||||
# Fall back to the linux constants
|
||||
# This is especially important for BTN_LEFT and such
|
||||
btn_name = evdev.ecodes.BTN.get(code, None)
|
||||
if btn_name is not None:
|
||||
if type(btn_name) in [list, tuple]:
|
||||
# python-evdev >= 1.8.0 uses tuples
|
||||
return btn_name[0]
|
||||
|
||||
return btn_name
|
||||
|
||||
key_name = evdev.ecodes.KEY.get(code, None)
|
||||
if key_name is not None:
|
||||
if type(key_name) in [list, tuple]:
|
||||
# python-evdev >= 1.8.0 uses tuples
|
||||
return key_name[0]
|
||||
|
||||
return key_name
|
||||
|
||||
return None
|
||||
|
||||
def _find_legit_mappings(self) -> dict:
|
||||
"""From the parsed xmodmap list find usable symbols and their codes."""
|
||||
xmodmap_dict = {}
|
||||
for keycode, names in self._xmodmap:
|
||||
# there might be multiple, like:
|
||||
# keycode 64 = Alt_L Meta_L Alt_L Meta_L
|
||||
# keycode 204 = NoSymbol Alt_L NoSymbol Alt_L
|
||||
# Alt_L should map to code 64. Writing code 204 only works
|
||||
# if a modifier is applied at the same time. So take the first
|
||||
# one.
|
||||
name = names.split()[0]
|
||||
xmodmap_dict[name] = int(keycode) - XKB_KEYCODE_OFFSET
|
||||
|
||||
return xmodmap_dict
|
||||
|
||||
|
||||
# TODO DI
|
||||
# this mapping represents the xmodmap output, which stays constant
|
||||
keyboard_layout = KeyboardLayout()
|
||||
525
inputremapper/configs/mapping.py
Normal file
525
inputremapper/configs/mapping.py
Normal file
@ -0,0 +1,525 @@
|
||||
# -*- 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/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from collections import namedtuple
|
||||
from typing import Optional, Callable, Tuple, TypeVar, Union, Any, Dict
|
||||
|
||||
from evdev.ecodes import (
|
||||
EV_KEY,
|
||||
EV_ABS,
|
||||
EV_REL,
|
||||
REL_WHEEL,
|
||||
REL_HWHEEL,
|
||||
REL_HWHEEL_HI_RES,
|
||||
REL_WHEEL_HI_RES,
|
||||
)
|
||||
from packaging import version
|
||||
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
try:
|
||||
from pydantic.v1 import (
|
||||
BaseModel,
|
||||
PositiveInt,
|
||||
confloat,
|
||||
conint,
|
||||
root_validator,
|
||||
validator,
|
||||
ValidationError,
|
||||
PositiveFloat,
|
||||
VERSION,
|
||||
BaseConfig,
|
||||
)
|
||||
except ImportError:
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
PositiveInt,
|
||||
confloat,
|
||||
conint,
|
||||
root_validator,
|
||||
validator,
|
||||
ValidationError,
|
||||
PositiveFloat,
|
||||
VERSION,
|
||||
BaseConfig,
|
||||
)
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination
|
||||
from inputremapper.configs.keyboard_layout import keyboard_layout, DISABLE_NAME
|
||||
from inputremapper.configs.validation_errors import (
|
||||
OutputSymbolUnknownError,
|
||||
SymbolNotAvailableInTargetError,
|
||||
OnlyOneAnalogInputError,
|
||||
TriggerPointInRangeError,
|
||||
OutputSymbolVariantError,
|
||||
MacroButTypeOrCodeSetError,
|
||||
SymbolAndCodeMismatchError,
|
||||
WrongMappingTypeForKeyError,
|
||||
MissingOutputAxisError,
|
||||
MissingMacroOrKeyError,
|
||||
)
|
||||
from inputremapper.gui.gettext import _
|
||||
from inputremapper.gui.messages.message_types import MessageType
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.macros.parse import Parser
|
||||
from inputremapper.utils import get_evdev_constant_name
|
||||
|
||||
# TODO: remove pydantic VERSION check as soon as we no longer support
|
||||
# Ubuntu 20.04 and with it the ancient pydantic 1.2
|
||||
|
||||
needs_workaround = version.parse(str(VERSION)) < version.parse("1.7.1")
|
||||
|
||||
|
||||
EMPTY_MAPPING_NAME: str = _("Empty Mapping")
|
||||
|
||||
# If `1` is the default speed for EV_REL, how much does this value needs to be scaled
|
||||
# up to get reasonable speeds for various EV_REL events?
|
||||
# Mouse injection rates vary wildly, and so do the values.
|
||||
REL_XY_SCALING: float = 60
|
||||
WHEEL_SCALING: float = 1
|
||||
# WHEEL_HI_RES always generates events with 120 times higher values than WHEEL
|
||||
# https://www.kernel.org/doc/html/latest/input/event-codes.html?highlight=wheel_hi_res#ev-rel
|
||||
WHEEL_HI_RES_SCALING: float = 120
|
||||
# Those values are assuming a rate of 60hz
|
||||
DEFAULT_REL_RATE: float = 60
|
||||
|
||||
|
||||
class KnownUinput(str, enum.Enum):
|
||||
"""The default targets."""
|
||||
|
||||
KEYBOARD = "keyboard"
|
||||
MOUSE = "mouse"
|
||||
GAMEPAD = "gamepad"
|
||||
KEYBOARD_MOUSE = "keyboard + mouse"
|
||||
|
||||
|
||||
class MappingType(str, enum.Enum):
|
||||
"""What kind of output the mapping produces."""
|
||||
|
||||
KEY_MACRO = "key_macro"
|
||||
ANALOG = "analog"
|
||||
|
||||
|
||||
CombinationChangedCallback = Optional[
|
||||
Callable[[InputCombination, InputCombination], None]
|
||||
]
|
||||
MappingModel = TypeVar("MappingModel", bound="UIMapping")
|
||||
|
||||
|
||||
class Cfg(BaseConfig):
|
||||
validate_assignment = True
|
||||
use_enum_values = True
|
||||
underscore_attrs_are_private = True
|
||||
json_encoders = {InputCombination: lambda v: v.json_key()}
|
||||
|
||||
|
||||
class ImmutableCfg(Cfg):
|
||||
allow_mutation = False
|
||||
|
||||
|
||||
class UIMapping(BaseModel):
|
||||
"""Holds all the data for mapping an input action to an output action.
|
||||
|
||||
The Preset contains multiple UIMappings.
|
||||
|
||||
This mapping does not validate the structure of the mapping or macros, only basic
|
||||
values. It is meant to be used in the GUI where invalid mappings are expected.
|
||||
"""
|
||||
|
||||
if needs_workaround:
|
||||
__slots__ = ("_combination_changed",)
|
||||
|
||||
# Required attributes
|
||||
# The InputEvent or InputEvent combination which is mapped
|
||||
input_combination: InputCombination = InputCombination.empty_combination()
|
||||
# The UInput to which the mapped event will be sent
|
||||
target_uinput: Optional[Union[str, KnownUinput]] = None
|
||||
|
||||
# Either `output_symbol` or `output_type` and `output_code` is required
|
||||
# Only set if output is "Key or Macro":
|
||||
output_symbol: Optional[str] = None # The symbol or macro string if applicable
|
||||
# "Analog Axis" or if preset edited manually to inject a code instead of a symbol:
|
||||
output_type: Optional[int] = None # The event type of the mapped event
|
||||
output_code: Optional[int] = None # The event code of the mapped event
|
||||
|
||||
name: Optional[str] = None
|
||||
mapping_type: Optional[MappingType] = None
|
||||
|
||||
# if release events will be sent to the forwarded device as soon as a combination
|
||||
# triggers see also #229
|
||||
release_combination_keys: bool = True
|
||||
|
||||
# macro settings
|
||||
macro_key_sleep_ms: conint(ge=0) = 0 # type: ignore
|
||||
|
||||
# Optional attributes for mapping Axis to Axis
|
||||
# The deadzone of the input axis
|
||||
deadzone: confloat(ge=0, le=1) = 0.1 # type: ignore
|
||||
gain: float = 1.0 # The scale factor for the transformation
|
||||
# The expo factor for the transformation
|
||||
expo: confloat(ge=-1, le=1) = 0 # type: ignore
|
||||
|
||||
# when mapping to relative axis
|
||||
# The frequency [Hz] at which EV_REL events get generated
|
||||
rel_rate: PositiveInt = 60
|
||||
|
||||
# when mapping from a relative axis:
|
||||
# the relative value at which a EV_REL axis is considered at its maximum. Moving
|
||||
# a mouse at 2x the regular speed would be considered max by default.
|
||||
rel_to_abs_input_cutoff: PositiveInt = 2
|
||||
|
||||
# the time until a relative axis is considered stationary if no new events arrive
|
||||
release_timeout: PositiveFloat = 0.05
|
||||
# don't release immediately when a relative axis drops below the speed threshold
|
||||
# instead wait until it dropped for loger than release_timeout below the threshold
|
||||
force_release_timeout: bool = False
|
||||
|
||||
# callback which gets called if the input_combination is updated
|
||||
if not needs_workaround:
|
||||
_combination_changed: Optional[CombinationChangedCallback] = None
|
||||
|
||||
# use type: ignore, looks like a mypy bug related to:
|
||||
# https://github.com/samuelcolvin/pydantic/issues/2949
|
||||
def __init__(self, **kwargs): # type: ignore
|
||||
super().__init__(**kwargs)
|
||||
if needs_workaround:
|
||||
object.__setattr__(self, "_combination_changed", None)
|
||||
|
||||
def __setattr__(self, key: str, value: Any):
|
||||
"""Call the combination changed callback
|
||||
if we are about to update the input_combination
|
||||
"""
|
||||
if key != "input_combination" or self._combination_changed is None:
|
||||
if key == "_combination_changed" and needs_workaround:
|
||||
object.__setattr__(self, "_combination_changed", value)
|
||||
return
|
||||
super().__setattr__(key, value)
|
||||
return
|
||||
|
||||
# the new combination is not yet validated
|
||||
try:
|
||||
new_combi = InputCombination.validate(value)
|
||||
except (ValueError, TypeError) as exception:
|
||||
raise ValidationError(
|
||||
f"failed to Validate {value} as InputCombination", UIMapping
|
||||
) from exception
|
||||
|
||||
if new_combi == self.input_combination:
|
||||
return
|
||||
|
||||
# raises a keyError if the combination or a permutation is already mapped
|
||||
self._combination_changed(new_combi, self.input_combination)
|
||||
super().__setattr__("input_combination", new_combi)
|
||||
|
||||
def __str__(self):
|
||||
return str(
|
||||
self.dict(
|
||||
exclude_defaults=True, include={"input_combination", "target_uinput"}
|
||||
)
|
||||
)
|
||||
|
||||
if needs_workaround:
|
||||
# https://github.com/samuelcolvin/pydantic/issues/1383
|
||||
def copy(self: MappingModel, *args, **kwargs) -> MappingModel:
|
||||
kwargs["deep"] = True
|
||||
copy = super().copy(*args, **kwargs)
|
||||
object.__setattr__(copy, "_combination_changed", self._combination_changed)
|
||||
return copy
|
||||
|
||||
def format_name(self) -> str:
|
||||
"""Get the custom-name or a readable representation of the combination."""
|
||||
if self.name:
|
||||
return self.name
|
||||
|
||||
if (
|
||||
self.input_combination == InputCombination.empty_combination()
|
||||
or self.input_combination is None
|
||||
):
|
||||
return EMPTY_MAPPING_NAME
|
||||
|
||||
return self.input_combination.beautify()
|
||||
|
||||
def has_input_defined(self) -> bool:
|
||||
"""Whether this mapping defines an event-input."""
|
||||
return self.input_combination != InputCombination.empty_combination()
|
||||
|
||||
def is_axis_mapping(self) -> bool:
|
||||
"""Whether this mapping specifies an output axis."""
|
||||
return self.output_type in [EV_ABS, EV_REL]
|
||||
|
||||
def is_wheel_output(self) -> bool:
|
||||
"""Check if this maps to wheel output."""
|
||||
return self.output_code in (
|
||||
REL_WHEEL,
|
||||
REL_HWHEEL,
|
||||
)
|
||||
|
||||
def is_high_res_wheel_output(self) -> bool:
|
||||
"""Check if this maps to high-res wheel output."""
|
||||
return self.output_code in (
|
||||
REL_WHEEL_HI_RES,
|
||||
REL_HWHEEL_HI_RES,
|
||||
)
|
||||
|
||||
def is_analog_output(self):
|
||||
return self.mapping_type == MappingType.ANALOG
|
||||
|
||||
def set_combination_changed_callback(self, callback: CombinationChangedCallback):
|
||||
self._combination_changed = callback
|
||||
|
||||
def remove_combination_changed_callback(self):
|
||||
self._combination_changed = None
|
||||
|
||||
def get_output_type_code(self) -> Optional[Tuple[int, int]]:
|
||||
"""Returns the output_type and output_code if set,
|
||||
otherwise looks the output_symbol up in the keyboard_layout
|
||||
return None for unknown symbols and macros
|
||||
"""
|
||||
if self.output_code is not None and self.output_type is not None:
|
||||
return self.output_type, self.output_code
|
||||
|
||||
if self.output_symbol and not Parser.is_this_a_macro(self.output_symbol):
|
||||
return EV_KEY, keyboard_layout.get(self.output_symbol)
|
||||
|
||||
return None
|
||||
|
||||
def get_output_name_constant(self) -> str:
|
||||
"""Get the evdev name costant for the output."""
|
||||
return get_evdev_constant_name(self.output_type, self.output_code)
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""If the mapping is valid."""
|
||||
return not self.get_error()
|
||||
|
||||
def get_error(self) -> Optional[ValidationError]:
|
||||
"""The validation error or None."""
|
||||
try:
|
||||
Mapping(**self.dict())
|
||||
except ValidationError as exception:
|
||||
return exception
|
||||
return None
|
||||
|
||||
def get_bus_message(self) -> MappingData:
|
||||
"""Return an immutable copy for use in the message broker."""
|
||||
return MappingData(**self.dict())
|
||||
|
||||
@root_validator
|
||||
def validate_mapping_type(cls, values):
|
||||
"""Overrides the mapping type if the output mapping type is obvious."""
|
||||
output_type = values.get("output_type")
|
||||
output_code = values.get("output_code")
|
||||
output_symbol = values.get("output_symbol")
|
||||
|
||||
if output_type is not None and output_symbol is not None:
|
||||
# This is currently only possible when someone edits the preset file by
|
||||
# hand. A key-output mapping without an output_symbol, but type and code
|
||||
# instead, is valid as well.
|
||||
logger.debug("Both output_type and output_symbol are set")
|
||||
|
||||
if output_type != EV_KEY and output_code is not None and not output_symbol:
|
||||
values["mapping_type"] = MappingType.ANALOG.value
|
||||
|
||||
if output_type is None and output_code is None and output_symbol:
|
||||
values["mapping_type"] = MappingType.KEY_MACRO.value
|
||||
|
||||
if output_type == EV_KEY:
|
||||
values["mapping_type"] = MappingType.KEY_MACRO.value
|
||||
|
||||
return values
|
||||
|
||||
Config = Cfg
|
||||
|
||||
|
||||
class Mapping(UIMapping):
|
||||
"""Holds all the data for mapping an input action to an output action.
|
||||
|
||||
This implements the missing validations from UIMapping.
|
||||
"""
|
||||
|
||||
# Override Required attributes to enforce they are set
|
||||
input_combination: InputCombination
|
||||
target_uinput: KnownUinput
|
||||
|
||||
@classmethod
|
||||
def from_combination(
|
||||
cls,
|
||||
input_combination=None,
|
||||
target_uinput="keyboard",
|
||||
output_symbol="a",
|
||||
):
|
||||
"""Convenient function to get a valid mapping."""
|
||||
if not input_combination:
|
||||
input_combination = [{"type": 99, "code": 99, "analog_threshold": 99}]
|
||||
|
||||
return cls(
|
||||
input_combination=input_combination,
|
||||
target_uinput=target_uinput,
|
||||
output_symbol=output_symbol,
|
||||
)
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""If the mapping is valid."""
|
||||
return True
|
||||
|
||||
@root_validator(pre=True)
|
||||
def validate_symbol(cls, values):
|
||||
"""Parse a macro to check for syntax errors."""
|
||||
symbol = values.get("output_symbol")
|
||||
|
||||
if symbol == "":
|
||||
values["output_symbol"] = None
|
||||
return values
|
||||
|
||||
if symbol is None:
|
||||
return values
|
||||
|
||||
symbol = symbol.strip()
|
||||
values["output_symbol"] = symbol
|
||||
|
||||
if symbol == DISABLE_NAME:
|
||||
return values
|
||||
|
||||
if Parser.is_this_a_macro(symbol):
|
||||
mapping_mock = namedtuple("Mapping", values.keys())(**values)
|
||||
# raises MacroError
|
||||
Parser.parse(symbol, mapping=mapping_mock, verbose=False)
|
||||
return values
|
||||
|
||||
code = keyboard_layout.get(symbol)
|
||||
if code is None:
|
||||
raise OutputSymbolUnknownError(symbol)
|
||||
|
||||
target = values.get("target_uinput")
|
||||
if target is not None and not GlobalUInputs.can_default_uinput_emit(
|
||||
target, EV_KEY, code
|
||||
):
|
||||
raise SymbolNotAvailableInTargetError(symbol, target)
|
||||
|
||||
return values
|
||||
|
||||
@validator("input_combination")
|
||||
def only_one_analog_input(cls, combination) -> InputCombination:
|
||||
"""Check that the input_combination specifies a maximum of one
|
||||
analog to analog mapping
|
||||
"""
|
||||
analog_events = [event for event in combination if event.defines_analog_input]
|
||||
if len(analog_events) > 1:
|
||||
raise OnlyOneAnalogInputError(analog_events)
|
||||
|
||||
return combination
|
||||
|
||||
@validator("input_combination")
|
||||
def trigger_point_in_range(cls, combination: InputCombination) -> InputCombination:
|
||||
"""Check if the trigger point for mapping analog axis to buttons is valid."""
|
||||
for input_config in combination:
|
||||
if (
|
||||
input_config.type == EV_ABS
|
||||
and input_config.analog_threshold
|
||||
and abs(input_config.analog_threshold) >= 100
|
||||
):
|
||||
raise TriggerPointInRangeError(input_config)
|
||||
return combination
|
||||
|
||||
@root_validator
|
||||
def validate_output_symbol_variant(cls, values):
|
||||
"""Validate that either type and code or symbol are set for key output."""
|
||||
o_symbol = values.get("output_symbol")
|
||||
o_type = values.get("output_type")
|
||||
o_code = values.get("output_code")
|
||||
if o_symbol is None and (o_type is None or o_code is None):
|
||||
raise OutputSymbolVariantError()
|
||||
return values
|
||||
|
||||
@root_validator
|
||||
def validate_output_integrity(cls, values):
|
||||
"""Validate the output key configuration."""
|
||||
symbol = values.get("output_symbol")
|
||||
type_ = values.get("output_type")
|
||||
code = values.get("output_code")
|
||||
if symbol is None:
|
||||
# If symbol is "", then validate_symbol changes it to None
|
||||
# type and code can be anything
|
||||
return values
|
||||
|
||||
if type_ is None and code is None:
|
||||
# we have a symbol: no type and code is fine
|
||||
return values
|
||||
|
||||
if Parser.is_this_a_macro(symbol):
|
||||
# disallow output type and code for macros
|
||||
if type_ is not None or code is not None:
|
||||
raise MacroButTypeOrCodeSetError()
|
||||
|
||||
if code is not None and code != keyboard_layout.get(symbol) or type_ != EV_KEY:
|
||||
raise SymbolAndCodeMismatchError(symbol, code)
|
||||
return values
|
||||
|
||||
@root_validator
|
||||
def output_matches_input(cls, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Validate that an output type is an axis if we have an input axis.
|
||||
And vice versa."""
|
||||
assert isinstance(values.get("input_combination"), InputCombination)
|
||||
combination: InputCombination = values["input_combination"]
|
||||
|
||||
analog_input_config = combination.find_analog_input_config()
|
||||
defines_analog_input = analog_input_config is not None
|
||||
output_type = values.get("output_type")
|
||||
output_code = values.get("output_code")
|
||||
mapping_type = values.get("mapping_type")
|
||||
output_symbol = values.get("output_symbol")
|
||||
output_key_set = output_symbol or (output_type == EV_KEY and output_code)
|
||||
|
||||
if mapping_type is None:
|
||||
# Empty mapping most likely
|
||||
return values
|
||||
|
||||
if not defines_analog_input and mapping_type != MappingType.KEY_MACRO.value:
|
||||
raise WrongMappingTypeForKeyError()
|
||||
|
||||
if not defines_analog_input and not output_key_set:
|
||||
raise MissingMacroOrKeyError()
|
||||
|
||||
if (
|
||||
defines_analog_input
|
||||
and output_type not in (EV_ABS, EV_REL)
|
||||
and output_symbol != DISABLE_NAME
|
||||
):
|
||||
raise MissingOutputAxisError(analog_input_config, output_type)
|
||||
|
||||
return values
|
||||
|
||||
|
||||
class MappingData(UIMapping):
|
||||
"""Like UIMapping, but can be sent over the message broker."""
|
||||
|
||||
Config = ImmutableCfg
|
||||
message_type = MessageType.mapping # allow this to be sent over the MessageBroker
|
||||
|
||||
def __str__(self):
|
||||
return str(self.dict(exclude_defaults=True))
|
||||
|
||||
def dict(self, *args, **kwargs):
|
||||
"""Will not include the message_type."""
|
||||
dict_ = super().dict(*args, **kwargs)
|
||||
if "message_type" in dict_:
|
||||
del dict_["message_type"]
|
||||
return dict_
|
||||
516
inputremapper/configs/migrations.py
Normal file
516
inputremapper/configs/migrations.py
Normal file
@ -0,0 +1,516 @@
|
||||
# -*- 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/>.
|
||||
|
||||
"""Migration functions.
|
||||
|
||||
Only write changes to disk, if there actually are changes. Otherwise, file-modification
|
||||
dates are destroyed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Tuple, Dict, List, Optional, TypedDict
|
||||
|
||||
from evdev.ecodes import (
|
||||
EV_KEY,
|
||||
EV_ABS,
|
||||
EV_REL,
|
||||
ABS_X,
|
||||
ABS_Y,
|
||||
ABS_RX,
|
||||
ABS_RY,
|
||||
REL_X,
|
||||
REL_Y,
|
||||
REL_WHEEL_HI_RES,
|
||||
REL_HWHEEL_HI_RES,
|
||||
)
|
||||
from packaging import version
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||
from inputremapper.configs.mapping import Mapping, UIMapping
|
||||
from inputremapper.configs.paths import PathUtils
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.macros.parse import Parser
|
||||
from inputremapper.logging.logger import logger, VERSION
|
||||
from inputremapper.user import UserUtils
|
||||
|
||||
|
||||
class Config(TypedDict):
|
||||
input_combination: Optional[InputCombination]
|
||||
target_uinput: str
|
||||
output_type: int
|
||||
output_code: Optional[int]
|
||||
|
||||
|
||||
class Migrations:
|
||||
def __init__(self, global_uinputs: GlobalUInputs):
|
||||
self.global_uinputs = global_uinputs
|
||||
|
||||
def migrate(self):
|
||||
"""Migrate config files to the current release."""
|
||||
|
||||
self._rename_to_input_remapper()
|
||||
|
||||
self._copy_to_v2()
|
||||
|
||||
v = self.config_version()
|
||||
|
||||
if v < version.parse("0.4.0"):
|
||||
self._config_suffix()
|
||||
self._preset_path()
|
||||
|
||||
if v < version.parse("1.2.2"):
|
||||
self._mapping_keys()
|
||||
|
||||
if v < version.parse("1.4.0"):
|
||||
self.global_uinputs.prepare_all()
|
||||
self._add_target()
|
||||
|
||||
if v < version.parse("1.4.1"):
|
||||
self._otherwise_to_else()
|
||||
|
||||
if v < version.parse("1.5.0"):
|
||||
self._remove_logs()
|
||||
|
||||
if v < version.parse("1.6.0-beta"):
|
||||
self._convert_to_individual_mappings()
|
||||
|
||||
# add new migrations here
|
||||
|
||||
if v < version.parse(VERSION):
|
||||
self._update_version()
|
||||
|
||||
def all_presets(
|
||||
self,
|
||||
) -> Iterator[Tuple[os.PathLike, Dict | List]]:
|
||||
"""Get all presets for all groups as list."""
|
||||
if not os.path.exists(PathUtils.get_preset_path()):
|
||||
return
|
||||
|
||||
preset_path = Path(PathUtils.get_preset_path())
|
||||
for folder in preset_path.iterdir():
|
||||
if not folder.is_dir():
|
||||
continue
|
||||
|
||||
for preset in folder.iterdir():
|
||||
if preset.suffix != ".json":
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(preset, "r") as f:
|
||||
preset_structure = json.load(f)
|
||||
yield preset, preset_structure
|
||||
except json.decoder.JSONDecodeError:
|
||||
logger.warning('Invalid json format in preset "%s"', preset)
|
||||
continue
|
||||
|
||||
def config_version(self):
|
||||
"""Get the version string in config.json as packaging.Version object."""
|
||||
config_path = os.path.join(PathUtils.config_path(), "config.json")
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
return version.parse("0.0.0")
|
||||
|
||||
with open(config_path, "r") as file:
|
||||
config = json.load(file)
|
||||
|
||||
if "version" in config.keys():
|
||||
return version.parse(config["version"])
|
||||
|
||||
return version.parse("0.0.0")
|
||||
|
||||
def _config_suffix(self):
|
||||
"""Append the .json suffix to the config file."""
|
||||
deprecated_path = os.path.join(PathUtils.config_path(), "config")
|
||||
config_path = os.path.join(PathUtils.config_path(), "config.json")
|
||||
if os.path.exists(deprecated_path) and not os.path.exists(config_path):
|
||||
logger.info('Moving "%s" to "%s"', deprecated_path, config_path)
|
||||
os.rename(deprecated_path, config_path)
|
||||
|
||||
def _preset_path(self):
|
||||
"""Migrate the folder structure from < 0.4.0.
|
||||
|
||||
Move existing presets into the new subfolder 'presets'
|
||||
"""
|
||||
new_preset_folder = os.path.join(PathUtils.config_path(), "presets")
|
||||
if os.path.exists(PathUtils.get_preset_path()) or not os.path.exists(
|
||||
PathUtils.config_path()
|
||||
):
|
||||
return
|
||||
|
||||
logger.info("Migrating presets from < 0.4.0...")
|
||||
group_config_files = os.listdir(PathUtils.config_path())
|
||||
PathUtils.mkdir(PathUtils.get_preset_path())
|
||||
for group in group_config_files:
|
||||
path = os.path.join(PathUtils.config_path(), group)
|
||||
if os.path.isdir(path):
|
||||
target = path.replace(PathUtils.config_path(), new_preset_folder)
|
||||
logger.info('Moving "%s" to "%s"', path, target)
|
||||
os.rename(path, target)
|
||||
|
||||
logger.info("done")
|
||||
|
||||
def _mapping_keys(self):
|
||||
"""Update all preset mappings.
|
||||
|
||||
Update all keys in preset to include value e.g.: '1,5'->'1,5,1'
|
||||
"""
|
||||
for preset, preset_structure in self.all_presets():
|
||||
if isinstance(preset_structure, list):
|
||||
continue # the preset must be at least 1.6-beta version
|
||||
|
||||
changes = 0
|
||||
if "mapping" in preset_structure.keys():
|
||||
mapping = copy.deepcopy(preset_structure["mapping"])
|
||||
for key in mapping.keys():
|
||||
if key.count(",") == 1:
|
||||
preset_structure["mapping"][f"{key},1"] = preset_structure[
|
||||
"mapping"
|
||||
].pop(key)
|
||||
changes += 1
|
||||
|
||||
if changes:
|
||||
with open(preset, "w") as file:
|
||||
logger.info('Updating mapping keys of "%s"', preset)
|
||||
json.dump(preset_structure, file, indent=4)
|
||||
file.write("\n")
|
||||
|
||||
def _update_version(self):
|
||||
"""Write the current version to the config file."""
|
||||
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)
|
||||
|
||||
config["version"] = VERSION
|
||||
with open(config_file, "w") as file:
|
||||
logger.info('Updating version in config to "%s"', VERSION)
|
||||
json.dump(config, file, indent=4)
|
||||
|
||||
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")
|
||||
if not os.path.exists(PathUtils.config_path()) and os.path.exists(
|
||||
old_config_path
|
||||
):
|
||||
logger.info("Moving %s to %s", old_config_path, PathUtils.config_path())
|
||||
shutil.move(old_config_path, PathUtils.config_path())
|
||||
|
||||
def _find_target(self, symbol):
|
||||
"""Try to find a uinput with the required capabilities for the symbol."""
|
||||
capabilities = {EV_KEY: set(), EV_REL: set()}
|
||||
|
||||
if Parser.is_this_a_macro(symbol):
|
||||
# deprecated mechanic, cannot figure this out anymore
|
||||
# capabilities = parse(symbol).get_capabilities()
|
||||
return None
|
||||
|
||||
capabilities[EV_KEY] = {keyboard_layout.get(symbol)}
|
||||
|
||||
if len(capabilities[EV_REL]) > 0:
|
||||
return "mouse"
|
||||
|
||||
for name, uinput in self.global_uinputs.devices.items():
|
||||
if capabilities[EV_KEY].issubset(uinput.capabilities()[EV_KEY]):
|
||||
return name
|
||||
|
||||
logger.info('could not find a suitable target UInput for "%s"', symbol)
|
||||
return None
|
||||
|
||||
def _add_target(self):
|
||||
"""Add the target field to each preset mapping."""
|
||||
for preset, preset_structure in self.all_presets():
|
||||
if isinstance(preset_structure, list):
|
||||
continue
|
||||
|
||||
if "mapping" not in preset_structure.keys():
|
||||
continue
|
||||
|
||||
changed = False
|
||||
for key, symbol in preset_structure["mapping"].copy().items():
|
||||
if isinstance(symbol, list):
|
||||
continue
|
||||
|
||||
target = self._find_target(symbol)
|
||||
if target is None:
|
||||
target = "keyboard"
|
||||
symbol = (
|
||||
f"{symbol}\n"
|
||||
"# Broken mapping:\n"
|
||||
"# No target can handle all specified keycodes"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Changing target of mapping for "%s" in preset "%s" to "%s"',
|
||||
key,
|
||||
preset,
|
||||
target,
|
||||
)
|
||||
symbol = [symbol, target]
|
||||
preset_structure["mapping"][key] = symbol
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
continue
|
||||
|
||||
with open(preset, "w") as file:
|
||||
logger.info('Adding targets for "%s"', preset)
|
||||
json.dump(preset_structure, file, indent=4)
|
||||
file.write("\n")
|
||||
|
||||
def _otherwise_to_else(self):
|
||||
"""Conditional macros should use an "else" parameter instead of "otherwise"."""
|
||||
for preset, preset_structure in self.all_presets():
|
||||
if isinstance(preset_structure, list):
|
||||
continue
|
||||
|
||||
if "mapping" not in preset_structure.keys():
|
||||
continue
|
||||
|
||||
changed = False
|
||||
for key, symbol in preset_structure["mapping"].copy().items():
|
||||
if not Parser.is_this_a_macro(symbol[0]):
|
||||
continue
|
||||
|
||||
symbol_before = symbol[0]
|
||||
symbol[0] = re.sub(r"otherwise\s*=\s*", "else=", symbol[0])
|
||||
|
||||
if symbol_before == symbol[0]:
|
||||
continue
|
||||
|
||||
changed = changed or symbol_before != symbol[0]
|
||||
|
||||
logger.info(
|
||||
'Changing mapping for "%s" in preset "%s" to "%s"',
|
||||
key,
|
||||
preset,
|
||||
symbol[0],
|
||||
)
|
||||
|
||||
preset_structure["mapping"][key] = symbol
|
||||
|
||||
if not changed:
|
||||
continue
|
||||
|
||||
with open(preset, "w") as file:
|
||||
logger.info('Changing otherwise to else for "%s"', preset)
|
||||
json.dump(preset_structure, file, indent=4)
|
||||
file.write("\n")
|
||||
|
||||
def _input_combination_from_string(
|
||||
self, combination_string: str
|
||||
) -> InputCombination:
|
||||
configs = []
|
||||
for event_str in combination_string.split("+"):
|
||||
type_, code, analog_threshold = event_str.split(",")
|
||||
configs.append(
|
||||
{
|
||||
"type": int(type_),
|
||||
"code": int(code),
|
||||
"analog_threshold": int(analog_threshold),
|
||||
}
|
||||
)
|
||||
|
||||
return InputCombination(configs)
|
||||
|
||||
def _convert_to_individual_mappings(
|
||||
self,
|
||||
) -> None:
|
||||
"""Convert preset.json
|
||||
from {key: [symbol, target]}
|
||||
to [{input_combination: ..., output_symbol: symbol, ...}]
|
||||
"""
|
||||
|
||||
for old_preset_path, old_preset in self.all_presets():
|
||||
if isinstance(old_preset, list):
|
||||
continue
|
||||
|
||||
migrated_preset = Preset(old_preset_path, UIMapping)
|
||||
if "mapping" in old_preset.keys():
|
||||
for combination, symbol_target in old_preset["mapping"].items():
|
||||
logger.info(
|
||||
'migrating from "%s: %s" to mapping dict',
|
||||
combination,
|
||||
symbol_target,
|
||||
)
|
||||
try:
|
||||
combination = self._input_combination_from_string(combination)
|
||||
except ValueError:
|
||||
logger.error(
|
||||
"unable to migrate mapping with invalid combination %s",
|
||||
combination,
|
||||
)
|
||||
continue
|
||||
|
||||
mapping = UIMapping(
|
||||
input_combination=combination,
|
||||
target_uinput=symbol_target[1],
|
||||
output_symbol=symbol_target[0],
|
||||
)
|
||||
migrated_preset.add(mapping)
|
||||
|
||||
if (
|
||||
"gamepad" in old_preset.keys()
|
||||
and "joystick" in old_preset["gamepad"].keys()
|
||||
):
|
||||
joystick_dict = old_preset["gamepad"]["joystick"]
|
||||
left_purpose = joystick_dict.get("left_purpose")
|
||||
right_purpose = joystick_dict.get("right_purpose")
|
||||
# TODO if pointer_speed is migrated, why is it in my config?
|
||||
pointer_speed = joystick_dict.get("pointer_speed")
|
||||
if pointer_speed:
|
||||
pointer_speed /= 100
|
||||
# non_linearity = joystick_dict.get("non_linearity") # Todo
|
||||
x_scroll_speed = joystick_dict.get("x_scroll_speed")
|
||||
y_scroll_speed = joystick_dict.get("y_scroll_speed")
|
||||
|
||||
cfg: Config = {
|
||||
"input_combination": None,
|
||||
"target_uinput": "mouse",
|
||||
"output_type": EV_REL,
|
||||
"output_code": None,
|
||||
}
|
||||
|
||||
if left_purpose == "mouse":
|
||||
x_config = cfg.copy()
|
||||
y_config = cfg.copy()
|
||||
x_config["input_combination"] = InputCombination(
|
||||
[InputConfig(type=EV_ABS, code=ABS_X)]
|
||||
)
|
||||
y_config["input_combination"] = InputCombination(
|
||||
[InputConfig(type=EV_ABS, code=ABS_Y)]
|
||||
)
|
||||
x_config["output_code"] = REL_X
|
||||
y_config["output_code"] = REL_Y
|
||||
mapping_x = Mapping(**x_config)
|
||||
mapping_y = Mapping(**y_config)
|
||||
if pointer_speed:
|
||||
mapping_x.gain = pointer_speed
|
||||
mapping_y.gain = pointer_speed
|
||||
migrated_preset.add(mapping_x)
|
||||
migrated_preset.add(mapping_y)
|
||||
|
||||
if right_purpose == "mouse":
|
||||
x_config = cfg.copy()
|
||||
y_config = cfg.copy()
|
||||
x_config["input_combination"] = InputCombination(
|
||||
[InputConfig(type=EV_ABS, code=ABS_RX)]
|
||||
)
|
||||
y_config["input_combination"] = InputCombination(
|
||||
[InputConfig(type=EV_ABS, code=ABS_RY)]
|
||||
)
|
||||
x_config["output_code"] = REL_X
|
||||
y_config["output_code"] = REL_Y
|
||||
mapping_x = Mapping(**x_config)
|
||||
mapping_y = Mapping(**y_config)
|
||||
if pointer_speed:
|
||||
mapping_x.gain = pointer_speed
|
||||
mapping_y.gain = pointer_speed
|
||||
migrated_preset.add(mapping_x)
|
||||
migrated_preset.add(mapping_y)
|
||||
|
||||
if left_purpose == "wheel":
|
||||
x_config = cfg.copy()
|
||||
y_config = cfg.copy()
|
||||
x_config["input_combination"] = InputCombination(
|
||||
[InputConfig(type=EV_ABS, code=ABS_X)]
|
||||
)
|
||||
y_config["input_combination"] = InputCombination(
|
||||
[InputConfig(type=EV_ABS, code=ABS_Y)]
|
||||
)
|
||||
x_config["output_code"] = REL_HWHEEL_HI_RES
|
||||
y_config["output_code"] = REL_WHEEL_HI_RES
|
||||
mapping_x = Mapping(**x_config)
|
||||
mapping_y = Mapping(**y_config)
|
||||
if x_scroll_speed:
|
||||
mapping_x.gain = x_scroll_speed
|
||||
if y_scroll_speed:
|
||||
mapping_y.gain = y_scroll_speed
|
||||
migrated_preset.add(mapping_x)
|
||||
migrated_preset.add(mapping_y)
|
||||
|
||||
if right_purpose == "wheel":
|
||||
x_config = cfg.copy()
|
||||
y_config = cfg.copy()
|
||||
x_config["input_combination"] = InputCombination(
|
||||
[InputConfig(type=EV_ABS, code=ABS_RX)]
|
||||
)
|
||||
y_config["input_combination"] = InputCombination(
|
||||
[InputConfig(type=EV_ABS, code=ABS_RY)]
|
||||
)
|
||||
x_config["output_code"] = REL_HWHEEL_HI_RES
|
||||
y_config["output_code"] = REL_WHEEL_HI_RES
|
||||
mapping_x = Mapping(**x_config)
|
||||
mapping_y = Mapping(**y_config)
|
||||
if x_scroll_speed:
|
||||
mapping_x.gain = x_scroll_speed
|
||||
if y_scroll_speed:
|
||||
mapping_y.gain = y_scroll_speed
|
||||
migrated_preset.add(mapping_x)
|
||||
migrated_preset.add(mapping_y)
|
||||
|
||||
migrated_preset.save()
|
||||
|
||||
def _copy_to_v2(self):
|
||||
"""Move the beta config to the v2 path, or copy the v1 config to the v2 path."""
|
||||
# TODO test
|
||||
if os.path.exists(PathUtils.config_path()):
|
||||
# don't copy to already existing folder
|
||||
# users should delete the input-remapper-2 folder if they need to
|
||||
return
|
||||
|
||||
# prioritize the v1 configs over beta configs
|
||||
old_path = os.path.join(UserUtils.home, ".config/input-remapper")
|
||||
if os.path.exists(os.path.join(old_path, "config.json")):
|
||||
# no beta path, only old presets exist. COPY to v2 path, which will then be
|
||||
# migrated by the various self.
|
||||
logger.debug("copying all from %s to %s", old_path, PathUtils.config_path())
|
||||
shutil.copytree(old_path, PathUtils.config_path())
|
||||
return
|
||||
|
||||
# if v1 configs don't exist, try to find beta configs.
|
||||
beta_path = os.path.join(
|
||||
UserUtils.home, ".config/input-remapper/beta_1.6.0-beta"
|
||||
)
|
||||
if os.path.exists(beta_path):
|
||||
# There has never been a different version than "1.6.0-beta" in beta, so we
|
||||
# only need to check for that exact directory
|
||||
# already migrated, possibly new presets in them, move to v2 path
|
||||
logger.debug("moving %s to %s", beta_path, PathUtils.config_path())
|
||||
shutil.move(beta_path, PathUtils.config_path())
|
||||
|
||||
def _remove_logs(self):
|
||||
"""We will try to rely on journalctl for this in the future."""
|
||||
try:
|
||||
PathUtils.remove(f"{UserUtils.home}/.log/input-remapper")
|
||||
PathUtils.remove("/var/log/input-remapper")
|
||||
PathUtils.remove("/var/log/input-remapper-control")
|
||||
except Exception as error:
|
||||
logger.debug("Failed to remove deprecated logfiles: %s", str(error))
|
||||
# this migration is not important. Continue
|
||||
pass
|
||||
157
inputremapper/configs/paths.py
Normal file
157
inputremapper/configs/paths.py
Normal file
@ -0,0 +1,157 @@
|
||||
# -*- 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/>.
|
||||
|
||||
# TODO: convert everything to use pathlib.Path
|
||||
|
||||
"""Path constants to be used."""
|
||||
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import List, Union, Optional
|
||||
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.user import UserUtils
|
||||
|
||||
|
||||
# TODO maybe this could become, idk, ConfigService and PresetService
|
||||
class PathUtils:
|
||||
@staticmethod
|
||||
def config_path() -> str:
|
||||
# TODO when proper DI is being done, construct PathUtils and configure it
|
||||
# in the constructor. Then there is no need to recompute the config_path
|
||||
# each time. Tests might have overwritten UserUtils.home.
|
||||
xdg_config_home = os.getenv(
|
||||
"XDG_CONFIG_HOME", os.path.join(UserUtils.home, ".config")
|
||||
)
|
||||
return os.path.join(xdg_config_home, "input-remapper-2")
|
||||
|
||||
@staticmethod
|
||||
def chown(path):
|
||||
"""Set the owner of a path to the user."""
|
||||
try:
|
||||
logger.debug('Chown "%s", "%s"', path, UserUtils.user)
|
||||
shutil.chown(path, user=UserUtils.user, group=UserUtils.user)
|
||||
except LookupError:
|
||||
# the users group was unknown in one case for whatever reason
|
||||
shutil.chown(path, user=UserUtils.user)
|
||||
|
||||
@staticmethod
|
||||
def touch(path: Union[str, os.PathLike], log=True):
|
||||
"""Create an empty file and all its parent dirs, give it to the user."""
|
||||
if str(path).endswith("/"):
|
||||
raise ValueError(f"Expected path to not end with a slash: {path}")
|
||||
|
||||
if os.path.exists(path):
|
||||
return
|
||||
|
||||
if log:
|
||||
logger.info('Creating file "%s"', path)
|
||||
|
||||
PathUtils.mkdir(os.path.dirname(path), log=False)
|
||||
|
||||
os.mknod(path)
|
||||
PathUtils.chown(path)
|
||||
|
||||
@staticmethod
|
||||
def mkdir(path, log=True):
|
||||
"""Create a folder, give it to the user."""
|
||||
if path == "" or path is None:
|
||||
return
|
||||
|
||||
if os.path.exists(path):
|
||||
return
|
||||
|
||||
if log:
|
||||
logger.info('Creating dir "%s"', path)
|
||||
|
||||
# give all newly created folders to the user.
|
||||
# e.g. if .config/input-remapper/mouse/ is created the latter two
|
||||
base = os.path.split(path)[0]
|
||||
PathUtils.mkdir(base, log=False)
|
||||
|
||||
os.makedirs(path)
|
||||
PathUtils.chown(path)
|
||||
|
||||
@staticmethod
|
||||
def split_all(path: Union[os.PathLike, str]) -> List[str]:
|
||||
"""Split the path into its segments."""
|
||||
parts = []
|
||||
while True:
|
||||
path, tail = os.path.split(path)
|
||||
parts.append(tail)
|
||||
if path == os.path.sep:
|
||||
# we arrived at the root '/'
|
||||
parts.append(path)
|
||||
break
|
||||
if not path:
|
||||
# arrived at start of relative path
|
||||
break
|
||||
|
||||
parts.reverse()
|
||||
return parts
|
||||
|
||||
@staticmethod
|
||||
def remove(path):
|
||||
"""Remove whatever is at the path."""
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
|
||||
if os.path.isdir(path):
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
os.remove(path)
|
||||
|
||||
@staticmethod
|
||||
def sanitize_path_component(group_name: str) -> str:
|
||||
"""Replace characters listed in
|
||||
https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words
|
||||
with an underscore.
|
||||
"""
|
||||
for character in '/\\?%*:|"<>':
|
||||
if character in group_name:
|
||||
group_name = group_name.replace(character, "_")
|
||||
return group_name
|
||||
|
||||
@staticmethod
|
||||
def get_preset_path(group_name: Optional[str] = None, preset: Optional[str] = None):
|
||||
"""Get a path to the stored preset, or to store a preset to."""
|
||||
presets_base = os.path.join(PathUtils.config_path(), "presets")
|
||||
|
||||
if group_name is None:
|
||||
return presets_base
|
||||
|
||||
group_name = PathUtils.sanitize_path_component(group_name)
|
||||
|
||||
if preset is not None:
|
||||
# the extension of the preset should not be shown in the ui.
|
||||
# if a .json extension arrives this place, it has not been
|
||||
# stripped away properly prior to this.
|
||||
if not preset.endswith(".json"):
|
||||
preset = f"{preset}.json"
|
||||
|
||||
if preset is None:
|
||||
return os.path.join(presets_base, group_name)
|
||||
|
||||
return os.path.join(presets_base, group_name, preset)
|
||||
|
||||
@staticmethod
|
||||
def get_config_path(*paths) -> str:
|
||||
"""Get a path in ~/.config/input-remapper/."""
|
||||
return os.path.join(PathUtils.config_path(), *paths)
|
||||
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
|
||||
137
inputremapper/configs/validation_errors.py
Normal file
137
inputremapper/configs/validation_errors.py
Normal file
@ -0,0 +1,137 @@
|
||||
# -*- 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/>.
|
||||
|
||||
"""Exceptions that are thrown when configurations are incorrect."""
|
||||
|
||||
# can't merge this with exceptions.py, because I want error constructors here to
|
||||
# be intelligent to avoid redundant code, and they need imports, which would cause
|
||||
# circular imports.
|
||||
|
||||
# pydantic only catches ValueError, TypeError, and AssertionError
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from evdev.ecodes import EV_KEY
|
||||
|
||||
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
|
||||
|
||||
class OutputSymbolVariantError(ValueError):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
"Missing Argument: Mapping must either contain "
|
||||
"`output_symbol` or `output_type` and `output_code`"
|
||||
)
|
||||
|
||||
|
||||
class TriggerPointInRangeError(ValueError):
|
||||
def __init__(self, input_config):
|
||||
super().__init__(
|
||||
f"{input_config = } maps an absolute axis to a button, but the "
|
||||
"trigger point (event.analog_threshold) is not between -100[%] "
|
||||
"and 100[%]"
|
||||
)
|
||||
|
||||
|
||||
class OnlyOneAnalogInputError(ValueError):
|
||||
def __init__(self, analog_events):
|
||||
super().__init__(
|
||||
f"Cannot map a combination of multiple analog inputs: {analog_events}"
|
||||
"add trigger points (event.value != 0) to map as a button"
|
||||
)
|
||||
|
||||
|
||||
class SymbolNotAvailableInTargetError(ValueError):
|
||||
def __init__(self, symbol, target):
|
||||
code = keyboard_layout.get(symbol)
|
||||
|
||||
fitting_targets = GlobalUInputs.find_fitting_default_uinputs(EV_KEY, code)
|
||||
fitting_targets_string = '", "'.join(fitting_targets)
|
||||
|
||||
super().__init__(
|
||||
f'The output_symbol "{symbol}" is not available for the "{target}" '
|
||||
+ f'target. Try "{fitting_targets_string}".'
|
||||
)
|
||||
|
||||
|
||||
class OutputSymbolUnknownError(ValueError):
|
||||
def __init__(self, symbol: str):
|
||||
super().__init__(
|
||||
f'The output_symbol "{symbol}" is not a macro and not a valid '
|
||||
+ "keycode-name"
|
||||
)
|
||||
|
||||
|
||||
class MacroButTypeOrCodeSetError(ValueError):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
"output_symbol is a macro: output_type " "and output_code must be None"
|
||||
)
|
||||
|
||||
|
||||
class SymbolAndCodeMismatchError(ValueError):
|
||||
def __init__(self, symbol, code):
|
||||
super().__init__(
|
||||
"output_symbol and output_code mismatch: "
|
||||
f"output macro is {symbol} -> {keyboard_layout.get(symbol)} "
|
||||
f"but output_code is {code} -> {keyboard_layout.get_name(code)} "
|
||||
)
|
||||
|
||||
|
||||
class WrongMappingTypeForKeyError(ValueError):
|
||||
def __init__(self):
|
||||
super().__init__("Wrong mapping_type for key input")
|
||||
|
||||
|
||||
class MissingMacroOrKeyError(ValueError):
|
||||
def __init__(self):
|
||||
super().__init__("Missing macro or key")
|
||||
|
||||
|
||||
class MissingOutputAxisError(ValueError):
|
||||
def __init__(self, analog_input_config, output_type):
|
||||
super().__init__(
|
||||
"Missing output axis: "
|
||||
f'"{analog_input_config}" is used as analog input, '
|
||||
f"but the {output_type = } is not an axis "
|
||||
)
|
||||
|
||||
|
||||
class MacroError(ValueError):
|
||||
"""Macro syntax errors."""
|
||||
|
||||
def __init__(self, symbol: Optional[str] = None, msg="Error while parsing a macro"):
|
||||
self.symbol = symbol
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
def pydantify(error: type):
|
||||
"""Generate a string as it would appear IN pydantic error types.
|
||||
|
||||
This does not include the base class name, which is transformed to snake case in
|
||||
pydantic. Example pydantic error type: "value_error.foobar" for FooBarError.
|
||||
"""
|
||||
# See https://github.com/pydantic/pydantic/discussions/5112
|
||||
lower_classname = error.__name__.lower()
|
||||
if lower_classname.endswith("error"):
|
||||
return lower_classname[: -len("error")]
|
||||
return lower_classname
|
||||
Reference in New Issue
Block a user