Refactorings (#275)
parent
ae56f2c0e3
commit
b3e1e4ca19
@ -0,0 +1,129 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""Store which presets should be enabled for which device on login."""
|
||||
|
||||
import os
|
||||
import json
|
||||
import copy
|
||||
|
||||
from inputremapper.configs.paths import CONFIG_PATH, USER, touch
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.configs.base_config import ConfigBase, INITIAL_CONFIG
|
||||
|
||||
MOUSE = "mouse"
|
||||
WHEEL = "wheel"
|
||||
BUTTONS = "buttons"
|
||||
NONE = "none"
|
||||
|
||||
|
||||
class GlobalConfig(ConfigBase):
|
||||
"""Global default configuration.
|
||||
It can also contain some extra stuff not relevant for presets, like the
|
||||
autoload stuff. If presets have a config key set, it will ignore
|
||||
the default global configuration for that one. If none of the configs
|
||||
have the key set, a hardcoded default value will be used.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.path = os.path.join(CONFIG_PATH, "config.json")
|
||||
super().__init__()
|
||||
|
||||
def set_autoload_preset(self, group_key, preset):
|
||||
"""Set a preset to be automatically applied on start.
|
||||
Parameters
|
||||
----------
|
||||
group_key : string
|
||||
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 : string or None
|
||||
if None, don't autoload something for this device.
|
||||
"""
|
||||
if preset is not None:
|
||||
self.set(["autoload", group_key], preset)
|
||||
else:
|
||||
logger.info('Not injecting for "%s" automatically anmore', group_key)
|
||||
self.remove(["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, preset):
|
||||
"""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.get(["autoload", group_key], log_unknown=False) == preset
|
||||
|
||||
def load_config(self, path=None):
|
||||
"""Load the config from the file system.
|
||||
Parameters
|
||||
----------
|
||||
path : string or None
|
||||
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 USER == "root":
|
||||
logger.debug("Skipping config file creation for the root user")
|
||||
return
|
||||
|
||||
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")
|
||||
|
||||
|
||||
global_config = GlobalConfig()
|
@ -0,0 +1,414 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
"""Contains and manages mappings."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import glob
|
||||
import time
|
||||
|
||||
from typing import Tuple, Dict, List
|
||||
from evdev.ecodes import EV_KEY, BTN_LEFT
|
||||
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.configs.paths import touch, get_preset_path, mkdir
|
||||
from inputremapper.configs.global_config import global_config
|
||||
from inputremapper.configs.base_config import ConfigBase
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.injection.macros.parse import clean
|
||||
from inputremapper.groups import groups
|
||||
|
||||
|
||||
class Preset(ConfigBase):
|
||||
"""Contains and manages mappings of a single preset."""
|
||||
|
||||
_mapping: Dict[EventCombination, Tuple[str, str]]
|
||||
|
||||
def __init__(self):
|
||||
# a mapping of a EventCombination object to (symbol, target) tuple
|
||||
self._mapping: Dict[EventCombination, Tuple[str, str]] = {}
|
||||
self._changed = False
|
||||
|
||||
# are there actually any keys set in the preset file?
|
||||
self.num_saved_keys = 0
|
||||
|
||||
super().__init__(fallback=global_config)
|
||||
|
||||
def __iter__(self) -> Preset._mapping.items:
|
||||
"""Iterate over EventCombination objects and their mappings."""
|
||||
return iter(self._mapping.items())
|
||||
|
||||
def __len__(self):
|
||||
return len(self._mapping)
|
||||
|
||||
def set(self, *args):
|
||||
"""Set a config value. See `ConfigBase.set`."""
|
||||
self._changed = True
|
||||
return super().set(*args)
|
||||
|
||||
def remove(self, *args):
|
||||
"""Remove a config value. See `ConfigBase.remove`."""
|
||||
self._changed = True
|
||||
return super().remove(*args)
|
||||
|
||||
def change(self, new_combination, target, symbol, previous_combination=None):
|
||||
"""Replace the mapping of a keycode with a different one.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
new_combination : EventCombination
|
||||
target : string
|
||||
name of target uinput
|
||||
symbol : string
|
||||
A single symbol known to xkb or linux.
|
||||
Examples: KEY_KP1, Shift_L, a, B, BTN_LEFT.
|
||||
previous_combination : EventCombination or None
|
||||
the previous combination
|
||||
|
||||
If not set, will not remove any previous mapping. If you recently
|
||||
used (1, 10, 1) for new_key and want to overwrite that with
|
||||
(1, 11, 1), provide (1, 10, 1) here.
|
||||
"""
|
||||
if not isinstance(new_combination, EventCombination):
|
||||
raise TypeError(
|
||||
f"Expected {new_combination} to be a EventCombination object"
|
||||
)
|
||||
|
||||
if symbol is None or symbol.strip() == "":
|
||||
raise ValueError("Expected `symbol` not to be empty")
|
||||
|
||||
if target is None or target.strip() == "":
|
||||
raise ValueError("Expected `target` not to be None")
|
||||
|
||||
target = target.strip()
|
||||
symbol = symbol.strip()
|
||||
output = (symbol, target)
|
||||
|
||||
if previous_combination is None and self._mapping.get(new_combination):
|
||||
# the combination didn't change
|
||||
previous_combination = new_combination
|
||||
|
||||
key_changed = new_combination != previous_combination
|
||||
if not key_changed and (symbol, target) == self._mapping.get(new_combination):
|
||||
# nothing was changed, no need to act
|
||||
return
|
||||
|
||||
self.clear(new_combination) # this also clears all equivalent keys
|
||||
|
||||
logger.debug('changing %s to "%s"', new_combination, clean(symbol))
|
||||
|
||||
self._mapping[new_combination] = output
|
||||
|
||||
if key_changed and previous_combination is not None:
|
||||
# clear previous mapping of that code, because the line
|
||||
# representing that one will now represent a different one
|
||||
self.clear(previous_combination)
|
||||
|
||||
self._changed = True
|
||||
|
||||
def has_unsaved_changes(self):
|
||||
"""Check if there are unsaved changed."""
|
||||
return self._changed
|
||||
|
||||
def set_has_unsaved_changes(self, changed):
|
||||
"""Write down if there are unsaved changes, or if they have been saved."""
|
||||
self._changed = changed
|
||||
|
||||
def clear(self, combination):
|
||||
"""Remove a keycode from the preset.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
combination : EventCombination
|
||||
"""
|
||||
if not isinstance(combination, EventCombination):
|
||||
raise TypeError(
|
||||
f"Expected combination to be a EventCombination object but got {combination}"
|
||||
)
|
||||
|
||||
for permutation in combination.get_permutations():
|
||||
if permutation in self._mapping:
|
||||
logger.debug("%s cleared", permutation)
|
||||
del self._mapping[permutation]
|
||||
self._changed = True
|
||||
# there should be only one variation of the permutations
|
||||
# in the preset actually
|
||||
|
||||
def empty(self):
|
||||
"""Remove all mappings and custom configs without saving."""
|
||||
self._mapping = {}
|
||||
self._changed = True
|
||||
self.clear_config()
|
||||
|
||||
def load(self, path):
|
||||
"""Load a dumped JSON from home to overwrite the mappings.
|
||||
|
||||
Parameters
|
||||
path : string
|
||||
Path of the preset file
|
||||
"""
|
||||
logger.info('Loading preset from "%s"', path)
|
||||
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f'Tried to load non-existing preset "{path}"')
|
||||
|
||||
self.empty()
|
||||
self._changed = False
|
||||
|
||||
with open(path, "r") as file:
|
||||
preset_dict = json.load(file)
|
||||
|
||||
if not isinstance(preset_dict.get("mapping"), dict):
|
||||
logger.error(
|
||||
"Expected mapping to be a dict, but was %s. "
|
||||
'Invalid preset config at "%s"',
|
||||
preset_dict.get("mapping"),
|
||||
path,
|
||||
)
|
||||
return
|
||||
|
||||
for combination, symbol in preset_dict["mapping"].items():
|
||||
try:
|
||||
combination = EventCombination.from_string(combination)
|
||||
except ValueError as error:
|
||||
logger.error(str(error))
|
||||
continue
|
||||
|
||||
if isinstance(symbol, list):
|
||||
symbol = tuple(symbol) # use a immutable type
|
||||
|
||||
logger.debug("%s maps to %s", combination, symbol)
|
||||
self._mapping[combination] = symbol
|
||||
|
||||
# add any metadata of the preset
|
||||
for key in preset_dict:
|
||||
if key == "mapping":
|
||||
continue
|
||||
self._config[key] = preset_dict[key]
|
||||
|
||||
self._changed = False
|
||||
self.num_saved_keys = len(self)
|
||||
|
||||
def save(self, path):
|
||||
"""Dump as JSON into home."""
|
||||
logger.info("Saving preset to %s", path)
|
||||
|
||||
touch(path)
|
||||
|
||||
with open(path, "w") as file:
|
||||
if self._config.get("mapping") is not None:
|
||||
logger.error(
|
||||
'"mapping" is reserved and cannot be used as config ' "key: %s",
|
||||
self._config.get("mapping"),
|
||||
)
|
||||
|
||||
preset_dict = self._config.copy() # shallow copy
|
||||
|
||||
# make sure to keep the option to add metadata if ever needed,
|
||||
# so put the mapping into a special key
|
||||
json_ready_mapping = {}
|
||||
# tuple keys are not possible in json, encode them as string
|
||||
for combination, value in self._mapping.items():
|
||||
new_key = combination.json_str()
|
||||
json_ready_mapping[new_key] = value
|
||||
|
||||
preset_dict["mapping"] = json_ready_mapping
|
||||
json.dump(preset_dict, file, indent=4)
|
||||
file.write("\n")
|
||||
|
||||
self._changed = False
|
||||
self.num_saved_keys = len(self)
|
||||
|
||||
def get_mapping(self, combination: EventCombination):
|
||||
"""Read the (symbol, target)-tuple that is mapped to this keycode.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
combination : EventCombination
|
||||
"""
|
||||
if not isinstance(combination, EventCombination):
|
||||
raise TypeError(
|
||||
f"Expected combination to be a EventCombination object but got {combination}"
|
||||
)
|
||||
|
||||
for permutation in combination.get_permutations():
|
||||
existing = self._mapping.get(permutation)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
return None
|
||||
|
||||
def dangerously_mapped_btn_left(self):
|
||||
"""Return True if this mapping disables BTN_Left."""
|
||||
if self.get_mapping(EventCombination([EV_KEY, BTN_LEFT, 1])) is not None:
|
||||
values = [value[0].lower() for value in self._mapping.values()]
|
||||
return "btn_left" not in values
|
||||
|
||||
return False
|
||||
|
||||
|
||||
###########################################################################
|
||||
# Method from previously presets.py
|
||||
# TODO: See what can be implemented as classmethod or
|
||||
# member function of Preset
|
||||
###########################################################################
|
||||
|
||||
|
||||
def get_available_preset_name(group_name, preset="new preset", copy=False):
|
||||
"""Increment the preset name until it is available."""
|
||||
if group_name is None:
|
||||
# endless loop otherwise
|
||||
raise ValueError("group_name may not be None")
|
||||
|
||||
preset = preset.strip()
|
||||
|
||||
if copy and not re.match(r"^.+\scopy( \d+)?$", preset):
|
||||
preset = f"{preset} copy"
|
||||
|
||||
# find a name that is not already taken
|
||||
if os.path.exists(get_preset_path(group_name, preset)):
|
||||
# if there already is a trailing number, increment it instead of
|
||||
# adding another one
|
||||
match = re.match(r"^(.+) (\d+)$", preset)
|
||||
if match:
|
||||
preset = match[1]
|
||||
i = int(match[2]) + 1
|
||||
else:
|
||||
i = 2
|
||||
|
||||
while os.path.exists(get_preset_path(group_name, f"{preset} {i}")):
|
||||
i += 1
|
||||
|
||||
return f"{preset} {i}"
|
||||
|
||||
return preset
|
||||
|
||||
|
||||
def get_presets(group_name: str) -> List[str]:
|
||||
"""Get all preset filenames for the device and user, starting with the newest.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
group_name : string
|
||||
"""
|
||||
device_folder = get_preset_path(group_name)
|
||||
mkdir(device_folder)
|
||||
|
||||
paths = glob.glob(os.path.join(device_folder, "*.json"))
|
||||
presets = [
|
||||
os.path.splitext(os.path.basename(path))[0]
|
||||
for path in sorted(paths, key=os.path.getmtime)
|
||||
]
|
||||
# the highest timestamp to the front
|
||||
presets.reverse()
|
||||
return presets
|
||||
|
||||
|
||||
def get_any_preset() -> Tuple[str | None, str | None]:
|
||||
"""Return the first found tuple of (device, preset)."""
|
||||
group_names = groups.list_group_names()
|
||||
if len(group_names) == 0:
|
||||
return None, None
|
||||
any_device = list(group_names)[0]
|
||||
any_preset = (get_presets(any_device) or [None])[0]
|
||||
return any_device, any_preset
|
||||
|
||||
|
||||
def find_newest_preset(group_name=None):
|
||||
"""Get a tuple of (device, preset) that was most recently modified
|
||||
in the users home directory.
|
||||
|
||||
If no device has been configured yet, return an arbitrary device.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
group_name : string
|
||||
If set, will return the newest preset for the device or None
|
||||
"""
|
||||
# sort the oldest files to the front in order to use pop to get the newest
|
||||
if group_name is None:
|
||||
paths = sorted(
|
||||
glob.glob(os.path.join(get_preset_path(), "*/*.json")), key=os.path.getmtime
|
||||
)
|
||||
else:
|
||||
paths = sorted(
|
||||
glob.glob(os.path.join(get_preset_path(group_name), "*.json")),
|
||||
key=os.path.getmtime,
|
||||
)
|
||||
|
||||
if len(paths) == 0:
|
||||
logger.debug("No presets found")
|
||||
return get_any_preset()
|
||||
|
||||
group_names = groups.list_group_names()
|
||||
|
||||
newest_path = None
|
||||
while len(paths) > 0:
|
||||
# take the newest path
|
||||
path = paths.pop()
|
||||
preset = os.path.split(path)[1]
|
||||
group_name = os.path.split(os.path.split(path)[0])[1]
|
||||
if group_name in group_names:
|
||||
newest_path = path
|
||||
break
|
||||
|
||||
if newest_path is None:
|
||||
return get_any_preset()
|
||||
|
||||
preset = os.path.splitext(preset)[0]
|
||||
logger.debug('The newest preset is "%s", "%s"', group_name, preset)
|
||||
|
||||
return group_name, preset
|
||||
|
||||
|
||||
def delete_preset(group_name, preset):
|
||||
"""Delete one of the users presets."""
|
||||
preset_path = get_preset_path(group_name, preset)
|
||||
if not os.path.exists(preset_path):
|
||||
logger.debug('Cannot remove non existing path "%s"', preset_path)
|
||||
return
|
||||
|
||||
logger.info('Removing "%s"', preset_path)
|
||||
os.remove(preset_path)
|
||||
|
||||
device_path = get_preset_path(group_name)
|
||||
if os.path.exists(device_path) and len(os.listdir(device_path)) == 0:
|
||||
logger.debug('Removing empty dir "%s"', device_path)
|
||||
os.rmdir(device_path)
|
||||
|
||||
|
||||
def rename_preset(group_name, old_preset_name, new_preset_name):
|
||||
"""Rename one of the users presets while avoiding name conflicts."""
|
||||
if new_preset_name == old_preset_name:
|
||||
return None
|
||||
|
||||
new_preset_name = get_available_preset_name(group_name, new_preset_name)
|
||||
logger.info('Moving "%s" to "%s"', old_preset_name, new_preset_name)
|
||||
os.rename(
|
||||
get_preset_path(group_name, old_preset_name),
|
||||
get_preset_path(group_name, new_preset_name),
|
||||
)
|
||||
# set the modification date to now
|
||||
now = time.time()
|
||||
os.utime(get_preset_path(group_name, new_preset_name), (now, now))
|
||||
return new_preset_name
|
@ -0,0 +1,220 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
|
||||
from typing import Tuple, Iterable
|
||||
|
||||
import evdev
|
||||
from evdev import ecodes
|
||||
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.configs.system_mapping import system_mapping
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.exceptions import InputEventCreationError
|
||||
|
||||
# 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,
|
||||
]
|
||||
|
||||
|
||||
class EventCombination(Tuple[InputEvent]):
|
||||
"""one or multiple InputEvent objects for use as an unique identifier for mappings"""
|
||||
|
||||
# tuple is immutable, therefore we need to override __new__()
|
||||
# https://jfine-python-classes.readthedocs.io/en/latest/subclass-tuple.html
|
||||
def __new__(cls, *init_args) -> EventCombination:
|
||||
events = []
|
||||
for init_arg in init_args:
|
||||
event = None
|
||||
for constructor in InputEvent.__get_validators__():
|
||||
try:
|
||||
event = constructor(init_arg)
|
||||
break
|
||||
except InputEventCreationError:
|
||||
pass
|
||||
if event:
|
||||
events.append(event)
|
||||
else:
|
||||
raise ValueError(f"failed to create InputEvent with {init_arg = }")
|
||||
|
||||
return super().__new__(cls, events)
|
||||
|
||||
def __str__(self):
|
||||
# only used in tests and logging
|
||||
return f"EventCombination({', '.join([str(e.event_tuple) for e in self])})"
|
||||
|
||||
@classmethod
|
||||
def __get_validators__(cls):
|
||||
"""used by pydantic to create EventCombination objects"""
|
||||
yield cls.from_string
|
||||
yield cls.from_events
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, init_string: str) -> EventCombination:
|
||||
init_args = init_string.split("+")
|
||||
return cls(*init_args)
|
||||
|
||||
@classmethod
|
||||
def from_events(
|
||||
cls, init_events: Iterable[InputEvent | evdev.InputEvent]
|
||||
) -> EventCombination:
|
||||
return cls(*init_events)
|
||||
|
||||
def contains_type_and_code(self, type, code) -> bool:
|
||||
"""if a InputEvent contains the type and code"""
|
||||
for event in self:
|
||||
if event.type_and_code == (type, code):
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_problematic(self):
|
||||
"""Is this combination going to work properly on all systems?"""
|
||||
if len(self) <= 1:
|
||||
return False
|
||||
|
||||
for event in self:
|
||||
if event.type != ecodes.EV_KEY:
|
||||
continue
|
||||
|
||||
if event.code in DIFFICULT_COMBINATIONS:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_permutations(self):
|
||||
"""Get a list of EventCombination objects 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]
|
||||
|
||||
permutations = []
|
||||
for permutation in itertools.permutations(self[:-1]):
|
||||
permutations.append(EventCombination(*permutation, self[-1]))
|
||||
|
||||
return permutations
|
||||
|
||||
def json_str(self) -> str:
|
||||
return "+".join([event.json_str() for event in self])
|
||||
|
||||
def beautify(self) -> str:
|
||||
"""Get a human readable string representation."""
|
||||
result = []
|
||||
|
||||
for event in self:
|
||||
|
||||
if event.type not in ecodes.bytype:
|
||||
logger.error("Unknown type for %s", event)
|
||||
result.append(str(event.code))
|
||||
continue
|
||||
|
||||
if event.code not in ecodes.bytype[event.type]:
|
||||
logger.error("Unknown combination code for %s", event)
|
||||
result.append(str(event.code))
|
||||
continue
|
||||
|
||||
key_name = None
|
||||
|
||||
# first try to find the name in xmodmap to not display wrong
|
||||
# names due to the keyboard layout
|
||||
if event.type == ecodes.EV_KEY:
|
||||
key_name = system_mapping.get_name(event.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 = ecodes.bytype[event.type][event.code]
|
||||
if isinstance(key_name, list):
|
||||
key_name = key_name[0]
|
||||
|
||||
if event.type != ecodes.EV_KEY:
|
||||
direction = {
|
||||
# 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((event.code, event.value))
|
||||
if direction is not None:
|
||||
key_name += f" {direction}"
|
||||
|
||||
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")
|
||||
key_name = key_name.replace("ABS_HAT0Y", "DPad")
|
||||
key_name = key_name.replace("ABS_HAT1X", "DPad 2")
|
||||
key_name = key_name.replace("ABS_HAT1Y", "DPad 2")
|
||||
key_name = key_name.replace("ABS_HAT2X", "DPad 3")
|
||||
key_name = key_name.replace("ABS_HAT2Y", "DPad 3")
|
||||
|
||||
key_name = key_name.replace("ABS_X", "Joystick")
|
||||
key_name = key_name.replace("ABS_Y", "Joystick")
|
||||
key_name = key_name.replace("ABS_RX", "Joystick 2")
|
||||
key_name = key_name.replace("ABS_RY", "Joystick 2")
|
||||
|
||||
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(" ", " ")
|
||||
|
||||
result.append(key_name)
|
||||
|
||||
return " + ".join(result)
|
@ -0,0 +1,135 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
import evdev
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Tuple
|
||||
|
||||
from inputremapper.exceptions import InputEventCreationError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InputEvent:
|
||||
"""
|
||||
the evnet used by inputremapper
|
||||
|
||||
as a drop in replacement for evdev.InputEvent
|
||||
"""
|
||||
|
||||
sec: int
|
||||
usec: int
|
||||
type: int
|
||||
code: int
|
||||
value: int
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.type, self.code, self.value))
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, InputEvent) or isinstance(other, evdev.InputEvent):
|
||||
return self.event_tuple == (other.type, other.code, other.value)
|
||||
if isinstance(other, tuple):
|
||||
return self.event_tuple == other
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def __get_validators__(cls):
|
||||
"""used by pydantic and EventCombination to create InputEvent objects"""
|
||||
yield cls.from_event
|
||||
yield cls.from_tuple
|
||||
yield cls.from_string
|
||||
|
||||
@classmethod
|
||||
def from_event(cls, event: evdev.InputEvent) -> InputEvent:
|
||||
"""create a InputEvent from another InputEvent or evdev.InputEvent"""
|
||||
try:
|
||||
return cls(event.sec, event.usec, event.type, event.code, event.value)
|
||||
except AttributeError:
|
||||
raise InputEventCreationError(
|
||||
f"failed to create InputEvent from {event = }"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, string: str) -> InputEvent:
|
||||
"""create a InputEvent from a string like 'type, code, value'"""
|
||||
try:
|
||||
t, c, v = string.split(",")
|
||||
return cls(0, 0, int(t), int(c), int(v))
|
||||
except (ValueError, AttributeError):
|
||||
raise InputEventCreationError(
|
||||
f"failed to create InputEvent from {string = !r}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_tuple(cls, event_tuple: Tuple[int, int, int]) -> InputEvent:
|
||||
"""create a InputEvent from a (type, code, value) tuple"""
|
||||
try:
|
||||
if len(event_tuple) != 3:
|
||||
raise InputEventCreationError(
|
||||
f"failed to create InputEvent {event_tuple = }"
|
||||
f" must have length 3"
|
||||
)
|
||||
return cls(
|
||||
0, 0, int(event_tuple[0]), int(event_tuple[1]), int(event_tuple[2])
|
||||
)
|
||||
except ValueError:
|
||||
raise InputEventCreationError(
|
||||
f"failed to create InputEvent from {event_tuple = }"
|
||||
)
|
||||
except TypeError:
|
||||
raise InputEventCreationError(
|
||||
f"failed to create InputEvent from {type(event_tuple) = }"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def btn_left(cls):
|
||||
return cls(0, 0, evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT, 1)
|
||||
|
||||
@property
|
||||
def type_and_code(self) -> Tuple[int, int]:
|
||||
"""event type, code"""
|
||||
return self.type, self.code
|
||||
|
||||
@property
|
||||
def event_tuple(self) -> Tuple[int, int, int]:
|
||||
"""event type, code, value"""
|
||||
return self.type, self.code, self.value
|
||||
|
||||
def modify(
|
||||
self,
|
||||
sec: int = None,
|
||||
usec: int = None,
|
||||
type: int = None,
|
||||
code: int = None,
|
||||
value: int = None,
|
||||
) -> InputEvent:
|
||||
"""return a new modified event"""
|
||||
return InputEvent(
|
||||
sec if sec is not None else self.sec,
|
||||
usec if usec is not None else self.usec,
|
||||
type if type is not None else self.type,
|
||||
code if code is not None else self.code,
|
||||
value if value is not None else self.value,
|
||||
)
|
||||
|
||||
def json_str(self) -> str:
|
||||
return ",".join([str(self.type), str(self.code), str(self.value)])
|
@ -1,265 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""A button or a key combination."""
|
||||
|
||||
|
||||
import itertools
|
||||
|
||||
import evdev
|
||||
from evdev import ecodes
|
||||
|
||||
from inputremapper.system_mapping import system_mapping
|
||||
from inputremapper.logger import logger
|
||||
|
||||
|
||||
def verify(key):
|
||||
"""Check if the key is an int 3-tuple of type, code, value"""
|
||||
if not isinstance(key, tuple) or len(key) != 3:
|
||||
raise ValueError(f"Expected key to be a 3-tuple, but got {key}")
|
||||
if sum([not isinstance(value, int) for value in key]) != 0:
|
||||
raise ValueError(f"Can only use integers, but got {key}")
|
||||
|
||||
|
||||
# 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,
|
||||
]
|
||||
|
||||
|
||||
class Key:
|
||||
"""Represents one or more pressed down keys.
|
||||
|
||||
Can be used in hashmaps/dicts as key
|
||||
"""
|
||||
|
||||
def __init__(self, *keys):
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
Takes an arbitrary number of tuples as arguments. Each one should
|
||||
be in the format of
|
||||
0: type, one of evdev.events, taken from the original source
|
||||
event. Everything will be mapped to EV_KEY.
|
||||
1: The source keycode, what the mouse would report without any
|
||||
modification.
|
||||
2. The value. 1 (down), 0 (up) or any
|
||||
other value that the device reports. Gamepads use a continuous
|
||||
space of values for joysticks and triggers.
|
||||
|
||||
or Key objects, which will flatten all of them into one combination
|
||||
"""
|
||||
if len(keys) == 0:
|
||||
raise ValueError("At least one key is required")
|
||||
|
||||
if isinstance(keys[0], int):
|
||||
# type, code, value was provided instead of a tuple
|
||||
keys = (keys,)
|
||||
|
||||
# multiple objects of Key get flattened into one tuple
|
||||
flattened = ()
|
||||
for key in keys:
|
||||
if isinstance(key, Key):
|
||||
flattened += key.keys # pylint: disable=no-member
|
||||
else:
|
||||
flattened += (key,)
|
||||
keys = flattened
|
||||
|
||||
for key in keys:
|
||||
verify(key)
|
||||
|
||||
self.keys = tuple(keys)
|
||||
self.release = (*self.keys[-1][:2], 0)
|
||||
|
||||
@classmethod
|
||||
def btn_left(cls):
|
||||
"""Construct a Key object representing a left click on a mouse."""
|
||||
return cls(ecodes.EV_KEY, ecodes.BTN_LEFT, 1)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.keys)
|
||||
|
||||
def __getitem__(self, item):
|
||||
return self.keys[item]
|
||||
|
||||
def __len__(self):
|
||||
"""Get the number of pressed down kes."""
|
||||
return len(self.keys)
|
||||
|
||||
def __str__(self):
|
||||
return f"Key{str(self.keys)}"
|
||||
|
||||
def __repr__(self):
|
||||
# used in the AssertionError output of tests
|
||||
return self.__str__()
|
||||
|
||||
def __hash__(self):
|
||||
if len(self.keys) == 1:
|
||||
return hash(self.keys[0])
|
||||
|
||||
return hash(self.keys)
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, tuple):
|
||||
if isinstance(other[0], tuple):
|
||||
# a combination ((1, 5, 1), (1, 3, 1))
|
||||
return self.keys == other
|
||||
|
||||
# otherwise, self needs to represent a single key as well
|
||||
return len(self.keys) == 1 and self.keys[0] == other
|
||||
|
||||
if not isinstance(other, Key):
|
||||
return False
|
||||
|
||||
# compare two instances of Key
|
||||
return self.keys == other.keys
|
||||
|
||||
def is_problematic(self):
|
||||
"""Is this combination going to work properly on all systems?"""
|
||||
if len(self.keys) <= 1:
|
||||
return False
|
||||
|
||||
for sub_key in self.keys:
|
||||
if sub_key[0] != ecodes.EV_KEY:
|
||||
continue
|
||||
|
||||
if sub_key[1] in DIFFICULT_COMBINATIONS:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_permutations(self):
|
||||
"""Get a list of Key objects representing all possible permutations.
|
||||
|
||||
combining a + b + c should have the same result as b + a + c.
|
||||
Only the last key remains the same in the returned result.
|
||||
"""
|
||||
if len(self.keys) <= 2:
|
||||
return [self]
|
||||
|
||||
permutations = []
|
||||
for permutation in itertools.permutations(self.keys[:-1]):
|
||||
permutations.append(Key(*permutation, self.keys[-1]))
|
||||
|
||||
return permutations
|
||||
|
||||
def beautify(self):
|
||||
"""Get a human readable string representation."""
|
||||
result = []
|
||||
|
||||
for sub_key in self:
|
||||
if isinstance(sub_key[0], tuple):
|
||||
raise Exception("deprecated stuff")
|
||||
|
||||
ev_type, code, value = sub_key
|
||||
|
||||
if ev_type not in evdev.ecodes.bytype:
|
||||
logger.error("Unknown key type for %s", sub_key)
|
||||
result.append(str(code))
|
||||
continue
|
||||
|
||||
if code not in evdev.ecodes.bytype[ev_type]:
|
||||
logger.error("Unknown key code for %s", sub_key)
|
||||
result.append(str(code))
|
||||
continue
|
||||
|
||||
key_name = None
|
||||
|
||||
# first try to find the name in xmodmap to not display wrong
|
||||
# names due to the keyboard layout
|
||||
if ev_type == evdev.ecodes.EV_KEY:
|
||||
key_name = system_mapping.get_name(code)
|
||||
|
||||
if key_name is None:
|
||||
# if no result, look in the linux key constants. On a german
|
||||
# keyboard for example z and y are switched, which will therefore
|
||||
# cause the wrong letter to be displayed.
|
||||
key_name = evdev.ecodes.bytype[ev_type][code]
|
||||
if isinstance(key_name, list):
|
||||
key_name = key_name[0]
|
||||
|
||||
if ev_type != evdev.ecodes.EV_KEY:
|
||||
direction = {
|
||||
# D-Pad
|
||||
(evdev.ecodes.ABS_HAT0X, -1): "Left",
|
||||
(evdev.ecodes.ABS_HAT0X, 1): "Right",
|
||||
(evdev.ecodes.ABS_HAT0Y, -1): "Up",
|
||||
(evdev.ecodes.ABS_HAT0Y, 1): "Down",
|
||||
(evdev.ecodes.ABS_HAT1X, -1): "Left",
|
||||
(evdev.ecodes.ABS_HAT1X, 1): "Right",
|
||||
(evdev.ecodes.ABS_HAT1Y, -1): "Up",
|
||||
(evdev.ecodes.ABS_HAT1Y, 1): "Down",
|
||||
(evdev.ecodes.ABS_HAT2X, -1): "Left",
|
||||
(evdev.ecodes.ABS_HAT2X, 1): "Right",
|
||||
(evdev.ecodes.ABS_HAT2Y, -1): "Up",
|
||||
(evdev.ecodes.ABS_HAT2Y, 1): "Down",
|
||||
# joystick
|
||||
(evdev.ecodes.ABS_X, 1): "Right",
|
||||
(evdev.ecodes.ABS_X, -1): "Left",
|
||||
(evdev.ecodes.ABS_Y, 1): "Down",
|
||||
(evdev.ecodes.ABS_Y, -1): "Up",
|
||||
(evdev.ecodes.ABS_RX, 1): "Right",
|
||||
(evdev.ecodes.ABS_RX, -1): "Left",
|
||||
(evdev.ecodes.ABS_RY, 1): "Down",
|
||||
(evdev.ecodes.ABS_RY, -1): "Up",
|
||||
# wheel
|
||||
(evdev.ecodes.REL_WHEEL, -1): "Down",
|
||||
(evdev.ecodes.REL_WHEEL, 1): "Up",
|
||||
(evdev.ecodes.REL_HWHEEL, -1): "Left",
|
||||
(evdev.ecodes.REL_HWHEEL, 1): "Right",
|
||||
}.get((code, value))
|
||||
if direction is not None:
|
||||
key_name += f" {direction}"
|
||||
|
||||
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")
|
||||
key_name = key_name.replace("ABS_HAT0Y", "DPad")
|
||||
key_name = key_name.replace("ABS_HAT1X", "DPad 2")
|
||||
key_name = key_name.replace("ABS_HAT1Y", "DPad 2")
|
||||
key_name = key_name.replace("ABS_HAT2X", "DPad 3")
|
||||
key_name = key_name.replace("ABS_HAT2Y", "DPad 3")
|
||||
|
||||
key_name = key_name.replace("ABS_X", "Joystick")
|
||||
key_name = key_name.replace("ABS_Y", "Joystick")
|
||||
key_name = key_name.replace("ABS_RX", "Joystick 2")
|
||||
key_name = key_name.replace("ABS_RY", "Joystick 2")
|
||||
|
||||
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(" ", " ")
|
||||
|
||||
result.append(key_name)
|
||||
|
||||
return " + ".join(result)
|
@ -1,290 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Contains and manages mappings."""
|
||||
|
||||
|
||||
import os
|
||||
import json
|
||||
import copy
|
||||
|
||||
from evdev.ecodes import EV_KEY, BTN_LEFT
|
||||
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.paths import touch
|
||||
from inputremapper.config import ConfigBase, config
|
||||
from inputremapper.key import Key
|
||||
from inputremapper.injection.macros.parse import clean
|
||||
|
||||
|
||||
def split_key(key):
|
||||
"""Take a key like "1,2,3" and return a 3-tuple of ints."""
|
||||
key = key.strip()
|
||||
|
||||
if key.count(",") != 2:
|
||||
logger.error('Found invalid key: "%s"', key)
|
||||
return None
|
||||
|
||||
ev_type, code, value = key.split(",")
|
||||
try:
|
||||
key = (int(ev_type), int(code), int(value))
|
||||
except ValueError:
|
||||
logger.error('Found non-int in: "%s"', key)
|
||||
return None
|
||||
|
||||
return key
|
||||
|
||||
|
||||
class Mapping(ConfigBase):
|
||||
"""Contains and manages mappings and config of a single preset."""
|
||||
|
||||
def __init__(self):
|
||||
self._mapping = {} # a mapping of Key objects to strings
|
||||
self._changed = False
|
||||
|
||||
# are there actually any keys set in the mapping file?
|
||||
self.num_saved_keys = 0
|
||||
|
||||
super().__init__(fallback=config)
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate over Key objects and their mappings."""
|
||||
return iter(self._mapping.items())
|
||||
|
||||
def __len__(self):
|
||||
return len(self._mapping)
|
||||
|
||||
def set(self, *args):
|
||||
"""Set a config value. See `ConfigBase.set`."""
|
||||
self._changed = True
|
||||
return super().set(*args)
|
||||
|
||||
def remove(self, *args):
|
||||
"""Remove a config value. See `ConfigBase.remove`."""
|
||||
self._changed = True
|
||||
return super().remove(*args)
|
||||
|
||||
def change(self, new_key, target, symbol, previous_key=None):
|
||||
"""Replace the mapping of a keycode with a different one.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
new_key : Key
|
||||
target : string
|
||||
name of target uinput
|
||||
symbol : string
|
||||
A single symbol known to xkb or linux.
|
||||
Examples: KEY_KP1, Shift_L, a, B, BTN_LEFT.
|
||||
previous_key : Key or None
|
||||
the previous key
|
||||
|
||||
If not set, will not remove any previous mapping. If you recently
|
||||
used (1, 10, 1) for new_key and want to overwrite that with
|
||||
(1, 11, 1), provide (1, 10, 1) here.
|
||||
"""
|
||||
if not isinstance(new_key, Key):
|
||||
raise TypeError(f"Expected {new_key} to be a Key object")
|
||||
|
||||
if symbol is None or symbol.strip() == "":
|
||||
raise ValueError("Expected `symbol` not to be empty")
|
||||
|
||||
if target is None or target.strip() == "":
|
||||
raise ValueError("Expected `target` not to be None")
|
||||
|
||||
target = target.strip()
|
||||
symbol = symbol.strip()
|
||||
output = (symbol, target)
|
||||
|
||||
if previous_key is None and self._mapping.get(new_key):
|
||||
# the key didn't change
|
||||
previous_key = new_key
|
||||
|
||||
key_changed = new_key != previous_key
|
||||
if not key_changed and (symbol, target) == self._mapping.get(new_key):
|
||||
# nothing was changed, no need to act
|
||||
return
|
||||
|
||||
self.clear(new_key) # this also clears all equivalent keys
|
||||
|
||||
logger.debug('changing %s to "%s"', new_key, clean(symbol))
|
||||
|
||||
self._mapping[new_key] = output
|
||||
|
||||
if key_changed and previous_key is not None:
|
||||
# clear previous mapping of that code, because the line
|
||||
# representing that one will now represent a different one
|
||||
self.clear(previous_key)
|
||||
|
||||
self._changed = True
|
||||
|
||||
def has_unsaved_changes(self):
|
||||
"""Check if there are unsaved changed."""
|
||||
return self._changed
|
||||
|
||||
def set_has_unsaved_changes(self, changed):
|
||||
"""Write down if there are unsaved changes, or if they have been saved."""
|
||||
self._changed = changed
|
||||
|
||||
def clear(self, key):
|
||||
"""Remove a keycode from the mapping.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : Key
|
||||
"""
|
||||
if not isinstance(key, Key):
|
||||
raise TypeError(f"Expected key to be a Key object but got {key}")
|
||||
|
||||
for permutation in key.get_permutations():
|
||||
if permutation in self._mapping:
|
||||
logger.debug("%s cleared", permutation)
|
||||
del self._mapping[permutation]
|
||||
self._changed = True
|
||||
# there should be only one variation of the permutations
|
||||
# in the mapping actually
|
||||
|
||||
def empty(self):
|
||||
"""Remove all mappings and custom configs without saving."""
|
||||
self._mapping = {}
|
||||
self._changed = True
|
||||
self.clear_config()
|
||||
|
||||
def load(self, path):
|
||||
"""Load a dumped JSON from home to overwrite the mappings.
|
||||
|
||||
Parameters
|
||||
path : string
|
||||
Path of the preset file
|
||||
"""
|
||||
logger.info('Loading preset from "%s"', path)
|
||||
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f'Tried to load non-existing preset "{path}"')
|
||||
|
||||
self.empty()
|
||||
self._changed = False
|
||||
|
||||
with open(path, "r") as file:
|
||||
preset_dict = json.load(file)
|
||||
|
||||
if not isinstance(preset_dict.get("mapping"), dict):
|
||||
logger.error(
|
||||
"Expected mapping to be a dict, but was %s. "
|
||||
'Invalid preset config at "%s"',
|
||||
preset_dict.get("mapping"),
|
||||
path,
|
||||
)
|
||||
return
|
||||
|
||||
for key, symbol in preset_dict["mapping"].items():
|
||||
try:
|
||||
key = Key(
|
||||
*[
|
||||
split_key(chunk)
|
||||
for chunk in key.split("+")
|
||||
if chunk.strip() != ""
|
||||
]
|
||||
)
|
||||
except ValueError as error:
|
||||
logger.error(str(error))
|
||||
continue
|
||||
|
||||
if None in key:
|
||||
continue
|
||||
|
||||
if isinstance(symbol, list):
|
||||
symbol = tuple(symbol) # use a immutable type
|
||||
|
||||
logger.debug("%s maps to %s", key, symbol)
|
||||
self._mapping[key] = symbol
|
||||
|
||||
# add any metadata of the mapping
|
||||
for key in preset_dict:
|
||||
if key == "mapping":
|
||||
continue
|
||||
self._config[key] = preset_dict[key]
|
||||
|
||||
self._changed = False
|
||||
self.num_saved_keys = len(self)
|
||||
|
||||
def clone(self):
|
||||
"""Create a copy of the mapping."""
|
||||
mapping = Mapping()
|
||||
mapping._mapping = copy.deepcopy(self._mapping)
|
||||
mapping.set_has_unsaved_changes(self._changed)
|
||||
return mapping
|
||||
|
||||
def save(self, path):
|
||||
"""Dump as JSON into home."""
|
||||
logger.info("Saving preset to %s", path)
|
||||
|
||||
touch(path)
|
||||
|
||||
with open(path, "w") as file:
|
||||
if self._config.get("mapping") is not None:
|
||||
logger.error(
|
||||
'"mapping" is reserved and cannot be used as config ' "key: %s",
|
||||
self._config.get("mapping"),
|
||||
)
|
||||
|
||||
preset_dict = self._config.copy() # shallow copy
|
||||
|
||||
# make sure to keep the option to add metadata if ever needed,
|
||||
# so put the mapping into a special key
|
||||
json_ready_mapping = {}
|
||||
# tuple keys are not possible in json, encode them as string
|
||||
for key, value in self._mapping.items():
|
||||
new_key = "+".join(
|
||||
[",".join([str(value) for value in sub_key]) for sub_key in key]
|
||||
)
|
||||
json_ready_mapping[new_key] = value
|
||||
|
||||
preset_dict["mapping"] = json_ready_mapping
|
||||
json.dump(preset_dict, file, indent=4)
|
||||
file.write("\n")
|
||||
|
||||
self._changed = False
|
||||
self.num_saved_keys = len(self)
|
||||
|
||||
def get_mapping(self, key):
|
||||
"""Read the (symbol, target)-tuple that is mapped to this keycode.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : Key
|
||||
"""
|
||||
if not isinstance(key, Key):
|
||||
raise TypeError(f"Expected key to be a Key object but got {key}")
|
||||
|
||||
for permutation in key.get_permutations():
|
||||
existing = self._mapping.get(permutation)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
return None
|
||||
|
||||
def dangerously_mapped_btn_left(self):
|
||||
"""Return True if this mapping disables BTN_Left."""
|
||||
if self.get_mapping(Key(EV_KEY, BTN_LEFT, 1)) is not None:
|
||||
values = [value[0].lower() for value in self._mapping.values()]
|
||||
return "btn_left" not in values
|
||||
|
||||
return False
|
@ -1,172 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Helperfunctions to find device ids, names, and to load presets."""
|
||||
|
||||
|
||||
import os
|
||||
import time
|
||||
import glob
|
||||
import re
|
||||
|
||||
from inputremapper.paths import get_preset_path, mkdir
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.groups import groups
|
||||
|
||||
|
||||
def get_available_preset_name(group_name, preset="new preset", copy=False):
|
||||
"""Increment the preset name until it is available."""
|
||||
if group_name is None:
|
||||
# endless loop otherwise
|
||||
raise ValueError("group_name may not be None")
|
||||
|
||||
preset = preset.strip()
|
||||
|
||||
if copy and not re.match(r"^.+\scopy( \d+)?$", preset):
|
||||
preset = f"{preset} copy"
|
||||
|
||||
# find a name that is not already taken
|
||||
if os.path.exists(get_preset_path(group_name, preset)):
|
||||
# if there already is a trailing number, increment it instead of
|
||||
# adding another one
|
||||
match = re.match(r"^(.+) (\d+)$", preset)
|
||||
if match:
|
||||
preset = match[1]
|
||||
i = int(match[2]) + 1
|
||||
else:
|
||||
i = 2
|
||||
|
||||
while os.path.exists(get_preset_path(group_name, f"{preset} {i}")):
|
||||
i += 1
|
||||
|
||||
return f"{preset} {i}"
|
||||
|
||||
return preset
|
||||
|
||||
|
||||
def get_presets(group_name):
|
||||
"""Get all presets for the device and user, starting with the newest.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
group_name : string
|
||||
"""
|
||||
device_folder = get_preset_path(group_name)
|
||||
mkdir(device_folder)
|
||||
|
||||
paths = glob.glob(os.path.join(device_folder, "*.json"))
|
||||
presets = [
|
||||
os.path.splitext(os.path.basename(path))[0]
|
||||
for path in sorted(paths, key=os.path.getmtime)
|
||||
]
|
||||
# the highest timestamp to the front
|
||||
presets.reverse()
|
||||
return presets
|
||||
|
||||
|
||||
def get_any_preset():
|
||||
"""Return the first found tuple of (device, preset)."""
|
||||
group_names = groups.list_group_names()
|
||||
if len(group_names) == 0:
|
||||
return None, None
|
||||
any_device = list(group_names)[0]
|
||||
any_preset = (get_presets(any_device) or [None])[0]
|
||||
return any_device, any_preset
|
||||
|
||||
|
||||
def find_newest_preset(group_name=None):
|
||||
"""Get a tuple of (device, preset) that was most recently modified
|
||||
in the users home directory.
|
||||
|
||||
If no device has been configured yet, return an arbitrary device.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
group_name : string
|
||||
If set, will return the newest preset for the device or None
|
||||
"""
|
||||
# sort the oldest files to the front in order to use pop to get the newest
|
||||
if group_name is None:
|
||||
paths = sorted(
|
||||
glob.glob(os.path.join(get_preset_path(), "*/*.json")), key=os.path.getmtime
|
||||
)
|
||||
else:
|
||||
paths = sorted(
|
||||
glob.glob(os.path.join(get_preset_path(group_name), "*.json")),
|
||||
key=os.path.getmtime,
|
||||
)
|
||||
|
||||
if len(paths) == 0:
|
||||
logger.debug("No presets found")
|
||||
return get_any_preset()
|
||||
|
||||
group_names = groups.list_group_names()
|
||||
|
||||
newest_path = None
|
||||
while len(paths) > 0:
|
||||
# take the newest path
|
||||
path = paths.pop()
|
||||
preset = os.path.split(path)[1]
|
||||
group_name = os.path.split(os.path.split(path)[0])[1]
|
||||
if group_name in group_names:
|
||||
newest_path = path
|
||||
break
|
||||
|
||||
if newest_path is None:
|
||||
return get_any_preset()
|
||||
|
||||
preset = os.path.splitext(preset)[0]
|
||||
logger.debug('The newest preset is "%s", "%s"', group_name, preset)
|
||||
|
||||
return group_name, preset
|
||||
|
||||
|
||||
def delete_preset(group_name, preset):
|
||||
"""Delete one of the users presets."""
|
||||
preset_path = get_preset_path(group_name, preset)
|
||||
if not os.path.exists(preset_path):
|
||||
logger.debug('Cannot remove non existing path "%s"', preset_path)
|
||||
return
|
||||
|
||||
logger.info('Removing "%s"', preset_path)
|
||||
os.remove(preset_path)
|
||||
|
||||
device_path = get_preset_path(group_name)
|
||||
if os.path.exists(device_path) and len(os.listdir(device_path)) == 0:
|
||||
logger.debug('Removing empty dir "%s"', device_path)
|
||||
os.rmdir(device_path)
|
||||
|
||||
|
||||
def rename_preset(group_name, old_preset_name, new_preset_name):
|
||||
"""Rename one of the users presets while avoiding name conflicts."""
|
||||
if new_preset_name == old_preset_name:
|
||||
return None
|
||||
|
||||
new_preset_name = get_available_preset_name(group_name, new_preset_name)
|
||||
logger.info('Moving "%s" to "%s"', old_preset_name, new_preset_name)
|
||||
os.rename(
|
||||
get_preset_path(group_name, old_preset_name),
|
||||
get_preset_path(group_name, new_preset_name),
|
||||
)
|
||||
# set the modification date to now
|
||||
now = time.time()
|
||||
os.utime(get_preset_path(group_name, new_preset_name), (now, now))
|
||||
return new_preset_name
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,118 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import unittest
|
||||
|
||||
import evdev
|
||||
from dataclasses import FrozenInstanceError
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.exceptions import InputEventCreationError
|
||||
|
||||
|
||||
class TestInputEvent(unittest.TestCase):
|
||||
def test_from_event(self):
|
||||
e1 = InputEvent.from_event(evdev.InputEvent(1, 2, 3, 4, 5))
|
||||
e2 = InputEvent.from_event(e1)
|
||||
|
||||
self.assertEqual(e1, e2)
|
||||
self.assertEqual(e1.sec, 1)
|
||||
self.assertEqual(e1.usec, 2)
|
||||
self.assertEqual(e1.type, 3)
|
||||
self.assertEqual(e1.code, 4)
|
||||
self.assertEqual(e1.value, 5)
|
||||
|
||||
self.assertEqual(e1.sec, e2.sec)
|
||||
self.assertEqual(e1.usec, e2.usec)
|
||||
self.assertEqual(e1.type, e2.type)
|
||||
self.assertEqual(e1.code, e2.code)
|
||||
self.assertEqual(e1.value, e2.value)
|
||||
|
||||
self.assertRaises(InputEventCreationError, InputEvent.from_event, "1,2,3")
|
||||
|
||||
def test_from_string(self):
|
||||
s1 = "1,2,3"
|
||||
s2 = "1 ,2, 3 "
|
||||
s3 = (1, 2, 3)
|
||||
s4 = "1,2,3,4"
|
||||
s5 = "1,2,_3"
|
||||
|
||||
e1 = InputEvent.from_string(s1)
|
||||
e2 = InputEvent.from_string(s2)
|
||||
self.assertEqual(e1, e2)
|
||||
self.assertEqual(e1.sec, 0)
|
||||
self.assertEqual(e1.usec, 0)
|
||||
self.assertEqual(e1.type, 1)
|
||||
self.assertEqual(e1.code, 2)
|
||||
self.assertEqual(e1.value, 3)
|
||||
|
||||
self.assertRaises(InputEventCreationError, InputEvent.from_string, s3)
|
||||
self.assertRaises(InputEventCreationError, InputEvent.from_string, s4)
|
||||
self.assertRaises(InputEventCreationError, InputEvent.from_string, s5)
|
||||
|
||||
def test_from_event_tuple(self):
|
||||
t1 = (1, 2, 3)
|
||||
t2 = (1, "2", 3)
|
||||
t3 = (1, 2, 3, 4, 5)
|
||||
t4 = (1, "b", 3)
|
||||
|
||||
e1 = InputEvent.from_tuple(t1)
|
||||
e2 = InputEvent.from_tuple(t2)
|
||||
self.assertEqual(e1, e2)
|
||||
self.assertEqual(e1.sec, 0)
|
||||
self.assertEqual(e1.usec, 0)
|
||||
self.assertEqual(e1.type, 1)
|
||||
self.assertEqual(e1.code, 2)
|
||||
self.assertEqual(e1.value, 3)
|
||||
|
||||
self.assertRaises(InputEventCreationError, InputEvent.from_string, t3)
|
||||
self.assertRaises(InputEventCreationError, InputEvent.from_string, t4)
|
||||
|
||||
def test_properties(self):
|
||||
e1 = InputEvent.btn_left()
|
||||
self.assertEqual(
|
||||
e1.event_tuple, (evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT, 1)
|
||||
)
|
||||
self.assertEqual(e1.type_and_code, (evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT))
|
||||
|
||||
with self.assertRaises(TypeError):
|
||||
e1.event_tuple = (1, 2, 3)
|
||||
|
||||
with self.assertRaises(TypeError):
|
||||
e1.type_and_code = (1, 2)
|
||||
|
||||
with self.assertRaises(FrozenInstanceError):
|
||||
e1.value = 5
|
||||
|
||||
def test_modify(self):
|
||||
e1 = InputEvent(1, 2, 3, 4, 5)
|
||||
e2 = e1.modify(value=6)
|
||||
e3 = e1.modify(sec=0, usec=0, type=0, code=0, value=0)
|
||||
|
||||
self.assertNotEqual(e1, e2)
|
||||
self.assertEqual(e1.sec, e2.sec)
|
||||
self.assertEqual(e1.usec, e2.usec)
|
||||
self.assertEqual(e1.type, e2.type)
|
||||
self.assertEqual(e1.code, e2.code)
|
||||
self.assertNotEqual(e1.value, e2.value)
|
||||
self.assertEqual(e3.sec, 0)
|
||||
self.assertEqual(e3.usec, 0)
|
||||
self.assertEqual(e3.type, 0)
|
||||
self.assertEqual(e3.code, 0)
|
||||
self.assertEqual(e3.value, 0)
|
@ -1,373 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import os
|
||||
import unittest
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from evdev.ecodes import EV_KEY, EV_ABS, KEY_A
|
||||
|
||||
from inputremapper.mapping import Mapping, split_key
|
||||
from inputremapper.system_mapping import SystemMapping, XMODMAP_FILENAME
|
||||
from inputremapper.config import config
|
||||
from inputremapper.paths import get_preset_path
|
||||
from inputremapper.key import Key
|
||||
|
||||
from tests.test import tmp, quick_cleanup
|
||||
|
||||
|
||||
class TestSystemMapping(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
quick_cleanup()
|
||||
|
||||
def test_split_key(self):
|
||||
self.assertEqual(split_key("1,2,3"), (1, 2, 3))
|
||||
self.assertIsNone(split_key("1,2"), (1, 2, 1))
|
||||
self.assertIsNone(split_key("1"))
|
||||
self.assertIsNone(split_key("1,a,2"))
|
||||
self.assertIsNone(split_key("1,a"))
|
||||
|
||||
def test_update(self):
|
||||
system_mapping = SystemMapping()
|
||||
system_mapping.update({"foo1": 101, "bar1": 102})
|
||||
system_mapping.update({"foo2": 201, "bar2": 202})
|
||||
self.assertEqual(system_mapping.get("foo1"), 101)
|
||||
self.assertEqual(system_mapping.get("bar2"), 202)
|
||||
|
||||
def test_xmodmap_file(self):
|
||||
system_mapping = SystemMapping()
|
||||
path = os.path.join(tmp, XMODMAP_FILENAME)
|
||||
os.remove(path)
|
||||
|
||||
system_mapping.populate()
|
||||
self.assertTrue(os.path.exists(path))
|
||||
with open(path, "r") as file:
|
||||
content = json.load(file)
|
||||
self.assertEqual(content["a"], KEY_A)
|
||||
# only xmodmap stuff should be present
|
||||
self.assertNotIn("key_a", content)
|
||||
self.assertNotIn("KEY_A", content)
|
||||
self.assertNotIn("disable", content)
|
||||
|
||||
def test_correct_case(self):
|
||||
system_mapping = SystemMapping()
|
||||
system_mapping.clear()
|
||||
system_mapping._set("A", 31)
|
||||
system_mapping._set("a", 32)
|
||||
system_mapping._set("abcd_B", 33)
|
||||
|
||||
self.assertEqual(system_mapping.correct_case("a"), "a")
|
||||
self.assertEqual(system_mapping.correct_case("A"), "A")
|
||||
self.assertEqual(system_mapping.correct_case("ABCD_b"), "abcd_B")
|
||||
# unknown stuff is returned as is
|
||||
self.assertEqual(system_mapping.correct_case("FOo"), "FOo")
|
||||
|
||||
self.assertEqual(system_mapping.get("A"), 31)
|
||||
self.assertEqual(system_mapping.get("a"), 32)
|
||||
self.assertEqual(system_mapping.get("ABCD_b"), 33)
|
||||
self.assertEqual(system_mapping.get("abcd_B"), 33)
|
||||
|
||||
def test_system_mapping(self):
|
||||
system_mapping = SystemMapping()
|
||||
system_mapping.populate()
|
||||
self.assertGreater(len(system_mapping._mapping), 100)
|
||||
|
||||
# this is case-insensitive
|
||||
self.assertEqual(system_mapping.get("1"), 2)
|
||||
self.assertEqual(system_mapping.get("KeY_1"), 2)
|
||||
|
||||
self.assertEqual(system_mapping.get("AlT_L"), 56)
|
||||
self.assertEqual(system_mapping.get("KEy_LEFtALT"), 56)
|
||||
|
||||
self.assertEqual(system_mapping.get("kEY_LeFTSHIFT"), 42)
|
||||
self.assertEqual(system_mapping.get("ShiFt_L"), 42)
|
||||
|
||||
self.assertEqual(system_mapping.get("BTN_left"), 272)
|
||||
|
||||
self.assertIsNotNone(system_mapping.get("KEY_KP4"))
|
||||
self.assertEqual(system_mapping.get("KP_Left"), system_mapping.get("KEY_KP4"))
|
||||
|
||||
# this only lists the correct casing,
|
||||
# includes linux constants and xmodmap symbols
|
||||
names = system_mapping.list_names()
|
||||
self.assertIn("2", names)
|
||||
self.assertIn("c", names)
|
||||
self.assertIn("KEY_3", names)
|
||||
self.assertNotIn("key_3", names)
|
||||
self.assertIn("KP_Down", names)
|
||||
self.assertNotIn("kp_down", names)
|
||||
names = system_mapping._mapping.keys()
|
||||
self.assertIn("F4", names)
|
||||
self.assertNotIn("f4", names)
|
||||
self.assertIn("BTN_RIGHT", names)
|
||||
self.assertNotIn("btn_right", names)
|
||||
self.assertIn("KEY_KP7", names)
|
||||
self.assertIn("KP_Home", names)
|
||||
self.assertNotIn("kp_home", names)
|
||||
|
||||
self.assertEqual(system_mapping.get("disable"), -1)
|
||||
|
||||
|
||||
class TestMapping(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.mapping = Mapping()
|
||||
self.assertFalse(self.mapping.has_unsaved_changes())
|
||||
|
||||
def tearDown(self):
|
||||
quick_cleanup()
|
||||
|
||||
def test_config(self):
|
||||
self.mapping.save(get_preset_path("foo", "bar2"))
|
||||
|
||||
self.assertEqual(self.mapping.get("a"), None)
|
||||
|
||||
self.assertFalse(self.mapping.has_unsaved_changes())
|
||||
|
||||
self.mapping.set("a", 1)
|
||||
self.assertEqual(self.mapping.get("a"), 1)
|
||||
self.assertTrue(self.mapping.has_unsaved_changes())
|
||||
|
||||
self.mapping.remove("a")
|
||||
self.mapping.set("a.b", 2)
|
||||
self.assertEqual(self.mapping.get("a.b"), 2)
|
||||
self.assertEqual(self.mapping._config["a"]["b"], 2)
|
||||
|
||||
self.mapping.remove("a.b")
|
||||
self.mapping.set("a.b.c", 3)
|
||||
self.assertEqual(self.mapping.get("a.b.c"), 3)
|
||||
self.assertEqual(self.mapping._config["a"]["b"]["c"], 3)
|
||||
|
||||
# setting mapping.whatever does not overwrite the mapping
|
||||
# after saving. It should be ignored.
|
||||
self.mapping.change(Key(EV_KEY, 81, 1), "keyboard", " a ")
|
||||
self.mapping.set("mapping.a", 2)
|
||||
self.assertEqual(self.mapping.num_saved_keys, 0)
|
||||
self.mapping.save(get_preset_path("foo", "bar"))
|
||||
self.assertEqual(self.mapping.num_saved_keys, len(self.mapping))
|
||||
self.assertFalse(self.mapping.has_unsaved_changes())
|
||||
self.mapping.load(get_preset_path("foo", "bar"))
|
||||
self.assertEqual(
|
||||
self.mapping.get_mapping(Key(EV_KEY, 81, 1)), ("a", "keyboard")
|
||||
)
|
||||
self.assertIsNone(self.mapping.get("mapping.a"))
|
||||
self.assertFalse(self.mapping.has_unsaved_changes())
|
||||
|
||||
# loading a different preset also removes the configs from memory
|
||||
self.mapping.remove("a")
|
||||
self.assertTrue(self.mapping.has_unsaved_changes())
|
||||
self.mapping.set("a.b.c", 6)
|
||||
self.mapping.load(get_preset_path("foo", "bar2"))
|
||||
self.assertIsNone(self.mapping.get("a.b.c"))
|
||||
|
||||
def test_fallback(self):
|
||||
config.set("d.e.f", 5)
|
||||
self.assertEqual(self.mapping.get("d.e.f"), 5)
|
||||
self.mapping.set("d.e.f", 3)
|
||||
self.assertEqual(self.mapping.get("d.e.f"), 3)
|
||||
|
||||
def test_clone(self):
|
||||
ev_1 = Key(EV_KEY, 1, 1)
|
||||
ev_2 = Key(EV_KEY, 2, 0)
|
||||
|
||||
mapping1 = Mapping()
|
||||
mapping1.change(ev_1, "keyboard", " a")
|
||||
mapping2 = mapping1.clone()
|
||||
mapping1.change(ev_2, "keyboard", "b ")
|
||||
|
||||
self.assertEqual(mapping1.get_mapping(ev_1), ("a", "keyboard"))
|
||||
self.assertEqual(mapping1.get_mapping(ev_2), ("b", "keyboard"))
|
||||
|
||||
self.assertEqual(mapping2.get_mapping(ev_1), ("a", "keyboard"))
|
||||
self.assertIsNone(mapping2.get_mapping(ev_2))
|
||||
|
||||
self.assertIsNone(mapping2.get_mapping(Key(EV_KEY, 2, 3)))
|
||||
self.assertIsNone(mapping2.get_mapping(Key(EV_KEY, 1, 3)))
|
||||
|
||||
def test_save_load(self):
|
||||
one = Key(EV_KEY, 10, 1)
|
||||
two = Key(EV_KEY, 11, 1)
|
||||
three = Key(EV_KEY, 12, 1)
|
||||
|
||||
self.mapping.change(one, "keyboard", "1")
|
||||
self.mapping.change(two, "keyboard", "2")
|
||||
self.mapping.change(Key(two, three), "keyboard", "3")
|
||||
self.mapping._config["foo"] = "bar"
|
||||
self.mapping.save(get_preset_path("Foo Device", "test"))
|
||||
|
||||
path = os.path.join(tmp, "presets", "Foo Device", "test.json")
|
||||
self.assertTrue(os.path.exists(path))
|
||||
|
||||
loaded = Mapping()
|
||||
self.assertEqual(len(loaded), 0)
|
||||
loaded.load(get_preset_path("Foo Device", "test"))
|
||||
|
||||
self.assertEqual(len(loaded), 3)
|
||||
self.assertEqual(loaded.get_mapping(one), ("1", "keyboard"))
|
||||
self.assertEqual(loaded.get_mapping(two), ("2", "keyboard"))
|
||||
self.assertEqual(loaded.get_mapping(Key(two, three)), ("3", "keyboard"))
|
||||
self.assertEqual(loaded._config["foo"], "bar")
|
||||
|
||||
def test_change(self):
|
||||
# the reader would not report values like 111 or 222, only 1 or -1.
|
||||
# the mapping just does what it is told, so it accepts them.
|
||||
ev_1 = Key(EV_KEY, 1, 111)
|
||||
ev_2 = Key(EV_KEY, 1, 222)
|
||||
ev_3 = Key(EV_KEY, 2, 111)
|
||||
ev_4 = Key(EV_ABS, 1, 111)
|
||||
|
||||
# 1 is not assigned yet, ignore it
|
||||
self.mapping.change(ev_1, "keyboard", "a", ev_2)
|
||||
self.assertTrue(self.mapping.has_unsaved_changes())
|
||||
self.assertIsNone(self.mapping.get_mapping(ev_2))
|
||||
self.assertEqual(self.mapping.get_mapping(ev_1), ("a", "keyboard"))
|
||||
self.assertEqual(len(self.mapping), 1)
|
||||
|
||||
# change ev_1 to ev_3 and change a to b
|
||||
self.mapping.change(ev_3, "keyboard", "b", ev_1)
|
||||
self.assertIsNone(self.mapping.get_mapping(ev_1))
|
||||
self.assertEqual(self.mapping.get_mapping(ev_3), ("b", "keyboard"))
|
||||
self.assertEqual(len(self.mapping), 1)
|
||||
|
||||
# add 4
|
||||
self.mapping.change(ev_4, "keyboard", "c", None)
|
||||
self.assertEqual(self.mapping.get_mapping(ev_3), ("b", "keyboard"))
|
||||
self.assertEqual(self.mapping.get_mapping(ev_4), ("c", "keyboard"))
|
||||
self.assertEqual(len(self.mapping), 2)
|
||||
|
||||
# change the mapping of 4 to d
|
||||
self.mapping.change(ev_4, "keyboard", "d", None)
|
||||
self.assertEqual(self.mapping.get_mapping(ev_4), ("d", "keyboard"))
|
||||
self.assertEqual(len(self.mapping), 2)
|
||||
|
||||
# this also works in the same way
|
||||
self.mapping.change(ev_4, "keyboard", "e", ev_4)
|
||||
self.assertEqual(self.mapping.get_mapping(ev_4), ("e", "keyboard"))
|
||||
self.assertEqual(len(self.mapping), 2)
|
||||
|
||||
self.assertEqual(self.mapping.num_saved_keys, 0)
|
||||
|
||||
def test_rejects_empty(self):
|
||||
key = Key(EV_KEY, 1, 111)
|
||||
self.assertEqual(len(self.mapping), 0)
|
||||
self.assertRaises(
|
||||
ValueError, lambda: self.mapping.change(key, "keyboard", " \n ")
|
||||
)
|
||||
self.assertRaises(ValueError, lambda: self.mapping.change(key, " \n ", "b"))
|
||||
self.assertEqual(len(self.mapping), 0)
|
||||
|
||||
def test_avoids_redundant_changes(self):
|
||||
# to avoid logs that don't add any value
|
||||
def clear(*_):
|
||||
# should not be called
|
||||
raise AssertionError
|
||||
|
||||
key = Key(EV_KEY, 987, 1)
|
||||
target = "keyboard"
|
||||
symbol = "foo"
|
||||
|
||||
self.mapping.change(key, target, symbol)
|
||||
with patch.object(self.mapping, "clear", clear):
|
||||
self.mapping.change(key, target, symbol)
|
||||
self.mapping.change(key, target, symbol, previous_key=key)
|
||||
|
||||
def test_combinations(self):
|
||||
ev_1 = Key(EV_KEY, 1, 111)
|
||||
ev_2 = Key(EV_KEY, 1, 222)
|
||||
ev_3 = Key(EV_KEY, 2, 111)
|
||||
ev_4 = Key(EV_ABS, 1, 111)
|
||||
combi_1 = Key(ev_1, ev_2, ev_3)
|
||||
combi_2 = Key(ev_2, ev_1, ev_3)
|
||||
combi_3 = Key(ev_1, ev_2, ev_4)
|
||||
|
||||
self.mapping.change(combi_1, "keyboard", "a")
|
||||
self.assertEqual(self.mapping.get_mapping(combi_1), ("a", "keyboard"))
|
||||
self.assertEqual(self.mapping.get_mapping(combi_2), ("a", "keyboard"))
|
||||
# since combi_1 and combi_2 are equivalent, a changes to b
|
||||
self.mapping.change(combi_2, "keyboard", "b")
|
||||
self.assertEqual(self.mapping.get_mapping(combi_1), ("b", "keyboard"))
|
||||
self.assertEqual(self.mapping.get_mapping(combi_2), ("b", "keyboard"))
|
||||
|
||||
self.mapping.change(combi_3, "keyboard", "c")
|
||||
self.assertEqual(self.mapping.get_mapping(combi_1), ("b", "keyboard"))
|
||||
self.assertEqual(self.mapping.get_mapping(combi_2), ("b", "keyboard"))
|
||||
self.assertEqual(self.mapping.get_mapping(combi_3), ("c", "keyboard"))
|
||||
|
||||
self.mapping.change(combi_3, "keyboard", "c", combi_1)
|
||||
self.assertIsNone(self.mapping.get_mapping(combi_1))
|
||||
self.assertIsNone(self.mapping.get_mapping(combi_2))
|
||||
self.assertEqual(self.mapping.get_mapping(combi_3), ("c", "keyboard"))
|
||||
|
||||
def test_clear(self):
|
||||
# does nothing
|
||||
ev_1 = Key(EV_KEY, 40, 1)
|
||||
ev_2 = Key(EV_KEY, 30, 1)
|
||||
ev_3 = Key(EV_KEY, 20, 1)
|
||||
ev_4 = Key(EV_KEY, 10, 1)
|
||||
|
||||
self.mapping.clear(ev_1)
|
||||
self.assertFalse(self.mapping.has_unsaved_changes())
|
||||
self.assertEqual(len(self.mapping), 0)
|
||||
|
||||
self.mapping._mapping[ev_1] = "b"
|
||||
self.assertEqual(len(self.mapping), 1)
|
||||
self.mapping.clear(ev_1)
|
||||
self.assertEqual(len(self.mapping), 0)
|
||||
self.assertTrue(self.mapping.has_unsaved_changes())
|
||||
|
||||
self.mapping.change(ev_4, "keyboard", "KEY_KP1", None)
|
||||
self.assertTrue(self.mapping.has_unsaved_changes())
|
||||
self.mapping.change(ev_3, "keyboard", "KEY_KP2", None)
|
||||
self.mapping.change(ev_2, "keyboard", "KEY_KP3", None)
|
||||
self.assertEqual(len(self.mapping), 3)
|
||||
self.mapping.clear(ev_3)
|
||||
self.assertEqual(len(self.mapping), 2)
|
||||
self.assertEqual(self.mapping.get_mapping(ev_4), ("KEY_KP1", "keyboard"))
|
||||
self.assertIsNone(self.mapping.get_mapping(ev_3))
|
||||
self.assertEqual(self.mapping.get_mapping(ev_2), ("KEY_KP3", "keyboard"))
|
||||
|
||||
def test_empty(self):
|
||||
self.mapping.change(Key(EV_KEY, 10, 1), "keyboard", "1")
|
||||
self.mapping.change(Key(EV_KEY, 11, 1), "keyboard", "2")
|
||||
self.mapping.change(Key(EV_KEY, 12, 1), "keyboard", "3")
|
||||
self.assertEqual(len(self.mapping), 3)
|
||||
self.mapping.empty()
|
||||
self.assertEqual(len(self.mapping), 0)
|
||||
|
||||
def test_dangerously_mapped_btn_left(self):
|
||||
self.mapping.change(Key.btn_left(), "keyboard", "1")
|
||||
self.assertTrue(self.mapping.dangerously_mapped_btn_left())
|
||||
|
||||
self.mapping.change(Key(EV_KEY, 41, 1), "keyboard", "2")
|
||||
self.assertTrue(self.mapping.dangerously_mapped_btn_left())
|
||||
|
||||
self.mapping.change(Key(EV_KEY, 42, 1), "gamepad", "btn_left")
|
||||
self.assertFalse(self.mapping.dangerously_mapped_btn_left())
|
||||
|
||||
self.mapping.change(Key(EV_KEY, 42, 1), "gamepad", "BTN_Left")
|
||||
self.assertFalse(self.mapping.dangerously_mapped_btn_left())
|
||||
|
||||
self.mapping.change(Key(EV_KEY, 42, 1), "keyboard", "3")
|
||||
self.assertTrue(self.mapping.dangerously_mapped_btn_left())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
@ -0,0 +1,361 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import os
|
||||
import unittest
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from evdev.ecodes import EV_KEY, EV_ABS, KEY_A
|
||||
from inputremapper.input_event import InputEvent
|
||||
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.configs.system_mapping import SystemMapping, XMODMAP_FILENAME
|
||||
from inputremapper.configs.global_config import global_config
|
||||
from inputremapper.configs.paths import get_preset_path
|
||||
from inputremapper.event_combination import EventCombination
|
||||
|
||||
from tests.test import tmp, quick_cleanup
|
||||
|
||||
|
||||
class TestSystemMapping(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
quick_cleanup()
|
||||
|
||||
def test_update(self):
|
||||
system_mapping = SystemMapping()
|
||||
system_mapping.update({"foo1": 101, "bar1": 102})
|
||||
system_mapping.update({"foo2": 201, "bar2": 202})
|
||||
self.assertEqual(system_mapping.get("foo1"), 101)
|
||||
self.assertEqual(system_mapping.get("bar2"), 202)
|
||||
|
||||
def test_xmodmap_file(self):
|
||||
system_mapping = SystemMapping()
|
||||
path = os.path.join(tmp, XMODMAP_FILENAME)
|
||||
os.remove(path)
|
||||
|
||||
system_mapping.populate()
|
||||
self.assertTrue(os.path.exists(path))
|
||||
with open(path, "r") as file:
|
||||
content = json.load(file)
|
||||
self.assertEqual(content["a"], KEY_A)
|
||||
# only xmodmap stuff should be present
|
||||
self.assertNotIn("key_a", content)
|
||||
self.assertNotIn("KEY_A", content)
|
||||
self.assertNotIn("disable", content)
|
||||
|
||||
def test_correct_case(self):
|
||||
system_mapping = SystemMapping()
|
||||
system_mapping.clear()
|
||||
system_mapping._set("A", 31)
|
||||
system_mapping._set("a", 32)
|
||||
system_mapping._set("abcd_B", 33)
|
||||
|
||||
self.assertEqual(system_mapping.correct_case("a"), "a")
|
||||
self.assertEqual(system_mapping.correct_case("A"), "A")
|
||||
self.assertEqual(system_mapping.correct_case("ABCD_b"), "abcd_B")
|
||||
# unknown stuff is returned as is
|
||||
self.assertEqual(system_mapping.correct_case("FOo"), "FOo")
|
||||
|
||||
self.assertEqual(system_mapping.get("A"), 31)
|
||||
self.assertEqual(system_mapping.get("a"), 32)
|
||||
self.assertEqual(system_mapping.get("ABCD_b"), 33)
|
||||
self.assertEqual(system_mapping.get("abcd_B"), 33)
|
||||
|
||||
def test_system_mapping(self):
|
||||
system_mapping = SystemMapping()
|
||||
system_mapping.populate()
|
||||
self.assertGreater(len(system_mapping._mapping), 100)
|
||||
|
||||
# this is case-insensitive
|
||||
self.assertEqual(system_mapping.get("1"), 2)
|
||||
self.assertEqual(system_mapping.get("KeY_1"), 2)
|
||||
|
||||
self.assertEqual(system_mapping.get("AlT_L"), 56)
|
||||
self.assertEqual(system_mapping.get("KEy_LEFtALT"), 56)
|
||||
|
||||
self.assertEqual(system_mapping.get("kEY_LeFTSHIFT"), 42)
|
||||
self.assertEqual(system_mapping.get("ShiFt_L"), 42)
|
||||
|
||||
self.assertEqual(system_mapping.get("BTN_left"), 272)
|
||||
|
||||
self.assertIsNotNone(system_mapping.get("KEY_KP4"))
|
||||
self.assertEqual(system_mapping.get("KP_Left"), system_mapping.get("KEY_KP4"))
|
||||
|
||||
# this only lists the correct casing,
|
||||
# includes linux constants and xmodmap symbols
|
||||
names = system_mapping.list_names()
|
||||
self.assertIn("2", names)
|
||||
self.assertIn("c", names)
|
||||
self.assertIn("KEY_3", names)
|
||||
self.assertNotIn("key_3", names)
|
||||
self.assertIn("KP_Down", names)
|
||||
self.assertNotIn("kp_down", names)
|
||||
names = system_mapping._mapping.keys()
|
||||
self.assertIn("F4", names)
|
||||
self.assertNotIn("f4", names)
|
||||
self.assertIn("BTN_RIGHT", names)
|
||||
self.assertNotIn("btn_right", names)
|
||||
self.assertIn("KEY_KP7", names)
|
||||
self.assertIn("KP_Home", names)
|
||||
self.assertNotIn("kp_home", names)
|
||||
|
||||
self.assertEqual(system_mapping.get("disable"), -1)
|
||||
|
||||
|
||||
class TestMapping(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.preset = Preset()
|
||||
self.assertFalse(self.preset.has_unsaved_changes())
|
||||
|
||||
def tearDown(self):
|
||||
quick_cleanup()
|
||||
|
||||
def test_config(self):
|
||||
self.preset.save(get_preset_path("foo", "bar2"))
|
||||
|
||||
self.assertEqual(self.preset.get("a"), None)
|
||||
|
||||
self.assertFalse(self.preset.has_unsaved_changes())
|
||||
|
||||
self.preset.set("a", 1)
|
||||
self.assertEqual(self.preset.get("a"), 1)
|
||||
self.assertTrue(self.preset.has_unsaved_changes())
|
||||
|
||||
self.preset.remove("a")
|
||||
self.preset.set("a.b", 2)
|
||||
self.assertEqual(self.preset.get("a.b"), 2)
|
||||
self.assertEqual(self.preset._config["a"]["b"], 2)
|
||||
|
||||
self.preset.remove("a.b")
|
||||
self.preset.set("a.b.c", 3)
|
||||
self.assertEqual(self.preset.get("a.b.c"), 3)
|
||||
self.assertEqual(self.preset._config["a"]["b"]["c"], 3)
|
||||
|
||||
# setting preset.whatever does not overwrite the preset
|
||||
# after saving. It should be ignored.
|
||||
self.preset.change(EventCombination([EV_KEY, 81, 1]), "keyboard", " a ")
|
||||
self.preset.set("mapping.a", 2)
|
||||
self.assertEqual(self.preset.num_saved_keys, 0)
|
||||
self.preset.save(get_preset_path("foo", "bar"))
|
||||
self.assertEqual(self.preset.num_saved_keys, len(self.preset))
|
||||
self.assertFalse(self.preset.has_unsaved_changes())
|
||||
self.preset.load(get_preset_path("foo", "bar"))
|
||||
self.assertEqual(
|
||||
self.preset.get_mapping(EventCombination([EV_KEY, 81, 1])),
|
||||
("a", "keyboard"),
|
||||
)
|
||||
self.assertIsNone(self.preset.get("mapping.a"))
|
||||
self.assertFalse(self.preset.has_unsaved_changes())
|
||||
|
||||
# loading a different preset also removes the configs from memory
|
||||
self.preset.remove("a")
|
||||
self.assertTrue(self.preset.has_unsaved_changes())
|
||||
self.preset.set("a.b.c", 6)
|
||||
self.preset.load(get_preset_path("foo", "bar2"))
|
||||
self.assertIsNone(self.preset.get("a.b.c"))
|
||||
|
||||
def test_fallback(self):
|
||||
global_config.set("d.e.f", 5)
|
||||
self.assertEqual(self.preset.get("d.e.f"), 5)
|
||||
self.preset.set("d.e.f", 3)
|
||||
self.assertEqual(self.preset.get("d.e.f"), 3)
|
||||
|
||||
def test_save_load(self):
|
||||
one = InputEvent.from_tuple((EV_KEY, 10, 1))
|
||||
two = InputEvent.from_tuple((EV_KEY, 11, 1))
|
||||
three = InputEvent.from_tuple((EV_KEY, 12, 1))
|
||||
|
||||
self.preset.change(EventCombination(one), "keyboard", "1")
|
||||
self.preset.change(EventCombination(two), "keyboard", "2")
|
||||
self.preset.change(EventCombination(two, three), "keyboard", "3")
|
||||
self.preset._config["foo"] = "bar"
|
||||
self.preset.save(get_preset_path("Foo Device", "test"))
|
||||
|
||||
path = os.path.join(tmp, "presets", "Foo Device", "test.json")
|
||||
self.assertTrue(os.path.exists(path))
|
||||
|
||||
loaded = Preset()
|
||||
self.assertEqual(len(loaded), 0)
|
||||
loaded.load(get_preset_path("Foo Device", "test"))
|
||||
|
||||
self.assertEqual(len(loaded), 3)
|
||||
self.assertRaises(TypeError, loaded.get_mapping, one)
|
||||
self.assertEqual(loaded.get_mapping(EventCombination(one)), ("1", "keyboard"))
|
||||
self.assertEqual(loaded.get_mapping(EventCombination(two)), ("2", "keyboard"))
|
||||
self.assertEqual(
|
||||
loaded.get_mapping(EventCombination(two, three)), ("3", "keyboard")
|
||||
)
|
||||
self.assertEqual(loaded._config["foo"], "bar")
|
||||
|
||||
def test_change(self):
|
||||
# the reader would not report values like 111 or 222, only 1 or -1.
|
||||
# the preset just does what it is told, so it accepts them.
|
||||
ev_1 = EventCombination((EV_KEY, 1, 111))
|
||||
ev_2 = EventCombination((EV_KEY, 1, 222))
|
||||
ev_3 = EventCombination((EV_KEY, 2, 111))
|
||||
ev_4 = EventCombination((EV_ABS, 1, 111))
|
||||
|
||||
self.assertRaises(
|
||||
TypeError, self.preset.change, [(EV_KEY, 10, 1), "keyboard", "a", ev_2]
|
||||
)
|
||||
self.assertRaises(
|
||||
TypeError, self.preset.change, [ev_1, "keyboard", "a", (EV_KEY, 1, 222)]
|
||||
)
|
||||
|
||||
# 1 is not assigned yet, ignore it
|
||||
self.preset.change(ev_1, "keyboard", "a", ev_2)
|
||||
self.assertTrue(self.preset.has_unsaved_changes())
|
||||
self.assertIsNone(self.preset.get_mapping(ev_2))
|
||||
self.assertEqual(self.preset.get_mapping(ev_1), ("a", "keyboard"))
|
||||
self.assertEqual(len(self.preset), 1)
|
||||
|
||||
# change ev_1 to ev_3 and change a to b
|
||||
self.preset.change(ev_3, "keyboard", "b", ev_1)
|
||||
self.assertIsNone(self.preset.get_mapping(ev_1))
|
||||
self.assertEqual(self.preset.get_mapping(ev_3), ("b", "keyboard"))
|
||||
self.assertEqual(len(self.preset), 1)
|
||||
|
||||
# add 4
|
||||
self.preset.change(ev_4, "keyboard", "c", None)
|
||||
self.assertEqual(self.preset.get_mapping(ev_3), ("b", "keyboard"))
|
||||
self.assertEqual(self.preset.get_mapping(ev_4), ("c", "keyboard"))
|
||||
self.assertEqual(len(self.preset), 2)
|
||||
|
||||
# change the preset of 4 to d
|
||||
self.preset.change(ev_4, "keyboard", "d", None)
|
||||
self.assertEqual(self.preset.get_mapping(ev_4), ("d", "keyboard"))
|
||||
self.assertEqual(len(self.preset), 2)
|
||||
|
||||
# this also works in the same way
|
||||
self.preset.change(ev_4, "keyboard", "e", ev_4)
|
||||
self.assertEqual(self.preset.get_mapping(ev_4), ("e", "keyboard"))
|
||||
self.assertEqual(len(self.preset), 2)
|
||||
|
||||
self.assertEqual(self.preset.num_saved_keys, 0)
|
||||
|
||||
def test_rejects_empty(self):
|
||||
key = EventCombination([EV_KEY, 1, 111])
|
||||
self.assertEqual(len(self.preset), 0)
|
||||
self.assertRaises(
|
||||
ValueError, lambda: self.preset.change(key, "keyboard", " \n ")
|
||||
)
|
||||
self.assertRaises(ValueError, lambda: self.preset.change(key, " \n ", "b"))
|
||||
self.assertEqual(len(self.preset), 0)
|
||||
|
||||
def test_avoids_redundant_changes(self):
|
||||
# to avoid logs that don't add any value
|
||||
def clear(*_):
|
||||
# should not be called
|
||||
raise AssertionError
|
||||
|
||||
key = EventCombination([EV_KEY, 987, 1])
|
||||
target = "keyboard"
|
||||
symbol = "foo"
|
||||
|
||||
self.preset.change(key, target, symbol)
|
||||
with patch.object(self.preset, "clear", clear):
|
||||
self.preset.change(key, target, symbol)
|
||||
self.preset.change(key, target, symbol, previous_combination=key)
|
||||
|
||||
def test_combinations(self):
|
||||
ev_1 = InputEvent.from_tuple((EV_KEY, 1, 111))
|
||||
ev_2 = InputEvent.from_tuple((EV_KEY, 1, 222))
|
||||
ev_3 = InputEvent.from_tuple((EV_KEY, 2, 111))
|
||||
ev_4 = InputEvent.from_tuple((EV_ABS, 1, 111))
|
||||
combi_1 = EventCombination(ev_1, ev_2, ev_3)
|
||||
combi_2 = EventCombination(ev_2, ev_1, ev_3)
|
||||
combi_3 = EventCombination(ev_1, ev_2, ev_4)
|
||||
|
||||
self.preset.change(combi_1, "keyboard", "a")
|
||||
self.assertEqual(self.preset.get_mapping(combi_1), ("a", "keyboard"))
|
||||
self.assertEqual(self.preset.get_mapping(combi_2), ("a", "keyboard"))
|
||||
# since combi_1 and combi_2 are equivalent, a changes to b
|
||||
self.preset.change(combi_2, "keyboard", "b")
|
||||
self.assertEqual(self.preset.get_mapping(combi_1), ("b", "keyboard"))
|
||||
self.assertEqual(self.preset.get_mapping(combi_2), ("b", "keyboard"))
|
||||
|
||||
self.preset.change(combi_3, "keyboard", "c")
|
||||
self.assertEqual(self.preset.get_mapping(combi_1), ("b", "keyboard"))
|
||||
self.assertEqual(self.preset.get_mapping(combi_2), ("b", "keyboard"))
|
||||
self.assertEqual(self.preset.get_mapping(combi_3), ("c", "keyboard"))
|
||||
|
||||
self.preset.change(combi_3, "keyboard", "c", combi_1)
|
||||
self.assertIsNone(self.preset.get_mapping(combi_1))
|
||||
self.assertIsNone(self.preset.get_mapping(combi_2))
|
||||
self.assertEqual(self.preset.get_mapping(combi_3), ("c", "keyboard"))
|
||||
|
||||
def test_clear(self):
|
||||
# does nothing
|
||||
ev_1 = EventCombination((EV_KEY, 40, 1))
|
||||
ev_2 = EventCombination((EV_KEY, 30, 1))
|
||||
ev_3 = EventCombination((EV_KEY, 20, 1))
|
||||
ev_4 = EventCombination((EV_KEY, 10, 1))
|
||||
|
||||
self.assertRaises(TypeError, self.preset.clear, (EV_KEY, 10, 1))
|
||||
self.preset.clear(ev_1)
|
||||
self.assertFalse(self.preset.has_unsaved_changes())
|
||||
self.assertEqual(len(self.preset), 0)
|
||||
|
||||
self.preset._mapping[ev_1] = "b"
|
||||
self.assertEqual(len(self.preset), 1)
|
||||
self.preset.clear(ev_1)
|
||||
self.assertEqual(len(self.preset), 0)
|
||||
self.assertTrue(self.preset.has_unsaved_changes())
|
||||
|
||||
self.preset.change(ev_4, "keyboard", "KEY_KP1", None)
|
||||
self.assertTrue(self.preset.has_unsaved_changes())
|
||||
self.preset.change(ev_3, "keyboard", "KEY_KP2", None)
|
||||
self.preset.change(ev_2, "keyboard", "KEY_KP3", None)
|
||||
self.assertEqual(len(self.preset), 3)
|
||||
self.preset.clear(ev_3)
|
||||
self.assertEqual(len(self.preset), 2)
|
||||
self.assertEqual(self.preset.get_mapping(ev_4), ("KEY_KP1", "keyboard"))
|
||||
self.assertIsNone(self.preset.get_mapping(ev_3))
|
||||
self.assertEqual(self.preset.get_mapping(ev_2), ("KEY_KP3", "keyboard"))
|
||||
|
||||
def test_empty(self):
|
||||
self.preset.change(EventCombination([EV_KEY, 10, 1]), "keyboard", "1")
|
||||
self.preset.change(EventCombination([EV_KEY, 11, 1]), "keyboard", "2")
|
||||
self.preset.change(EventCombination([EV_KEY, 12, 1]), "keyboard", "3")
|
||||
self.assertEqual(len(self.preset), 3)
|
||||
self.preset.empty()
|
||||
self.assertEqual(len(self.preset), 0)
|
||||
|
||||
def test_dangerously_mapped_btn_left(self):
|
||||
self.preset.change(EventCombination(InputEvent.btn_left()), "keyboard", "1")
|
||||
self.assertTrue(self.preset.dangerously_mapped_btn_left())
|
||||
|
||||
self.preset.change(EventCombination([EV_KEY, 41, 1]), "keyboard", "2")
|
||||
self.assertTrue(self.preset.dangerously_mapped_btn_left())
|
||||
|
||||
self.preset.change(EventCombination([EV_KEY, 42, 1]), "gamepad", "btn_left")
|
||||
self.assertFalse(self.preset.dangerously_mapped_btn_left())
|
||||
|
||||
self.preset.change(EventCombination([EV_KEY, 42, 1]), "gamepad", "BTN_Left")
|
||||
self.assertFalse(self.preset.dangerously_mapped_btn_left())
|
||||
|
||||
self.preset.change(EventCombination([EV_KEY, 42, 1]), "keyboard", "3")
|
||||
self.assertTrue(self.preset.dangerously_mapped_btn_left())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
Loading…
Reference in New Issue