Refactored injection (#263)

pull/376/head
jonasBoss 2 years ago committed by GitHub
parent 4f03a7b484
commit 1a2b2d7076
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -26,7 +26,7 @@ jobs:
shell: bash
run: |
scripts/ci-install-deps.sh
pip install flake8 pylint mypy black
pip install flake8 pylint mypy black types-pkg_resources
- name: Set env for PR
if: github.event_name == 'pull_request'
shell: bash

4
.gitignore vendored

@ -4,6 +4,7 @@ inputremapper/commit_hash.py
*.glade#
.idea
*.png~
*.orig
# Byte-compiled / optimized / DLL files
__pycache__/
@ -58,6 +59,9 @@ coverage.xml
.hypothesis/
.pytest_cache/
# pyreverse graphs
*.dot
# Translations
*.mo

@ -0,0 +1,7 @@
[mypy]
plugins = pydantic.mypy
# ignore the missing evdev stubs
[mypy-evdev.*]
ignore_missing_imports = True

@ -2,7 +2,8 @@
max-line-length=88 # black
extension-pkg-whitelist=evdev
extension-pkg-whitelist=evdev, pydantic
load-plugins=pylint_pydantic
disable=
# that is the standard way to import GTK afaik

@ -0,0 +1,16 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Only Integration Tests" type="tests" factoryName="Autodetect">
<module name="input-remapper" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/tests" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="_new_additionalArguments" value="&quot;--start-dir integration&quot;" />
<option name="_new_target" value="&quot;$PROJECT_DIR$/tests&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method v="2" />
</configuration>
</component>

@ -0,0 +1,16 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Only Unit Tests" type="tests" factoryName="Autodetect">
<module name="input-remapper" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/tests" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="_new_additionalArguments" value="&quot;--start-dir unit&quot;" />
<option name="_new_target" value="&quot;$PROJECT_DIR$/tests&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method v="2" />
</configuration>
</component>

@ -1,5 +1,5 @@
Package: input-remapper
Version: 1.4.2
Version: 1.5b1
Architecture: all
Maintainer: Sezanzeb <proxima@sezanzeb.de>
Depends: build-essential, libpython3-dev, libdbus-1-dev, python3, python3-setuptools, python3-evdev, python3-pydbus, python3-gi, gettext, python3-cairo, libgtk-3-0, libgtksourceview-4-dev, python3-pydantic

@ -1,6 +1,6 @@
<p align="center"><img src="data/input-remapper.svg" width=100/></p>
<h1 align="center">Input Remapper</h1>
<h1 align="center">Input Remapper (Beta)</h1>
<p align="center"><b>Formerly Key Mapper</b></p>
@ -14,6 +14,13 @@
<p align="center"><img src="readme/pylint.svg"/> <img src="readme/coverage.svg"/></p>
#### Known Issues (Beta Branch)
* The GUI is currently is not adapted to reflect all new features and might behave strange.
Mapping a gamepad to mouse is currently not possible in the GUI.
Also mapping joystick or mouse axis to buttons might not work.
Those are only limitations of the GUI, when editing the preset manually all those features are still available.
## Installation
##### Manjaro/Arch
@ -31,7 +38,7 @@ or install the latest changes via:
sudo apt install git python3-setuptools gettext
git clone https://github.com/sezanzeb/input-remapper.git
cd input-remapper && ./scripts/build.sh
sudo apt install ./dist/input-remapper-1.4.2.deb
sudo apt install ./dist/input-remapper-1.5b1.deb
```
input-remapper is now part of [Debian Unstable](https://packages.debian.org/sid/input-remapper)

File diff suppressed because it is too large Load Diff

@ -78,4 +78,9 @@ list button {
padding-left: 18px;
}
.no-v-padding{
padding-top: 0;
padding-bottom: 0;
}
/* @theme_bg_color, @theme_fg_color */

@ -27,25 +27,6 @@ NONE = "none"
INITIAL_CONFIG = {
"version": VERSION,
"autoload": {},
"macros": {
# some time between keystrokes might be required for them to be
# detected properly in software.
"keystroke_sleep_ms": 10
},
"gamepad": {
"joystick": {
# very small movements of the joystick should result in very
# small mouse movements. With a non_linearity of 1 it is
# impossible/hard to even find a resting position that won't
# move the cursor.
"non_linearity": 4,
"pointer_speed": 80,
"left_purpose": NONE,
"right_purpose": NONE,
"x_scroll_speed": 2,
"y_scroll_speed": 0.5,
},
},
}

@ -0,0 +1,408 @@
#!/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 enum
from evdev.ecodes import EV_KEY, EV_ABS, EV_REL
from pydantic import (
BaseModel,
PositiveInt,
confloat,
root_validator,
validator,
ValidationError,
PositiveFloat,
VERSION,
)
from typing import Optional, Callable, Tuple, Dict, Union, Any
import pkg_resources
from inputremapper.event_combination import EventCombination
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.exceptions import MacroParsingError
from inputremapper.injection.macros.parse import is_this_a_macro, parse
from inputremapper.input_event import EventActions
# TODO: remove pydantic VERSION check as soon as we no longer support
# Ubuntu 20.04 and with it the ainchant pydantic 1.2
needs_workaround = pkg_resources.parse_version(
str(VERSION)
) < pkg_resources.parse_version("1.7.1")
# TODO: in python 3.11 inherit enum.StrEnum
class KnownUinput(str, enum.Enum):
keyboard = "keyboard"
mouse = "mouse"
gamepad = "gamepad"
CombinationChangedCallback = Optional[
Callable[[EventCombination, EventCombination], None]
]
class Mapping(BaseModel):
"""
holds all the data for mapping an
input action to an output action
"""
if needs_workaround:
__slots__ = ("_combination_changed",)
# Required attributes
# The InputEvent or InputEvent combination which is mapped
event_combination: EventCombination
target_uinput: KnownUinput # The UInput to which the mapped event will be sent
# Either `output_symbol` or `output_type` and `output_code` is required
output_symbol: Optional[str] = None # The symbol or macro string if applicable
output_type: Optional[int] = None # The event type of the mapped event
output_code: Optional[int] = None # The event code of the mapped event
# 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: PositiveInt = 20
# 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
rate: PositiveInt = 60 # The frequency [Hz] at which EV_REL events get generated
# the base speed of the relative axis, compounds with the gain
rel_speed: PositiveInt = 100
# when mapping from relative axis:
# the absolute value at which a EV_REL axis is considered at its maximum
rel_input_cutoff: PositiveInt = 100
# the time until a relative axis is considered stationary if no new events arrive
release_timeout: PositiveFloat = 0.05
# callback which gets called if the event_combination is updated
if not needs_workaround:
_combination_changed: 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, value):
"""
call the combination changed callback
if we are about to update the event_combination
"""
if key != "event_combination" or self._combination_changed is None:
if key == "_combination_changed" and needs_workaround:
object.__setattr__(self, "_combination_changed", value)
return
super(Mapping, self).__setattr__(key, value)
return
# the new combination is not yet validated
try:
new_combi = EventCombination.validate(value)
except ValueError:
raise ValidationError(
f"failed to Validate {value} as EventCombination", Mapping
)
if new_combi == self.event_combination:
return
# raises a keyError if the combination or a permutation is already mapped
self._combination_changed(new_combi, self.event_combination)
super(Mapping, self).__setattr__(key, value)
def __str__(self):
return str(self.dict(exclude_defaults=True))
if needs_workaround:
def copy(self, *args, **kwargs) -> Mapping:
copy = super(Mapping, self).copy(*args, **kwargs)
object.__setattr__(copy, "_combination_changed", self._combination_changed)
return copy
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 system_mapping
return None for unknown symbols and macros
"""
if self.output_code and self.output_type:
return self.output_type, self.output_code
if not is_this_a_macro(self.output_symbol):
return EV_KEY, system_mapping.get(self.output_symbol)
return None
def is_valid(self) -> bool:
"""if the mapping is valid"""
return True
@validator("output_symbol", pre=True)
def validate_symbol(cls, symbol):
if not symbol:
return None
if is_this_a_macro(symbol):
try:
parse(symbol) # raises MacroParsingError
return symbol
except MacroParsingError as e:
raise ValueError(
e
) # pydantic only catches ValueError, TypeError, and AssertionError
if system_mapping.get(symbol) is not None:
return symbol
raise ValueError(
f"the output_symbol '{symbol}' is not a macro and not a valid keycode-name"
)
@validator("event_combination")
def only_one_analog_input(cls, combination) -> EventCombination:
"""
check that the event_combination specifies a maximum of one
analog to analog mapping
"""
# any event with a value of 0 is considered an analog input (even key events)
# any event with a non-zero value is considered a binary input
analog_events = [event for event in combination if event.value == 0]
if len(analog_events) > 1:
raise ValueError(
f"cannot map a combination of multiple analog inputs: {analog_events}"
f"add trigger points (event.value != 0) to map as a button"
)
return combination
@validator("event_combination")
def trigger_point_in_range(cls, combination) -> EventCombination:
"""
check if the trigger point for mapping analog axis to buttons is valid
"""
for event in combination:
if event.type == EV_ABS and abs(event.value) >= 100:
raise ValueError(
f"{event = } maps a absolute axis to a button, but the trigger "
f"point (event.value) is not between -100[%] and 100[%]"
)
return combination
@validator("event_combination")
def set_event_actions(cls, combination):
"""sets the correct action for each event"""
new_combination = []
for event in combination:
if event.value != 0:
event = event.modify(action=EventActions.as_key)
new_combination.append(event)
return EventCombination(new_combination)
@root_validator
def contains_output(cls, values):
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 ValueError(
"missing Argument: Mapping must either contain "
"`output_symbol` or `output_type` and `output_code`"
)
return values
@root_validator
def validate_output_integrity(cls, values):
symbol = values.get("output_symbol")
type_ = values.get("output_type")
code = values.get("output_code")
if symbol is None:
return values # type and code can be anything
if type_ is None and code is None:
return values # we have a symbol: no type and code is fine
if is_this_a_macro(symbol): # disallow output type and code for macros
if type_ is not None or code is not None:
raise ValueError(
f"output_symbol is a macro: output_type "
f"and output_code must be None"
)
if code is not None and code != system_mapping.get(symbol) or type_ != EV_KEY:
raise ValueError(
f"output_symbol and output_code mismatch: "
f"output macro is {symbol} --> {system_mapping.get(symbol)} "
f"but output_code is {code} --> {system_mapping.get_name(code)} "
)
return values
@root_validator
def output_axis_given(cls, values):
"""validate that an output type is an axis if we have an input axis"""
combination = values.get("event_combination")
output_type = values.get("output_type")
event_values = [event.value for event in combination]
if 0 not in event_values:
return values
if output_type not in (EV_ABS, EV_REL):
raise ValueError(
f"the {combination = } specifies a input axis, "
f"but the {output_type = } is not an axis "
)
return values
class Config:
validate_assignment = True
use_enum_values = True
underscore_attrs_are_private = True
json_encoders = {EventCombination: lambda v: v.json_str()}
class UIMapping(Mapping):
"""
The UI Mapping adds the ability to create Invalid Mapping objects.
Intended for use in the frontend, where invalid data is allowed
during creation of the mapping. Invalid assignments are cached and
revalidation is attempted as soon as the mapping changes.
"""
_cache: Dict[str, Any] # the invalid mapping data
_last_error: Optional[ValidationError] # the last validation error
# all attributes that __setattr__ will not forward to super() or _cache
ATTRIBUTES = ("_cache", "_last_error")
# use type: ignore, looks like a mypy bug related to:
# https://github.com/samuelcolvin/pydantic/issues/2949
def __init__(self, **data): # type: ignore
object.__setattr__(self, "_last_error", None)
super().__init__(
event_combination="99,99,99",
target_uinput="keyboard",
output_symbol="KEY_A",
)
cache = {
"event_combination": None,
"target_uinput": None,
"output_symbol": None,
}
cache.update(**data)
object.__setattr__(self, "_cache", cache)
self._validate()
def __setattr__(self, key, value):
if key in self.ATTRIBUTES:
object.__setattr__(self, key, value)
return
try:
super(UIMapping, self).__setattr__(key, value)
if key in self._cache:
del self._cache[key]
except ValidationError as error:
# cache the value
self._last_error = error
self._cache[key] = value
# retry the validation
self._validate()
def __getattribute__(self, item):
# intercept any getattribute and prioritize attributes from the cache
try:
return object.__getattribute__(self, "_cache")[item]
except (KeyError, AttributeError):
pass
return object.__getattribute__(self, item)
def is_valid(self) -> bool:
"""if the mapping is valid"""
return len(self._cache) == 0
def dict(self, *args, **kwargs):
"""dict will include the invalid data"""
dict_ = super(UIMapping, self).dict(*args, **kwargs)
# combine all valid values with the invalid ones
dict_.update(**self._cache)
if "ATTRIBUTES" in dict_:
# remove so that super().__eq__ succeeds
# for comparing Mapping with UIMapping
del dict_["ATTRIBUTES"]
if needs_workaround:
if "_last_error" in dict_.keys():
del dict_["_last_error"]
del dict_["_cache"]
return dict_
def get_error(self) -> Optional[ValidationError]:
"""the validation error or None"""
return self._last_error
def _validate(self) -> None:
"""try to validate the mapping"""
if self.is_valid():
return
# preserve the combination_changed callback
callback = self._combination_changed
try:
super(UIMapping, self).__init__(**self.dict(exclude_defaults=True))
self._cache = {}
self._last_error = None
self.set_combination_changed_callback(callback)
return
except ValidationError as error:
self._last_error = error
if (
"event_combination" in self._cache.keys()
and self._cache["event_combination"]
):
# the event_combination needs to be valid
self._cache["event_combination"] = EventCombination.validate(
self._cache["event_combination"]
)

@ -28,10 +28,27 @@ import copy
import shutil
import pkg_resources
from typing import List
from pathlib import Path
from evdev.ecodes import EV_KEY, EV_REL
from inputremapper.logger import logger, VERSION
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 pydantic import ValidationError
from inputremapper.configs.preset import Preset
from inputremapper.configs.mapping import Mapping, UIMapping
from inputremapper.event_combination import EventCombination
from inputremapper.logger import logger, VERSION, IS_BETA
from inputremapper.user import HOME
from inputremapper.configs.paths import get_preset_path, mkdir, CONFIG_PATH
from inputremapper.configs.system_mapping import system_mapping
@ -39,7 +56,7 @@ from inputremapper.injection.global_uinputs import global_uinputs
from inputremapper.injection.macros.parse import parse, is_this_a_macro
def all_presets():
def all_presets() -> List[os.PathLike]:
"""Get all presets for all groups as list."""
if not os.path.exists(get_preset_path()):
return []
@ -144,12 +161,12 @@ def _update_version():
json.dump(config, file, indent=4)
def _rename_config():
def _rename_config(new_path=CONFIG_PATH):
"""Rename .config/key-mapper to .config/input-remapper."""
old_config_path = os.path.join(HOME, ".config/key-mapper")
if not os.path.exists(CONFIG_PATH) and os.path.exists(old_config_path):
logger.info("Moving %s to %s", old_config_path, CONFIG_PATH)
shutil.move(old_config_path, CONFIG_PATH)
if not os.path.exists(new_path) and os.path.exists(old_config_path):
logger.info("Moving %s to %s", old_config_path, new_path)
shutil.move(old_config_path, new_path)
def _find_target(symbol):
@ -244,8 +261,148 @@ def _otherwise_to_else():
file.write("\n")
def _convert_to_individual_mappings():
"""
convert preset.json
from {key: [symbol, target]}
to {key: {target: target, symbol: symbol, ...}}
"""
for preset_path, old_preset in all_presets():
preset = Preset(preset_path, UIMapping)
if "mapping" in old_preset.keys():
for combination, symbol_target in old_preset["mapping"].items():
logger.info(
f"migrating from '{combination}: {symbol_target}' to mapping dict"
)
try:
combination = EventCombination.from_string(combination)
except ValueError:
logger.error(
f"unable to migrate mapping with invalid {combination = }"
)
continue
mapping = UIMapping(
event_combination=combination,
target_uinput=symbol_target[1],
output_symbol=symbol_target[0],
)
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")
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 = {
"event_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["event_combination"] = ",".join((str(EV_ABS), str(ABS_X), "0"))
y_config["event_combination"] = ",".join((str(EV_ABS), str(ABS_Y), "0"))
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
preset.add(mapping_x)
preset.add(mapping_y)
if right_purpose == "mouse":
x_config = cfg.copy()
y_config = cfg.copy()
x_config["event_combination"] = ",".join(
(str(EV_ABS), str(ABS_RX), "0")
)
y_config["event_combination"] = ",".join(
(str(EV_ABS), str(ABS_RY), "0")
)
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
preset.add(mapping_x)
preset.add(mapping_y)
if left_purpose == "wheel":
x_config = cfg.copy()
y_config = cfg.copy()
x_config["event_combination"] = ",".join((str(EV_ABS), str(ABS_X), "0"))
y_config["event_combination"] = ",".join((str(EV_ABS), str(ABS_Y), "0"))
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
preset.add(mapping_x)
preset.add(mapping_y)
if right_purpose == "wheel":
x_config = cfg.copy()
y_config = cfg.copy()
x_config["event_combination"] = ",".join(
(str(EV_ABS), str(ABS_RX), "0")
)
y_config["event_combination"] = ",".join(
(str(EV_ABS), str(ABS_RY), "0")
)
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
preset.add(mapping_x)
preset.add(mapping_y)
preset.save()
def _copy_to_beta():
if os.path.exists(CONFIG_PATH) or not IS_BETA:
# don't copy to already existing folder
# users should delete the beta folder if they need to
return
regular_path = os.path.join(*os.path.split(CONFIG_PATH)[:-1])
# workaround to maker sure the rename from key-mapper to input-remapper
# does not move everythig to the beta folder
_rename_config(regular_path)
if os.path.exists(regular_path):
logger.debug(f"copying all from {regular_path} to {CONFIG_PATH}")
shutil.copytree(regular_path, CONFIG_PATH)
def migrate():
"""Migrate config files to the current release."""
_copy_to_beta()
v = config_version()
if v < pkg_resources.parse_version("0.4.0"):
_config_suffix()
@ -264,6 +421,9 @@ def migrate():
if v < pkg_resources.parse_version("1.4.1"):
_otherwise_to_else()
if v < pkg_resources.parse_version("1.5b1"):
_convert_to_individual_mappings()
# add new migrations here
if v < pkg_resources.parse_version(VERSION):

@ -18,6 +18,7 @@
# 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."""
@ -25,8 +26,13 @@
import os
import shutil
from inputremapper.logger import logger
from inputremapper.user import USER, CONFIG_PATH
from inputremapper.logger import logger, VERSION, IS_BETA
from inputremapper.user import USER, HOME
rel_path = ".config/input-remapper"
if IS_BETA:
rel_path = os.path.join(rel_path, f"beta_{VERSION}")
CONFIG_PATH = os.path.join(HOME, rel_path)
def chown(path):

@ -27,244 +27,261 @@ import json
import glob
import time
from typing import Tuple, Dict, List
from evdev.ecodes import EV_KEY, BTN_LEFT
from typing import Tuple, Dict, List, Optional, Iterator, Type, Iterable, Any, Union
from pydantic import ValidationError
from inputremapper.logger import logger
from inputremapper.configs.mapping import Mapping
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.input_event import InputEvent
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 common_data(list1: Iterable, list2: Iterable) -> List:
"""return common members of two iterables as list"""
# traverse in the 1st list
common = []
for x in list1:
# traverse in the 2nd list
for y in list2:
# if one common
if x == y:
common.append(x)
return common
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
class Preset:
"""Contains and manages mappings of a single preset."""
def has_unsaved_changes(self):
_mappings: Dict[EventCombination, Mapping]
# a copy of mappings for keeping track of changes
_saved_mappings: Dict[EventCombination, Mapping]
_path: Optional[os.PathLike]
_mapping_factpry: Type[Mapping] # the mapping class which is used by load()
def __init__(
self,
path: Optional[os.PathLike] = None,
mapping_factory: Type[Mapping] = Mapping,
) -> None:
self._mappings = {}
self._saved_mappings = {}
self._path = path
self._mapping_factory = mapping_factory
def __iter__(self) -> Iterator[Mapping]:
"""Iterate over Mapping objects."""
return iter(self._mappings.values())
def __len__(self) -> int:
return len(self._mappings)
def has_unsaved_changes(self) -> bool:
"""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
return self._mappings != self._saved_mappings
def clear(self, combination):
"""Remove a keycode from the preset.
def remove(self, combination: EventCombination) -> None:
"""remove a mapping from the preset by providing the EventCombination"""
Parameters
----------
combination : EventCombination
"""
if not isinstance(combination, EventCombination):
raise TypeError(
f"Expected combination to be a EventCombination object but got {combination}"
f"combination must by of type EventCombination, got {type(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.
if permutation in self._mappings.keys():
combination = permutation
break
try:
mapping = self._mappings.pop(combination)
mapping.remove_combination_changed_callback()
except KeyError:
logger.debug(f"unable to remove non-existing mapping with {combination = }")
pass
def add(self, mapping: Mapping) -> None:
"""add a mapping to the preset"""
for permutation in mapping.event_combination.get_permutations():
if permutation in self._mappings:
raise KeyError(
"a mapping with this event_combination: %s already exists",
permutation,
)
Parameters
path : string
Path of the preset file
"""
logger.info('Loading preset from "%s"', path)
mapping.set_combination_changed_callback(self._combination_changed_callback)
self._mappings[mapping.event_combination] = mapping
if not os.path.exists(path):
raise FileNotFoundError(f'Tried to load non-existing preset "{path}"')
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._changed = False
self._saved_mappings = {}
self.path = None
with open(path, "r") as file:
preset_dict = json.load(file)
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 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
if not self.path or not os.path.exists(self.path):
raise FileNotFoundError(f'Tried to load non-existing preset "{self.path}"')
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
self._saved_mappings = self._get_mappings_from_disc()
self.empty()
for mapping in self._saved_mappings.values():
# use the external add method to make sure
# the _combination_changed_callback is attached
self.add(mapping.copy())
# add any metadata of the preset
for key in preset_dict:
if key == "mapping":
continue
self._config[key] = preset_dict[key]
def save(self) -> None:
"""Dump as JSON to self.path"""
self._changed = False
self.num_saved_keys = len(self)
if not self.path:
logger.debug("unable to save preset without a path set Preset.path first")
return
def save(self, path):
"""Dump as JSON into home."""
logger.info("Saving preset to %s", path)
touch(str(self.path)) # touch expects a string, not a Posix path
if not self.has_unsaved_changes():
return
touch(path)
logger.info("Saving preset to %s", self.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"),
json_ready = {}
saved_mappings = {}
for mapping in self:
if not mapping.is_valid():
if not isinstance(mapping.event_combination, EventCombination):
# we save invalid mapping except for those with
# invalid event_combination
logger.debug("skipping invalid mapping %s", mapping)
continue
combinations = [m.event_combination for m in self]
common = common_data(
mapping.event_combination.get_permutations(), combinations
)
if len(common) > 1:
logger.debug(
"skipping mapping with duplicate event combination %s", mapping
)
continue
preset_dict = self._config.copy() # shallow copy
d = mapping.dict(exclude_defaults=True)
combination = d.pop("event_combination")
json_ready[combination.json_str()] = d
# 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
saved_mappings[combination] = mapping.copy()
saved_mappings[combination].remove_combination_changed_callback()
preset_dict["mapping"] = json_ready_mapping
json.dump(preset_dict, file, indent=4)
with open(self.path, "w") as file:
json.dump(json_ready, file, indent=4)
file.write("\n")
self._changed = False
self.num_saved_keys = len(self)
self._saved_mappings = saved_mappings
def get_mapping(self, combination: EventCombination):
"""Read the (symbol, target)-tuple that is mapped to this keycode.
def is_valid(self) -> bool:
return False not in [mapping.is_valid() for mapping in self]
def get_mapping(self, combination: Optional[EventCombination]) -> Optional[Mapping]:
"""Return the Mapping that is mapped to this EventCombination.
Parameters
----------
combination : EventCombination
"""
if not combination:
return None
if not isinstance(combination, EventCombination):
raise TypeError(
f"Expected combination to be a EventCombination object but got {combination}"
f"combination must by of type EventCombination, got {type(combination)}"
)
for permutation in combination.get_permutations():
existing = self._mapping.get(permutation)
existing = self._mappings.get(permutation)
if existing is not None:
return existing
return None
def dangerously_mapped_btn_left(self):
def dangerously_mapped_btn_left(self) -> bool:
"""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
if EventCombination(InputEvent.btn_left()) not in [
m.event_combination for m in self
]:
return False
values: List[str | Tuple[int, int] | None] = []
for mapping in self:
if mapping.output_symbol is None:
continue
values.append(mapping.output_symbol.lower())
values.append(mapping.get_output_type_code())
print(values)
return (
"btn_left" not in values
or InputEvent.btn_left().type_and_code not in values
)
return False
def _combination_changed_callback(
self, new: EventCombination, old: EventCombination
) -> 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[EventCombination, Mapping]:
mappings: Dict[EventCombination, Mapping] = {}
if not self.path:
logger.debug("unable to read preset without a path set Preset.path first")
return mappings
with open(self.path, "r") as file:
try:
preset_dict = json.load(file)
except json.JSONDecodeError:
logger.error("unable to decode json file: %s", self.path)
return mappings
for combination, mapping_dict in preset_dict.items():
try:
mapping = self._mapping_factory(
event_combination=combination, **mapping_dict
)
except ValidationError as error:
logger.error(
"failed to Validate mapping for %s: %s", combination, error
)
continue
mappings[mapping.event_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()
###########################################################################
@ -330,8 +347,8 @@ def get_any_preset() -> Tuple[str | None, str | None]:
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
any_preset = get_presets(any_device)
return any_device, any_preset[0] if any_preset else None
def find_newest_preset(group_name=None):
@ -400,7 +417,7 @@ def delete_preset(group_name, preset):
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
return old_preset_name
new_preset_name = get_available_preset_name(group_name, new_preset_name)
logger.info('Moving "%s" to "%s"', old_preset_name, new_preset_name)

@ -17,11 +17,8 @@
#
# 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 re
import json
import subprocess
@ -150,7 +147,7 @@ class SystemMapping:
self._mapping[str(name)] = code
self._case_insensitive_mapping[str(name).lower()] = name
def get(self, name):
def get(self, name) -> int:
"""Return the code mapped to the key."""
# the correct casing should be shown when asking the system_mapping
# for stuff. indexing case insensitive to support old presets.

@ -441,24 +441,6 @@ class Daemon:
f"{preset}.json",
)
preset = Preset()
try:
preset.load(preset_path)
except FileNotFoundError as error:
logger.error(str(error))
return False
for event_combination, (symbol, target) in preset:
# only create those uinputs that are required to avoid
# confusing the system. Seems to be especially important with
# gamepads, because some apps treat the first gamepad they found
# as the only gamepad they'll ever care about.
global_uinputs.prepare_single(target)
if self.injectors.get(group_key) is not None:
self.stop_injecting(group_key)
# Path to a dump of the xkb mappings, to provide more human
# readable keys in the correct keyboard layout to the service.
# The service cannot use `xmodmap -pke` because it's running via
@ -476,6 +458,24 @@ class Daemon:
except FileNotFoundError:
logger.error('Could not find "%s"', xmodmap_path)
preset = Preset(preset_path)
try:
preset.load()
except FileNotFoundError as error:
logger.error(str(error))
return False
for mapping in preset:
# only create those uinputs that are required to avoid
# confusing the system. Seems to be especially important with
# gamepads, because some apps treat the first gamepad they found
# as the only gamepad they'll ever care about.
global_uinputs.prepare_single(mapping.target_uinput)
if self.injectors.get(group_key) is not None:
self.stop_injecting(group_key)
try:
injector = Injector(group, preset)
injector.start()

@ -20,17 +20,15 @@
from __future__ import annotations
import itertools
from typing import Tuple, Iterable
import itertools
from typing import Tuple, Iterable, Union, List, Callable, Sequence
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
from inputremapper.input_event import InputEvent, InputEventValidationType
# having shift in combinations modifies the configured output,
# ctrl might not work at all
@ -44,29 +42,33 @@ DIFFICULT_COMBINATIONS = [
]
EventCombinationInitType = Union[
InputEventValidationType,
Iterable[InputEventValidationType],
]
EventCombinationValidatorType = Union[EventCombinationInitType, str]
class EventCombination(Tuple[InputEvent]):
"""one or multiple InputEvent objects for use as an unique identifier for mappings"""
"""
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
def __new__(cls, events: EventCombinationInitType) -> EventCombination:
validated_events = []
try:
validated_events.append(InputEvent.validate(events))
if event:
events.append(event)
else:
raise ValueError(f"failed to create InputEvent with {init_arg = }")
except ValueError:
for event in events:
validated_events.append(InputEvent.validate(event))
return super().__new__(cls, events)
# mypy bug: https://github.com/python/mypy/issues/8957
# https://github.com/python/mypy/issues/8541
return super().__new__(cls, validated_events) # type: ignore
def __str__(self):
# only used in tests and logging
@ -75,26 +77,35 @@ class EventCombination(Tuple[InputEvent]):
@classmethod
def __get_validators__(cls):
"""used by pydantic to create EventCombination objects"""
yield cls.from_string
yield cls.from_events
yield cls.validate
@classmethod
def from_string(cls, init_string: str) -> EventCombination:
init_args = init_string.split("+")
return cls(*init_args)
def validate(cls, init_arg: EventCombinationValidatorType) -> EventCombination:
"""try all the different methods, and raise an error if none succeed"""
if isinstance(init_arg, EventCombination):
return init_arg
combi = None
validators: Sequence[Callable[..., EventCombination]] = (cls.from_string, cls)
for validator in validators:
try:
combi = validator(init_arg)
break
except ValueError:
pass
if combi:
return combi
raise ValueError(f"failed to create EventCombination with {init_arg = }")
@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 from_string(cls, init_string: str) -> EventCombination:
"""create a EventCombination form a string like '1,2,3+4,5,6'"""
try:
init_strs = init_string.split("+")
return cls(init_strs)
except AttributeError:
raise ValueError(f"failed to create EventCombination from {init_string = }")
def is_problematic(self):
"""Is this combination going to work properly on all systems?"""
@ -111,7 +122,8 @@ class EventCombination(Tuple[InputEvent]):
return False
def get_permutations(self):
"""Get a list of EventCombination objects representing all possible permutations.
"""
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.
@ -121,7 +133,7 @@ class EventCombination(Tuple[InputEvent]):
permutations = []
for permutation in itertools.permutations(self[:-1]):
permutations.append(EventCombination(*permutation, self[-1]))
permutations.append(EventCombination((*permutation, self[-1])))
return permutations

@ -41,6 +41,19 @@ class EventNotHandled(Error):
super().__init__(f"Event {event} can not be handled by the configured target")
class MacroParsingError(Error):
def __init__(self, symbol=None, msg="Error while parsing a macro"):
self.symbol = symbol
super().__init__(msg)
class MappingParsingError(Error):
def __init__(self, msg, *, mapping=None, mapping_handler=None):
self.mapping_handler = mapping_handler
self.mapping = mapping
super().__init__(msg)
class InputEventCreationError(Error):
def __init__(self, msg):
super().__init__(msg)

@ -486,7 +486,13 @@ class _Groups:
"""Load a serialized representation created via dumps."""
self._groups = [_Group.loads(group) for group in json.loads(dump)]
def find(self, name=None, key=None, path=None, include_inputremapper=False):
def find(
self,
name: str = None,
key: str = None,
path: str = None,
include_inputremapper: bool = False,
) -> _Group:
"""Find a group that matches the provided parameters.
Parameters

@ -23,6 +23,7 @@
from inputremapper.configs.preset import Preset
from inputremapper.configs.mapping import UIMapping
active_preset = Preset()
active_preset = Preset(mapping_factory=UIMapping)

@ -23,15 +23,23 @@
import re
import locale
import gettext
import os
import time
from typing import Optional
from gi.repository import Gtk, GLib, Gdk
from inputremapper.configs.data import get_data_path
from inputremapper.configs.mapping import UIMapping
from inputremapper.gui.gettext import _
from gi.repository import Gtk, GLib, Gdk, GtkSource
from inputremapper.gui.gettext import _
from inputremapper.gui.editor.autocompletion import Autocompletion
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.active_preset import active_preset
from inputremapper.event_combination import EventCombination
from inputremapper.input_event import InputEvent
from inputremapper.logger import logger
from inputremapper.gui.reader import reader
from inputremapper.gui.utils import CTX_KEYCODE, CTX_WARNING, CTX_ERROR
@ -56,7 +64,7 @@ class SelectionLabel(Gtk.ListBoxRow):
# Make the child label widget break lines, important for
# long combinations
label.set_line_wrap(True)
label.set_line_wrap_mode(2)
label.set_line_wrap_mode(Gtk.WrapMode.WORD)
label.set_justify(Gtk.Justification.CENTER)
self.label = label
@ -93,6 +101,44 @@ class SelectionLabel(Gtk.ListBoxRow):
return self.__str__()
class CombinationEntry(Gtk.ListBoxRow):
"""One row per InputEvent in the EventCombination"""
__gtype_name__ = "CombinationEntry"
def __init__(self, event: InputEvent):
super().__init__()
self.event = event
hbox = Gtk.Box(Gtk.Orientation.HORIZONTAL, spacing=4)
label = Gtk.Label()
label.set_label(event.json_str())
hbox.pack_start(label, False, False, 0)
up_btn = Gtk.Button()
up_btn.set_halign(Gtk.Align.END)
up_btn.set_relief(Gtk.ReliefStyle.NONE)
up_btn.get_style_context().add_class("no-v-padding")
up_img = Gtk.Image.new_from_icon_name("go-up", Gtk.IconSize.BUTTON)
up_btn.add(up_img)
down_btn = Gtk.Button()
down_btn.set_halign(Gtk.Align.END)
down_btn.set_relief(Gtk.ReliefStyle.NONE)
down_btn.get_style_context().add_class("no-v-padding")
down_img = Gtk.Image.new_from_icon_name("go-down", Gtk.IconSize.BUTTON)
down_btn.add(down_img)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
vbox.pack_start(up_btn, False, True, 0)
vbox.pack_end(down_btn, False, True, 0)
hbox.pack_end(vbox, False, False, 0)
self.add(hbox)
self.show_all()
def ensure_everything_saved(func):
"""Make sure the editor has written its changes to active_preset and save."""
@ -119,6 +165,8 @@ class Editor:
self.autocompletion = None
self.active_mapping: Optional[UIMapping] = None
self._setup_target_selector()
self._setup_source_view()
self._setup_recording_toggle()
@ -128,7 +176,7 @@ class Editor:
GLib.timeout_add(100, self.check_add_new_key),
GLib.timeout_add(1000, self.update_toggle_opacity),
]
self.active_selection_label: SelectionLabel = None
self.active_selection_label: Optional[SelectionLabel] = None
selection_label_listbox = self.get("selection_label_listbox")
selection_label_listbox.connect("row-selected", self.on_mapping_selected)
@ -140,8 +188,9 @@ class Editor:
self.record_events_until = RECORD_NONE
text_input = self.get_text_input()
text_input.connect("focus-out-event", self.on_text_input_unfocus)
code_editor = self.get_code_editor()
code_editor.connect("focus-out-event", self.on_text_input_unfocus)
code_editor.get_buffer().connect("changed", self.on_text_input_changed)
delete_button = self.get_delete_button()
delete_button.connect("clicked", self._on_delete_button_clicked)
@ -169,7 +218,7 @@ class Editor:
"""When unfocusing the text it saves.
Input Remapper doesn't save the editor on change, because that would cause
an incredible amount of logs for every single input. The custom_mapping would
an incredible amount of logs for every single input. The active_preset would
need to be changed, which causes two logs, then it has to be saved
to disk which is another two log messages. So every time a single character
is typed it writes 4 lines.
@ -189,10 +238,21 @@ class Editor:
"""
pass # the decorator will be triggered
@ensure_everything_saved
def on_text_input_changed(self, *_):
# correct case
symbol = self.get_symbol_input_text()
correct_case = system_mapping.correct_case(symbol)
if symbol != correct_case:
self.get_code_editor().get_buffer().set_text(correct_case)
if self.active_mapping:
# might be None if the empty mapping was selected, and the text input cleared
self.active_mapping.output_symbol = correct_case
def _on_target_input_changed(self, *_):
"""save when target changed"""
pass
self.active_mapping.target_uinput = self.get_target_selection()
self.gather_changes_and_save()
def clear(self):
"""Clear all inputs, labels, etc. Reset the state.
@ -254,7 +314,7 @@ class Editor:
def _setup_source_view(self):
"""Prepare the code editor."""
source_view = self.get("code_editor")
source_view = self.get_code_editor()
# without this the wrapping ScrolledWindow acts weird when new lines are added,
# not offering enough space to the text editor so the whole thing is suddenly
@ -282,7 +342,7 @@ class Editor:
def show_line_numbers_if_multiline(self, *_):
"""Show line numbers if a macro is being edited."""
code_editor = self.get("code_editor")
code_editor = self.get_code_editor()
symbol = self.get_symbol_input_text() or ""
if "\n" in symbol:
@ -294,9 +354,6 @@ class Editor:
code_editor.set_monospace(False)
code_editor.get_style_context().remove_class("multiline")
def get_delete_button(self):
return self.get("delete-mapping")
def check_add_new_key(self):
"""If needed, add a new empty mapping to the list for the user to configure."""
selection_label_listbox = self.get("selection_label_listbox")
@ -304,7 +361,12 @@ class Editor:
selection_label_listbox = selection_label_listbox.get_children()
for selection_label in selection_label_listbox:
if selection_label.get_combination() is None:
combination = selection_label.get_combination()
if (
combination is None
or active_preset.get_mapping(combination) is None
or not active_preset.get_mapping(combination).is_valid()
):
# unfinished row found
break
else:
@ -319,13 +381,12 @@ class Editor:
presets accidentally before configuring the key and then it's gone. It can
only be saved to the preset if a key is configured. This avoids that pitfall.
"""
logger.debug("Disabling the text input")
text_input = self.get_text_input()
logger.debug("Disabling the code editor")
text_input = self.get_code_editor()
# beware that this also appeared to disable event listeners like
# focus-out-event:
text_input.set_sensitive(False)
text_input.set_opacity(0.5)
if clear or self.get_symbol_input_text() == "":
@ -334,8 +395,8 @@ class Editor:
def enable_symbol_input(self):
"""Don't display help information anymore and allow changing the symbol."""
logger.debug("Enabling the text input")
text_input = self.get_text_input()
logger.debug("Enabling the code editor")
text_input = self.get_code_editor()
text_input.set_sensitive(True)
text_input.set_opacity(1)
@ -371,21 +432,26 @@ class Editor:
self.set_combination(combination)
if combination is None:
# the empty mapping was selected
self.active_mapping = UIMapping()
# active_preset.add(self.active_mapping)
self.disable_symbol_input(clear=True)
# default target should fit in most cases
self.set_target_selection("keyboard")
# symbol input disabled until a combination is configured
self.active_mapping.target_uinput = "keyboard"
# target input disabled until a combination is configured
self.disable_target_selector()
# symbol input disabled until a combination is configured
else:
if active_preset.get_mapping(combination):
self.set_symbol_input_text(active_preset.get_mapping(combination)[0])
self.set_target_selection(active_preset.get_mapping(combination)[1])
mapping = active_preset.get_mapping(combination)
if mapping is not None:
self.active_mapping = mapping
self.set_symbol_input_text(mapping.output_symbol)
self.set_target_selection(mapping.target_uinput)
self.enable_symbol_input()
self.enable_target_selector()
self.get("window").set_focus(self.get_text_input())
self.get("window").set_focus(self.get_code_editor())
def add_empty(self):
"""Add one empty row for a single mapped key."""
@ -402,9 +468,9 @@ class Editor:
selection_label_listbox.forall(selection_label_listbox.remove)
for key, _ in active_preset:
for mapping in active_preset:
selection_label = SelectionLabel()
selection_label.set_combination(key)
selection_label.set_combination(mapping.event_combination)
selection_label_listbox.insert(selection_label, -1)
self.check_add_new_key()
@ -421,15 +487,30 @@ class Editor:
def get_recording_toggle(self) -> Gtk.ToggleButton:
return self.get("key_recording_toggle")
def get_text_input(self):
def get_code_editor(self) -> GtkSource.View:
return self.get("code_editor")
def get_target_selector(self):
def get_target_selector(self) -> Gtk.ComboBox:
return self.get("target-selector")
def get_combination_listbox(self) -> Gtk.ListBox:
return self.get("combination-listbox")
def get_add_axis_btn(self) -> Gtk.Button:
return self.get("add-axis-as-btn")
def get_delete_button(self) -> Gtk.Button:
return self.get("delete-mapping")
def set_combination(self, combination):
"""Show what the user is currently pressing in the user interface."""
self.active_selection_label.set_combination(combination)
listbox = self.get_combination_listbox()
listbox.forall(listbox.remove)
if combination:
for event in combination:
listbox.insert(CombinationEntry(event), -1)
if combination and len(combination) > 0:
self.enable_symbol_input()
@ -447,10 +528,11 @@ class Editor:
return self.active_selection_label.combination
def set_symbol_input_text(self, symbol):
self.get("code_editor").get_buffer().set_text(symbol or "")
code_editor = self.get_code_editor()
code_editor.get_buffer().set_text(symbol or "")
# move cursor location to the beginning, like any code editor does
Gtk.TextView.do_move_cursor(
self.get("code_editor"),
code_editor,
Gtk.MovementStep.BUFFER_ENDS,
-1,
False,
@ -465,14 +547,14 @@ class Editor:
If there is no symbol, this returns None. This is important for some other
logic down the road in active_preset or something.
"""
buffer = self.get("code_editor").get_buffer()
buffer = self.get_code_editor().get_buffer()
symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True)
if symbol == SET_KEY_FIRST:
# not configured yet
return ""
return symbol.strip()
return symbol
def set_target_selection(self, target):
selector = self.get_target_selector()
@ -529,9 +611,9 @@ class Editor:
):
return
key = self.get_combination()
if key is not None:
active_preset.clear(key)
combination = self.get_combination()
if combination is not None:
active_preset.remove(combination)
# make sure there is no outdated information lying around in memory
self.set_combination(None)
@ -559,17 +641,8 @@ class Editor:
if not symbol or not target:
return
correct_case = system_mapping.correct_case(symbol)
if symbol != correct_case:
self.get_text_input().get_buffer().set_text(correct_case)
# make sure the active_preset is up to date
key = self.get_combination()
if correct_case and key and target:
active_preset.change(key, target, correct_case)
# save to disk if required
if active_preset.has_unsaved_changes():
if active_preset.is_valid():
self.user_interface.save_preset()
def is_waiting_for_input(self):
@ -603,11 +676,9 @@ class Editor:
# keycode is already set by some other row
existing = active_preset.get_mapping(combination)
if existing is not None:
existing = list(existing)
existing[0] = re.sub(r"\s", "", existing[0])
msg = _('"%s" already mapped to "%s"') % (
combination.beautify(),
tuple(existing),
existing.event_combination.beautify(),
)
logger.info("%s %s", combination, msg)
self.user_interface.show_status(CTX_KEYCODE, msg)
@ -635,26 +706,10 @@ class Editor:
return
self.set_combination(combination)
symbol = self.get_symbol_input_text()
target = self.get_target_selection()
if not symbol:
# has not been entered yet
logger.debug("Symbol missing")
return
if not target:
logger.debug("Target missing")
return
# else, the keycode has changed, the symbol is set, all good
active_preset.change(
new_combination=combination,
target=target,
symbol=symbol,
previous_combination=previous_key,
)
self.active_mapping.event_combination = combination
if previous_key is None and combination is not None:
logger.debug(f"adding new mapping to preset\n{self.active_mapping}")
active_preset.add(self.active_mapping)
def _switch_focus_if_complete(self):
"""If keys are released, it will switch to the text_input.
@ -679,7 +734,7 @@ class Editor:
window = self.user_interface.window
self.enable_symbol_input()
self.enable_target_selector()
GLib.idle_add(lambda: window.set_focus(self.get_text_input()))
GLib.idle_add(lambda: window.set_focus(self.get_code_editor()))
if not all_keys_released:
# currently the user is using the widget, and certain keys have already

@ -173,7 +173,7 @@ class Reader:
self.previous_event = previous_event
if len(self._unreleased) > 0:
result = EventCombination.from_events(self._unreleased.values())
result = EventCombination(self._unreleased.values())
if result == self.previous_result:
# don't return the same stuff twice
return None
@ -221,7 +221,7 @@ class Reader:
if len(unreleased) == 0:
return None
return EventCombination.from_events(unreleased)
return EventCombination(unreleased)
def _release(self, type_code):
"""Modify the state to recognize the releasing of the key."""

@ -28,12 +28,13 @@ import re
import sys
from inputremapper.gui.gettext import _
from evdev._ecodes import EV_KEY
from evdev.ecodes import EV_KEY
from gi.repository import Gtk, GtkSource, Gdk, GLib, GObject
from inputremapper.input_event import InputEvent
from inputremapper.configs.data import get_data_path
from inputremapper.configs.paths import get_config_path
from inputremapper.exceptions import MacroParsingError
from inputremapper.configs.paths import get_config_path, get_preset_path
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.active_preset import active_preset
from inputremapper.gui.utils import HandlerDisabled
@ -169,7 +170,7 @@ class UserInterface:
# set up the device selection
# https://python-gtk-3-tutorial.readthedocs.io/en/latest/treeview.html#the-view
combobox = self.get("device_selection")
combobox: Gtk.ComboBox = self.get("device_selection")
self.device_store = Gtk.ListStore(str, str, str)
combobox.set_model(self.device_store)
renderer_icon = Gtk.CellRendererPixbuf()
@ -204,12 +205,6 @@ class UserInterface:
# if any of the next steps take a bit to complete, have the window
# already visible (without content) to make it look more responsive.
gtk_iteration()
# this is not set to invisible in glade to give the ui a default
# height that doesn't jump when a gamepad is selected
self.get("gamepad_separator").hide()
self.get("gamepad_config").hide()
self.populate_devices()
self.timeouts = []
@ -291,33 +286,6 @@ class UserInterface:
if gdk_keycode in [Gdk.KEY_Control_L, Gdk.KEY_Control_R]:
self.ctrl = False
def initialize_gamepad_config(self):
"""Set slider and dropdown values when a gamepad is selected."""
if GAMEPAD in self.group.types:
self.get("gamepad_separator").show()
self.get("gamepad_config").show()
else:
self.get("gamepad_separator").hide()
self.get("gamepad_config").hide()
return
left_purpose = self.get("left_joystick_purpose")
right_purpose = self.get("right_joystick_purpose")
speed = self.get("joystick_mouse_speed")
with HandlerDisabled(left_purpose, self.on_left_joystick_changed):
value = active_preset.get("gamepad.joystick.left_purpose")
left_purpose.set_active_id(value)
with HandlerDisabled(right_purpose, self.on_right_joystick_changed):
value = active_preset.get("gamepad.joystick.right_purpose")
right_purpose.set_active_id(value)
with HandlerDisabled(speed, self.on_joystick_mouse_speed_changed):
value = active_preset.get("gamepad.joystick.pointer_speed")
range_value = math.log(value, 2)
speed.set_value(range_value)
def get(self, name):
"""Get a widget from the window"""
return self.builder.get_object(name)
@ -372,9 +340,10 @@ class UserInterface:
if len(presets) == 0:
new_preset = get_available_preset_name(self.group.name)
active_preset.empty()
active_preset.clear()
path = self.group.get_preset_path(new_preset)
active_preset.save(path)
active_preset.path = path
active_preset.save()
presets = [new_preset]
else:
logger.debug('"%s" presets: "%s"', self.group.name, '", "'.join(presets))
@ -384,9 +353,8 @@ class UserInterface:
with HandlerDisabled(preset_selection, self.on_select_preset):
# otherwise the handler is called with None for each preset
preset_selection.remove_all()
for preset in presets:
preset_selection.append(preset, preset)
for preset in presets:
preset_selection.append(preset, preset)
# and select the newest one (on the top). triggers on_select_preset
preset_selection.set_active(0)
@ -465,22 +433,6 @@ class UserInterface:
status_bar.push(context_id, message)
status_bar.set_tooltip_text(tooltip)
def check_macro_syntax(self):
"""Check if the programmed macros are allright."""
self.show_status(CTX_MAPPING, None)
for key, output in active_preset:
output = output[0]
if not is_this_a_macro(output):
continue
error = parse(output, active_preset, return_errors=True)
if error is None:
continue
position = key.beautify()
msg = _("Syntax error at %s, hover for info") % position
self.show_status(CTX_MAPPING, msg, error)
@ensure_everything_saved
def on_rename_button_clicked(self, button):
"""Rename the preset based on the contents of the name input."""
@ -490,6 +442,7 @@ class UserInterface:
return
new_name = rename_preset(self.group.name, self.preset_name, new_name)
active_preset.path = get_preset_path(self.group.name, new_name)
# if the old preset was being autoloaded, change the
# name there as well
@ -520,7 +473,7 @@ class UserInterface:
"""Apply a preset without saving changes."""
self.save_preset()
if active_preset.num_saved_keys == 0:
if len(active_preset) == 0:
logger.error(_("Cannot apply empty preset file"))
# also helpful for first time use
self.show_status(CTX_ERROR, _("You need to add keys and save first"))
@ -673,10 +626,11 @@ class UserInterface:
else:
new_preset = get_available_preset_name(name)
self.editor.clear()
active_preset.empty()
active_preset.clear()
path = self.group.get_preset_path(new_preset)
active_preset.save(path)
active_preset.path = path
active_preset.save()
self.get("preset_selection").append(new_preset, new_preset)
# triggers on_select_preset
self.get("preset_selection").set_active_id(new_preset)
@ -704,8 +658,9 @@ class UserInterface:
logger.debug('Selecting preset "%s"', preset)
self.editor.clear_mapping_list()
self.preset_name = preset
active_preset.load(self.group.get_preset_path(preset))
active_preset.clear()
active_preset.path = self.group.get_preset_path(preset)
active_preset.load()
self.editor.load_custom_mapping()
@ -719,27 +674,6 @@ class UserInterface:
self.get("preset_name_input").set_text("")
self.initialize_gamepad_config()
active_preset.set_has_unsaved_changes(False)
def on_left_joystick_changed(self, dropdown):
"""Set the purpose of the left joystick."""
purpose = dropdown.get_active_id()
active_preset.set("gamepad.joystick.left_purpose", purpose)
self.save_preset()
def on_right_joystick_changed(self, dropdown):
"""Set the purpose of the right joystick."""
purpose = dropdown.get_active_id()
active_preset.set("gamepad.joystick.right_purpose", purpose)
self.save_preset()
def on_joystick_mouse_speed_changed(self, gtk_range):
"""Set how fast the joystick moves the mouse."""
speed = 2 ** gtk_range.get_value()
active_preset.set("gamepad.joystick.pointer_speed", speed)
def save_preset(self, *args):
"""Write changes in the active_preset to disk."""
if not active_preset.has_unsaved_changes():
@ -749,8 +683,7 @@ class UserInterface:
try:
assert self.preset_name is not None
path = self.group.get_preset_path(self.preset_name)
active_preset.save(path)
active_preset.save()
# after saving the preset, its modification date will be the
# newest, so populate_presets will automatically select the
@ -761,30 +694,7 @@ class UserInterface:
self.show_status(CTX_ERROR, _("Permission denied!"), error)
logger.error(error)
for _x, mapping in active_preset:
if not mapping:
continue
symbol = mapping[0]
target = mapping[1]
if is_this_a_macro(symbol):
continue
code = system_mapping.get(symbol)
if (
code is None
or code not in global_uinputs.get_uinput(target).capabilities()[EV_KEY]
):
trimmed = re.sub(r"\s+", " ", symbol).strip()
self.show_status(CTX_MAPPING, _("Unknown mapping %s") % trimmed)
break
else:
# no broken mappings found
self.show_status(CTX_MAPPING, None)
# checking macros is probably a bit more expensive, do that if
# the regular mappings are allright
self.check_macro_syntax()
self.show_status(CTX_MAPPING, None)
def on_about_clicked(self, button):
"""Show the about/help dialog."""

@ -1,121 +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/>.
"""Because multiple calls to async_read_loop won't work."""
import asyncio
import evdev
from inputremapper.injection.consumers.joystick_to_mouse import JoystickToMouse
from inputremapper.injection.consumers.keycode_mapper import KeycodeMapper
from inputremapper.logger import logger
from inputremapper.injection.context import Context
consumer_classes = [
KeycodeMapper,
JoystickToMouse,
]
class ConsumerControl:
"""Reads input events from a single device and distributes them.
There is one ConsumerControl object for each source, which tells multiple consumers
that a new event is ready so that they can inject all sorts of funny
things.
Other devnodes may be present for the hardware device, in which case this
needs to be created multiple times.
"""
def __init__(
self,
context: Context,
source: evdev.InputDevice,
forward_to: evdev.UInput,
) -> None:
"""Initialize all consumers
Parameters
----------
source : evdev.InputDevice
where to read keycodes from
forward_to : evdev.UInput
where to write keycodes to that were not mapped to anything.
Should be an UInput with capabilities that work for all forwarded
events, so ideally they should be copied from source.
"""
self._source = source
self._forward_to = forward_to
# add all consumers that are enabled for this particular configuration
self._consumers = []
for Consumer in consumer_classes:
consumer = Consumer(context, source, forward_to)
if consumer.is_enabled():
self._consumers.append(consumer)
async def run(self):
"""Start doing things.
Can be stopped by stopping the asyncio loop. This loop
reads events from a single device only.
"""
for consumer in self._consumers:
# run all of them in parallel
asyncio.ensure_future(consumer.run())
logger.debug(
"Starting to listen for events from %s, fd %s",
self._source.path,
self._source.fd,
)
async for event in self._source.async_read_loop():
if event.type == evdev.ecodes.EV_KEY and event.value == 2:
# button-hold event. Environments (gnome, etc.) create them on
# their own for the injection-fake-device if the release event
# won't appear, no need to forward or map them.
continue
handled = False
for consumer in self._consumers:
# copy so that the consumer doesn't screw this up for
# all other future consumers
event_copy = evdev.InputEvent(
sec=event.sec,
usec=event.usec,
type=event.type,
code=event.code,
value=event.value,
)
if consumer.is_handled(event_copy):
await consumer.notify(event_copy)
handled = True
if not handled:
# forward the rest
self._forward_to.write(event.type, event.code, event.value)
# this already includes SYN events, so need to syn here again
# This happens all the time in tests because the async_read_loop stops when
# there is nothing to read anymore. Otherwise tests would block.
logger.error('The async_read_loop for "%s" stopped early', self._source.path)

@ -1,83 +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/>.
"""Consumer base class.
Can be notified of new events so that inheriting classes can map them and
inject new events based on them.
"""
class Consumer:
"""Can be notified of new events to inject them. Base class."""
def __init__(self, context, source, forward_to=None):
"""Initialize event consuming functionality.
Parameters
----------
context : Context
The configuration of the Injector process
source : InputDevice
Where events used in handle_keycode come from
forward_to : evdev.UInput
Where to write keycodes to that were not mapped to anything.
Should be an UInput with capabilities that work for all forwarded
events, so ideally they should be copied from source.
"""
self.context = context
self.forward_to = forward_to
self.source = source
self.context.update_purposes()
def is_enabled(self):
"""Check if the consumer will have work to do."""
raise NotImplementedError
def forward(self, key):
"""Shorthand to forward an event."""
self.forward_to.write(*key)
async def notify(self, event):
"""A new event is ready.
Overwrite this function if the consumer should do something each time
a new event arrives. E.g. mapping a single button once clicked.
"""
raise NotImplementedError
def is_handled(self, event):
"""Check if the consumer will take care of this event.
If this returns true, the event will not be forwarded anymore
automatically. If you want to forward the event after all you can
inject it into `self.forward_to`.
"""
raise NotImplementedError
async def run(self):
"""Start doing things.
Overwrite this function if the consumer should do something
continuously even if no new event arrives. e.g. continuously injecting
mouse movement events.
"""
raise NotImplementedError

@ -1,273 +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/>.
"""Keeps mapping joystick to mouse movements."""
import asyncio
import time
from evdev.ecodes import (
EV_REL,
REL_X,
REL_Y,
REL_WHEEL,
REL_HWHEEL,
EV_ABS,
ABS_X,
ABS_Y,
ABS_RX,
ABS_RY,
)
from inputremapper.logger import logger
from inputremapper.configs.global_config import MOUSE, WHEEL
from inputremapper import utils
from inputremapper.injection.consumers.consumer import Consumer
from inputremapper.groups import classify, GAMEPAD
from inputremapper.injection.global_uinputs import global_uinputs
# miniscule movements on the joystick should not trigger a mouse wheel event
WHEEL_THRESHOLD = 0.15
def abs_max(value_1, value_2):
"""Get the value with the higher abs value."""
if abs(value_1) > abs(value_2):
return value_1
return value_2
class JoystickToMouse(Consumer):
"""Keeps producing events at 60hz if needed.
Maps joysticks to mouse movements.
This class does not handle injecting macro stuff over time, that is done
by the keycode_mapper.
"""
def __init__(self, *args, **kwargs):
"""Construct the event producer without it doing anything yet."""
super().__init__(*args, **kwargs)
self._abs_range = None
self._set_abs_range_from(self.source)
# events only take ints, so a movement of 0.3 needs to add
# up to 1.2 to affect the cursor, with 0.2 remaining
self.pending_rel = {REL_X: 0, REL_Y: 0, REL_WHEEL: 0, REL_HWHEEL: 0}
# the last known position of the joystick
self.abs_state = {ABS_X: 0, ABS_Y: 0, ABS_RX: 0, ABS_RY: 0}
def is_enabled(self):
gamepad = classify(self.source) == GAMEPAD
return gamepad and self.context.joystick_as_mouse()
def _write(self, ev_type, keycode, value):
"""Inject."""
# if the mouse won't move even though correct stuff is written here,
# the capabilities are probably wrong
try:
global_uinputs.write((ev_type, keycode, value), "mouse")
except OverflowError:
# screwed up the calculation of mouse movements
logger.error("OverflowError (%s, %s, %s)", ev_type, keycode, value)
def accumulate(self, code, input_value):
"""Since devices can't do float values, stuff has to be accumulated.
If pending is 0.6 and input_value is 0.5, return 0.1 and 1.
Because it should move 1px, and 0.1px is rememberd for the next value
in pending.
"""
self.pending_rel[code] += input_value
output_value = int(self.pending_rel[code])
self.pending_rel[code] -= output_value
return output_value
def _set_abs_range_from(self, device):
"""Update the min and max values joysticks will report.
This information is needed for abs -> rel mapping.
"""
if device is None:
# I don't think this ever happened
logger.error("Expected device to not be None")
return
abs_range = utils.get_abs_range(device)
if abs_range is None:
return
if abs_range[1] in [0, 1, None]:
# max abs_range of joysticks is usually a much higher number
return
self.set_abs_range(*abs_range)
logger.debug('ABS range of "%s": %s', device.name, abs_range)
def set_abs_range(self, min_abs, max_abs):
"""Update the min and max values joysticks will report.
This information is needed for abs -> rel mapping.
"""
self._abs_range = (min_abs, max_abs)
# all joysticks in resting position by default
center = (self._abs_range[1] + self._abs_range[0]) / 2
self.abs_state = {ABS_X: center, ABS_Y: center, ABS_RX: center, ABS_RY: center}
def get_abs_values(self):
"""Get the raw values for wheel and mouse movement.
Returned values center around 0 and are normalized into -1 and 1.
If two joysticks have the same purpose, the one that reports higher
absolute values takes over the control.
"""
# center is the value of the resting position
center = (self._abs_range[1] + self._abs_range[0]) / 2
# normalizer is the maximum possible value after centering
normalizer = (self._abs_range[1] - self._abs_range[0]) / 2
mouse_x = 0
mouse_y = 0
wheel_x = 0
wheel_y = 0
def standardize(value):
return (value - center) / normalizer
if self.context.left_purpose == MOUSE:
mouse_x = abs_max(mouse_x, standardize(self.abs_state[ABS_X]))
mouse_y = abs_max(mouse_y, standardize(self.abs_state[ABS_Y]))
if self.context.left_purpose == WHEEL:
wheel_x = abs_max(wheel_x, standardize(self.abs_state[ABS_X]))
wheel_y = abs_max(wheel_y, standardize(self.abs_state[ABS_Y]))
if self.context.right_purpose == MOUSE:
mouse_x = abs_max(mouse_x, standardize(self.abs_state[ABS_RX]))
mouse_y = abs_max(mouse_y, standardize(self.abs_state[ABS_RY]))
if self.context.right_purpose == WHEEL:
wheel_x = abs_max(wheel_x, standardize(self.abs_state[ABS_RX]))
wheel_y = abs_max(wheel_y, standardize(self.abs_state[ABS_RY]))
# Some joysticks report from 0 to 255 (EMV101),
# others from -32768 to 32767 (X-Box 360 Pad)
return mouse_x, mouse_y, wheel_x, wheel_y
def is_handled(self, event):
"""Check if the event is something this will take care of."""
if event.type != EV_ABS or event.code not in utils.JOYSTICK:
return False
if self._abs_range is None:
return False
purposes = [MOUSE, WHEEL]
left_purpose = self.context.left_purpose
right_purpose = self.context.right_purpose
if event.code in (ABS_X, ABS_Y) and left_purpose in purposes:
return True
if event.code in (ABS_RX, ABS_RY) and right_purpose in purposes:
return True
return False
async def notify(self, event):
if event.type == EV_ABS and event.code in self.abs_state:
self.abs_state[event.code] = event.value
async def run(self):
"""Keep writing mouse movements based on the gamepad stick position.
Even if no new input event arrived because the joystick remained at
its position, this will keep injecting the mouse movement events.
"""
abs_range = self._abs_range
preset = self.context.preset
pointer_speed = preset.get("gamepad.joystick.pointer_speed")
non_linearity = preset.get("gamepad.joystick.non_linearity")
x_scroll_speed = preset.get("gamepad.joystick.x_scroll_speed")
y_scroll_speed = preset.get("gamepad.joystick.y_scroll_speed")
max_speed = 2**0.5 # for normalized abs event values
if abs_range is not None:
logger.info(
"Left joystick as %s, right joystick as %s",
self.context.left_purpose,
self.context.right_purpose,
)
start = time.time()
while True:
# try to do this as close to 60hz as possible
time_taken = time.time() - start
await asyncio.sleep(max(0.0, (1 / 60) - time_taken))
start = time.time()
if abs_range is None:
# no ev_abs events will be mapped to ev_rel
continue
abs_values = self.get_abs_values()
if len([val for val in abs_values if not -1 <= val <= 1]) > 0:
logger.error("Inconsistent values: %s", abs_values)
continue
mouse_x, mouse_y, wheel_x, wheel_y = abs_values
# mouse movements
if abs(mouse_x) > 0 or abs(mouse_y) > 0:
if non_linearity != 1:
# to make small movements smaller for more precision
speed = (mouse_x**2 + mouse_y**2) ** 0.5 # pythagoras
factor = (speed / max_speed) ** non_linearity
else:
factor = 1
rel_x = mouse_x * factor * pointer_speed
rel_y = mouse_y * factor * pointer_speed
rel_x = self.accumulate(REL_X, rel_x)
rel_y = self.accumulate(REL_Y, rel_y)
if rel_x != 0:
self._write(EV_REL, REL_X, rel_x)
if rel_y != 0:
self._write(EV_REL, REL_Y, rel_y)
# wheel movements
if abs(wheel_x) > 0:
change = wheel_x * x_scroll_speed
value = self.accumulate(REL_WHEEL, change)
if abs(change) > WHEEL_THRESHOLD * x_scroll_speed:
self._write(EV_REL, REL_HWHEEL, value)
if abs(wheel_y) > 0:
change = wheel_y * y_scroll_speed
value = self.accumulate(REL_HWHEEL, change)
if abs(change) > WHEEL_THRESHOLD * y_scroll_speed:
self._write(EV_REL, REL_WHEEL, -value)

@ -1,554 +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/>.
"""Inject a keycode based on the mapping."""
import itertools
import asyncio
import time
import evdev
from evdev.ecodes import EV_KEY, EV_ABS
import inputremapper.exceptions
from inputremapper.logger import logger
from inputremapper.configs.system_mapping import DISABLE_CODE
from inputremapper import utils
from inputremapper.injection.consumers.consumer import Consumer
from inputremapper.utils import RELEASE
from inputremapper.groups import classify, GAMEPAD
from inputremapper.injection.global_uinputs import global_uinputs
# this state is shared by all KeycodeMappers of this process
# maps mouse buttons to macro instances that have been executed.
# They may still be running or already be done. Just like unreleased,
# this is a mapping of (type, code). The value is not included in the
# key, because a key release event with a value of 0 needs to be able
# to find the running macro. The downside is that a d-pad cannot
# execute two macros at once, one for each direction.
# Only sequentially.
active_macros = {}
# mapping of future release event (type, code) to an Unreleased object,
# All key-up events have a value of 0, so it is not added to
# the tuple. This is needed in order to release the correct event
# mapped on a D-Pad. Each direction on one D-Pad axis reports the
# same type and code, but different values. There cannot be both at
# the same time, as pressing one side of a D-Pad forces the other
# side to go up. If both sides of a D-Pad are mapped to different
# event-codes, this data structure helps to figure out which of those
# two to release on an event of value 0. Same goes for the Wheel.
# The input event is remembered to make sure no duplicate down-events
# are written. Since wheels report a lot of "down" events that don't
# serve any purpose when mapped to a key, those duplicate down events
# should be removed. If the same type and code arrives but with a
# different value (direction), there must be a way to check if the
# event is actually a duplicate and not a different event.
unreleased = {}
COMBINATION_INCOMPLETE = 1 # not all keys of the combination are pressed
NOT_COMBINED = 2 # this key is not part of a combination
def subsets(combination):
"""Return a list of subsets of the combination.
If combination is only one element long it returns an empty list,
because it's not a combination and there is no reason to iterate.
Includes the complete input as well.
Parameters
-----------
combination : tuple
tuple of 3-tuples, each being int, int, int (type, code, action)
"""
combination = list(combination)
lengths = list(range(2, len(combination) + 1))
lengths.reverse()
return list(
itertools.chain.from_iterable(
itertools.combinations(combination, length) for length in lengths
)
)
class Unreleased:
"""This represents a key that has been pressed but not released yet."""
__slots__ = (
"target",
"input_event_tuple",
"triggered_key",
)
def __init__(self, target, input_event_tuple, triggered_key):
"""
Parameters
----------
target : 3-tuple
int type, int code of what was injected or forwarded
and string target_uinput for injected events,
None for forwarded events
input_event_tuple : 3-tuple
int, int, int / type, code, action
triggered_key : tuple of 3-tuples
What was used to index key_to_code or macros when stuff
was triggered.
If nothing was triggered and input_event_tuple forwarded,
insert None.
"""
self.target = target
self.input_event_tuple = input_event_tuple
self.triggered_key = triggered_key
if not isinstance(input_event_tuple[0], int) or len(input_event_tuple) != 3:
raise ValueError(
"Expected input_event_tuple to be a 3-tuple of ints, but "
f"got {input_event_tuple}"
)
unreleased[input_event_tuple[:2]] = self
def is_mapped(self):
"""If true, the key-down event was written to context.uinput.
That means the release event should also be injected into that one.
If this returns false, just forward the release event instead.
"""
# This should end up being equal to context.is_mapped(key)
return self.triggered_key is not None
def __str__(self):
return (
"Unreleased("
f"target{self.target},"
f"input{self.input_event_tuple},"
f'key{self.triggered_key or "(None)"}'
")"
)
def __repr__(self):
return self.__str__()
def find_by_event(key):
"""Find an unreleased entry by an event.
If such an entry exists, it was created by an event that is exactly
like the input parameter (except for the timestamp).
That doesn't mean it triggered something, only that it was seen before.
"""
unreleased_entry = unreleased.get(key[:2])
if unreleased_entry and unreleased_entry.input_event_tuple == key:
return unreleased_entry
return None
def find_by_key(key):
"""Find an unreleased entry by a combination of keys.
If such an entry exist, it was created when a combination of keys
(which matches the parameter, can also be of len 1 = single key)
ended up triggering something.
Parameters
----------
key : tuple of int
type, code, action
"""
unreleased_entry = unreleased.get(key[-1][:2])
if unreleased_entry and unreleased_entry.triggered_key == key:
return unreleased_entry
return None
class KeycodeMapper(Consumer):
"""Injects keycodes and starts macros.
This really is somewhat complicated because it needs to be able to handle
combinations (which is actually not that trivial because the order of keys
matters). The nature of some events (D-Pads and Wheels) adds to the
complexity. Since macros are mapped the same way keys are, this class
takes care of both.
"""
def __init__(self, *args, **kwargs):
"""Create a keycode mapper for one virtual device.
There may be multiple KeycodeMappers for one hardware device. They
share some state (unreleased and active_macros) with each other.
"""
super().__init__(*args, **kwargs)
self._abs_range = None
if self.context.maps_joystick():
self._abs_range = utils.get_abs_range(self.source)
self._gamepad = classify(self.source) == GAMEPAD
self.debounces = {}
# some type checking, prevents me from forgetting what that stuff
# is supposed to be when writing tests.
for combination in self.context.key_to_code:
for event in combination:
if abs(event.value) > 1:
raise ValueError(
f"Expected values to be one of -1, 0 or 1, "
f"but got {combination}"
)
def is_enabled(self):
# even if the source does not provide a capability that is used here, it might
# be important for notifying macros of new events that run on other sources.
return len(self.context.key_to_code) > 0 or len(self.context.macros) > 0
def is_handled(self, event):
return utils.should_map_as_btn(event, self.context.preset, self._gamepad)
async def run(self):
"""Provide a debouncer to inject wheel releases."""
start = time.time()
while True:
# try to do this as close to 60hz as possible
time_taken = time.time() - start
await asyncio.sleep(max(0.0, (1 / 60) - time_taken))
start = time.time()
for debounce in self.debounces.values():
if debounce[2] == -1:
# has already been triggered
continue
if debounce[2] == 0:
debounce[0](*debounce[1])
debounce[2] = -1
else:
debounce[2] -= 1
def debounce(self, debounce_id, func, args, ticks):
"""Debounce a function call.
Parameters
----------
debounce_id : hashable
If this function is called with the same debounce_id again,
the previous debouncing is overwritten, and therefore restarted.
func : function
args : tuple
ticks : int
After ticks * 1 / 60 seconds the function will be executed,
unless debounce is called again with the same debounce_id
"""
self.debounces[debounce_id] = [func, args, ticks]
async def notify(self, event):
"""Receive the newest event that should be mapped."""
action = utils.classify_action(event, self._abs_range)
for macro, _ in self.context.macros.values():
macro.notify(event, action)
will_report_key_up = utils.will_report_key_up(event)
if not will_report_key_up:
# simulate a key-up event if no down event arrives anymore.
# this may release macros, combinations or keycodes.
release = evdev.InputEvent(0, 0, event.type, event.code, 0)
self.debounce(
debounce_id=(event.type, event.code, action),
func=self.handle_keycode,
args=(release, RELEASE, False),
ticks=3,
)
async def delayed_handle_keycode():
# give macros a priority of working on their asyncio iterations
# first before handle_keycode. This is important for if_single.
# If if_single injects a modifier to modify the key that canceled
# its sleep, it needs to inject it before handle_keycode injects
# anything. This is important for the space cadet shift.
# 1. key arrives
# 2. stop if_single
# 3. make if_single inject `then`
# 4. inject key
# But I can't just wait for if_single to do its thing because it might
# be a macro that sleeps for a few seconds.
# This appears to me to be incredibly race-conditiony. For that
# reason wait a few more asyncio ticks before continuing.
# But a single one also worked. I can't wait for the specific
# macro task here because it might block forever. I'll just give
# it a few asyncio iterations advance before continuing here.
for _ in range(10):
# Noticable delays caused by this start at 10000 iterations
# Also see the python docs on asyncio.sleep. Sleeping for 0
# seconds just iterates the loop once.
await asyncio.sleep(0)
self.handle_keycode(event, action)
await delayed_handle_keycode()
def macro_write(self, target_uinput):
def f(ev_type, code, value):
"""Handler for macros."""
logger.debug(
f"Macro sending %s to %s", (ev_type, code, value), target_uinput
)
global_uinputs.write((ev_type, code, value), target_uinput)
return f
def _get_key(self, key):
"""If the event triggers stuff, get the key for that.
This key can be used to index `key_to_code` and `macros` and it might
be a combination of keys.
Otherwise, for unmapped events, returns the input.
The return format is always a tuple of 3-tuples, each 3-tuple being
type, code, action (int, int, int)
Parameters
----------
key : tuple of int
3-tuple of type, code, action
Action should be one of -1, 0 or 1
"""
unreleased_entry = find_by_event(key)
# The key used to index the mappings `key_to_code` and `macros`.
# If the key triggers a combination, the returned key will be that one
# instead
action = key[2]
key = (key,)
if unreleased_entry and unreleased_entry.triggered_key is not None:
# seen before. If this key triggered a combination,
# use the combination that was triggered by this as key.
return unreleased_entry.triggered_key
if utils.is_key_down(action):
# get the key/combination that the key-down would trigger
# the triggering key-down has to be the last element in
# combination, all others can have any arbitrary order. By
# checking all unreleased keys, a + b + c takes priority over
# b + c, if both mappings exist.
# WARNING! the combination-down triggers, but a single key-up
# releases. Do not check if key in macros and such, if it is an
# up event. It's going to be False.
combination = tuple(
value.input_event_tuple for value in unreleased.values()
)
if key[0] not in combination: # might be a duplicate-down event
combination += key
# find any triggered combination. macros and key_to_code contain
# every possible equivalent permutation of possible macros. The
# last key in the combination needs to remain the newest key
# though.
for subset in subsets(combination):
if subset[-1] != key[0]:
# only combinations that are completed and triggered by
# the newest input are of interest
continue
if self.context.is_mapped(subset):
key = subset
break
else:
# no subset found, just use the key. all indices are tuples of
# tuples, both for combinations and single keys.
if len(combination) > 1:
logger.debug_key(combination, "unknown combination")
return key
def handle_keycode(self, event, action, forward=True):
"""Write mapped keycodes, forward unmapped ones and manage macros.
As long as the provided event is mapped it will handle it, it won't
check any type, code or capability anymore. Otherwise it forwards
it as it is.
Parameters
----------
action : int
One of PRESS, PRESS_NEGATIVE or RELEASE
Just looking at the events value is not enough, because then mapping
trigger-values that are between 1 and 255 is not possible. They might skip
the 1 when pressed fast enough.
event : evdev.InputEvent
forward : bool
if False, will not forward the event if it didn't trigger any
mapping
"""
assert isinstance(action, int)
type_and_code = (event.type, event.code)
active_macro = active_macros.get(type_and_code)
original_tuple = (event.type, event.code, event.value)
key = self._get_key((*type_and_code, action))
is_mapped = self.context.is_mapped(key)
"""Releasing keys and macros"""
if utils.is_key_up(action):
if active_macro is not None and active_macro.is_holding():
# Tell the macro for that keycode that the key is released and
# let it decide what to do with that information.
active_macro.release_trigger()
logger.debug_key(key, "releasing macro")
if type_and_code in unreleased:
# figure out what this release event was for
unreleased_entry = unreleased[type_and_code]
target_type, target_code, target_uinput = unreleased_entry.target
del unreleased[type_and_code]
if target_code == DISABLE_CODE:
logger.debug_key(key, "releasing disabled key")
return
if target_code is None:
logger.debug_key(key, "releasing key")
return
if unreleased_entry.is_mapped():
# release what the input is mapped to
try:
logger.debug_key(
key, "releasing (%s, %s)", target_code, target_uinput
)
global_uinputs.write(
(target_type, target_code, 0), target_uinput
)
return
except inputremapper.exceptions.Error:
logger.debug_key(key, "could not map")
pass
if forward:
# forward the release event
logger.debug_key((original_tuple,), "forwarding release")
self.forward(original_tuple)
else:
logger.debug_key(key, "not forwarding release")
return
if event.type != EV_ABS:
# ABS events might be spammed like crazy every time the
# position slightly changes
logger.debug_key(key, "unexpected key up")
# everything that can be released is released now
return
"""Filtering duplicate key downs"""
if is_mapped and utils.is_key_down(action):
# unmapped keys should not be filtered here, they should just
# be forwarded to populate unreleased and then be written.
if find_by_key(key) is not None:
# this key/combination triggered stuff before.
# duplicate key-down. skip this event. Avoid writing millions
# of key-down events when a continuous value is reported, for
# example for gamepad triggers or mouse-wheel-side buttons
logger.debug_key(key, "duplicate key down")
return
# it would start a macro usually
in_macros = key in self.context.macros
running = active_macro and active_macro.running
if in_macros and running:
# for key-down events and running macros, don't do anything.
# This avoids spawning a second macro while the first one is
# not finished, especially since gamepad-triggers report a ton
# of events with a positive value.
logger.debug_key(key, "macro already running")
self.context.macros[key][0].press_trigger()
return
"""starting new macros or injecting new keys"""
if utils.is_key_down(action):
# also enter this for unmapped keys, as they might end up
# triggering a combination, so they should be remembered in
# unreleased
if key in self.context.macros:
macro, target_uinput = self.context.macros[key]
active_macros[type_and_code] = macro
Unreleased((None, None, None), (*type_and_code, action), key)
macro.press_trigger()
logger.debug_key(
key, "maps to macro (%s, %s)", macro.code, target_uinput
)
asyncio.ensure_future(macro.run(self.macro_write(target_uinput)))
return
if key in self.context.key_to_code:
target_code, target_uinput = self.context.key_to_code[key]
# remember the key that triggered this
# (this combination or this single key)
Unreleased(
(EV_KEY, target_code, target_uinput), (*type_and_code, action), key
)
if target_code == DISABLE_CODE:
logger.debug_key(key, "disabled")
return
try:
logger.debug_key(
key, "maps to (%s, %s)", target_code, target_uinput
)
global_uinputs.write((EV_KEY, target_code, 1), target_uinput)
return
except inputremapper.exceptions.Error:
logger.debug_key(key, "could not map")
pass
if forward:
logger.debug_key((original_tuple,), "forwarding")
self.forward(original_tuple)
else:
logger.debug_key(((*type_and_code, action),), "not forwarding")
# unhandled events may still be important for triggering
# combinations later, so remember them as well.
Unreleased((*type_and_code, None), (*type_and_code, action), None)
return
logger.error("%s unhandled", key)

@ -20,13 +20,34 @@
"""Stores injection-process wide information."""
import asyncio
from typing import Awaitable, List, Dict, Tuple, Protocol, Set
from inputremapper.logger import logger
from inputremapper.injection.macros.parse import parse, is_this_a_macro
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.event_combination import EventCombination
from inputremapper.configs.global_config import NONE, MOUSE, WHEEL, BUTTONS
import evdev
from inputremapper.configs.preset import Preset
from inputremapper.input_event import InputEvent
from inputremapper.injection.mapping_handlers.mapping_parser import parse_mappings
from inputremapper.injection.mapping_handlers.mapping_handler import (
InputEventHandler,
EventListener,
)
class NotifyCallback(Protocol):
"""type signature of MappingHandler.notify
return True if the event was actually taken care of
"""
def __call__(
self,
event: evdev.InputEvent,
source: evdev.InputDevice = None,
forward: evdev.UInput = None,
supress: bool = False,
) -> bool:
...
class Context:
@ -50,113 +71,35 @@ class Context:
Members
-------
preset : Preset
The preset that is the source of key_to_code and macros,
only used to query config values.
key_to_code : dict
Preset of ((type, code, value),) to linux-keycode
or multiple of those like ((...), (...), ...) for combinations.
Combinations need to be present in every possible valid ordering.
e.g. shift + alt + a and alt + shift + a.
This is needed to query keycodes more efficiently without having
to search preset each time.
macros : dict
Preset of ((type, code, value),) to Macro objects.
Combinations work similar as in key_to_code
The preset holds all Mappings for the injection process
listeners : Set[EventListener]
a set of callbacks which receive all events
callbacks : Dict[Tuple[int, int], List[NotifyCallback]]
all entry points to the event pipeline sorted by InputEvent.type_and_code
"""
def __init__(self, preset):
self.preset = preset
preset: Preset
listeners: Set[EventListener]
callbacks: Dict[Tuple[int, int], List[NotifyCallback]]
_handlers: Dict[InputEvent, List[InputEventHandler]]
# avoid searching through the mapping at runtime,
# might be a bit expensive
self.key_to_code = self._map_keys_to_codes()
self.macros = self._parse_macros()
self.left_purpose = None
self.right_purpose = None
self.update_purposes()
def update_purposes(self):
"""Read joystick purposes from the configuration.
For efficiency, so that the config doesn't have to be read during
runtime repeatedly.
"""
self.left_purpose = self.preset.get("gamepad.joystick.left_purpose")
self.right_purpose = self.preset.get("gamepad.joystick.right_purpose")
def _parse_macros(self):
"""To quickly get the target macro during operation."""
logger.debug("Parsing macros")
macros = {}
for combination, output in self.preset:
if is_this_a_macro(output[0]):
macro = parse(output[0], self)
if macro is None:
continue
for permutation in combination.get_permutations():
macros[permutation] = (macro, output[1])
if len(macros) == 0:
logger.debug("No macros configured")
return macros
def _map_keys_to_codes(self):
"""To quickly get target keycodes during operation.
Returns a mapping of one or more 3-tuples to 2-tuples of (int, target_uinput).
Examples:
((1, 2, 1),): (3, "keyboard")
((1, 5, 1), (1, 4, 1)): (4, "gamepad")
"""
key_to_code = {}
for combination, output in self.preset:
if is_this_a_macro(output[0]):
continue
target_code = system_mapping.get(output[0])
if target_code is None:
logger.error('Don\'t know what "%s" is', output[0])
continue
for permutation in combination.get_permutations():
if permutation[-1].value not in [-1, 1]:
logger.error(
"Expected values to be -1 or 1 at this point: %s",
permutation,
)
key_to_code[permutation] = (target_code, output[1])
return key_to_code
def is_mapped(self, combination):
"""Check if this combination is used for macros or mappings.
Parameters
----------
combination : tuple of tuple of int
One or more 3-tuples of type, code, action,
for example ((EV_KEY, KEY_A, 1), (EV_ABS, ABS_X, -1))
or ((EV_KEY, KEY_B, 1),)
"""
return combination in self.macros or combination in self.key_to_code
def maps_joystick(self):
"""If at least one of the joysticks will serve a special purpose."""
return (self.left_purpose, self.right_purpose) != (NONE, NONE)
def joystick_as_mouse(self):
"""If at least one joystick maps to an EV_REL capability."""
purposes = (self.left_purpose, self.right_purpose)
return MOUSE in purposes or WHEEL in purposes
def joystick_as_dpad(self):
"""If at least one joystick may be mapped to keys."""
purposes = (self.left_purpose, self.right_purpose)
return BUTTONS in purposes
def writes_keys(self):
"""Check if anything is being mapped to keys."""
return len(self.macros) > 0 and len(self.key_to_code) > 0
def __init__(self, preset: Preset):
self.preset = preset
self.listeners = set()
self.callbacks = {}
self._handlers = parse_mappings(preset, self)
self._create_callbacks()
def reset(self) -> None:
"""call the reset method for each handler in the context"""
for handlers in self._handlers.values():
[handler.reset() for handler in handlers]
def _create_callbacks(self) -> None:
"""add the notify method from all _handlers to self.callbacks"""
for event, handler_list in self._handlers.items():
if event.type_and_code not in self.callbacks.keys():
self.callbacks[event.type_and_code] = []
for handler in handler_list:
self.callbacks[event.type_and_code].append(handler.notify)

@ -0,0 +1,169 @@
#!/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/>.
"""Because multiple calls to async_read_loop won't work."""
import asyncio
import evdev
from inputremapper.logger import logger
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.injection.context import Context
class _ReadLoop:
def __init__(self, device: evdev.InputDevice, stop_event: asyncio.Event):
self.iterator = device.async_read_loop().__aiter__()
self.stop_event = stop_event
self.wait_for_stop = asyncio.Task(stop_event.wait())
def __aiter__(self):
return self
def __anext__(self):
if self.stop_event.is_set():
raise StopAsyncIteration
return self.future()
async def future(self):
ev_task = asyncio.Task(self.iterator.__anext__())
stop_task = self.wait_for_stop
done, pending = await asyncio.wait(
{ev_task, stop_task},
return_when=asyncio.FIRST_COMPLETED,
)
if stop_task in done:
raise StopAsyncIteration
return done.pop().result()
class EventReader:
"""Reads input events from a single device and distributes them.
There is one EventReader object for each source, which tells multiple mapping_handlers
that a new event is ready so that they can inject all sorts of funny
things.
Other devnodes may be present for the hardware device, in which case this
needs to be created multiple times.
"""
def __init__(
self,
context: Context,
source: evdev.InputDevice,
forward_to: evdev.UInput,
stop_event: asyncio.Event,
) -> None:
"""Initialize all mapping_handlers
Parameters
----------
source : evdev.InputDevice
where to read keycodes from
forward_to : evdev.UInput
where to write keycodes to that were not mapped to anything.
Should be an UInput with capabilities that work for all forwarded
events, so ideally they should be copied from source.
"""
self._source = source
self._forward_to = forward_to
self.context = context
self.stop_event = stop_event
def send_to_handlers(self, event: InputEvent) -> bool:
"""Send the event to callback."""
if event.type == evdev.ecodes.EV_MSC:
return False
if event.type == evdev.ecodes.EV_SYN:
return False
results = set()
for callback in self.context.callbacks.get(event.type_and_code) or ():
results.add(callback(event, source=self._source, forward=self._forward_to))
return True in results
async def send_to_listeners(self, event: InputEvent) -> None:
"""Send the event to listeners."""
if event.type == evdev.ecodes.EV_MSC:
return
if event.type == evdev.ecodes.EV_SYN:
return
for listener in self.context.listeners.copy():
# use a copy, since the listeners might remove themselves form the set
# fire and forget, run them in parallel and don't wait for them, since
# a listener might be blocking forever while waiting for more events.
asyncio.ensure_future(listener(event))
# Running macros have priority, give them a head-start for processing the
# event. If if_single injects a modifier, this modifier should be active
# before the next handler injects an "a" or something, so that it is
# possible to capitalize it via if_single.
# 1. Event from keyboard arrives (e.g. an "a")
# 2. the listener for if_single is called
# 3. if_single decides runs then (e.g. injects shift_L)
# 4. The original event is forwarded (or whatever it is supposed to do)
# 5. Capitalized "A" is injected.
# So make sure to call the listeners before notifying the handlers.
await asyncio.sleep(0)
def forward(self, event: InputEvent) -> None:
"""Forward an event, which injects it unmodified."""
if event.type == evdev.ecodes.EV_KEY:
logger.debug_key(event.event_tuple, "forwarding")
self._forward_to.write(*event.event_tuple)
async def handle(self, event: InputEvent) -> None:
if event.type == evdev.ecodes.EV_KEY and event.value == 2:
# button-hold event. Environments (gnome, etc.) create them on
# their own for the injection-fake-device if the release event
# won't appear, no need to forward or map them.
return
await self.send_to_listeners(event)
if not self.send_to_handlers(event):
# no handler took care of it, forward it
self.forward(event)
async def run(self):
"""Start doing things.
Can be stopped by stopping the asyncio loop. This loop
reads events from a single device only.
"""
logger.debug(
"Starting to listen for events from %s, fd %s",
self._source.path,
self._source.fd,
)
async for event in _ReadLoop(self._source, self.stop_event):
await self.handle(InputEvent.from_event(event))
self.context.reset()
logger.info("read loop for %s stopped", self._source.path)

@ -41,7 +41,7 @@ DEFAULT_UINPUTS = {
},
"mouse": {
evdev.ecodes.EV_KEY: [*range(0x110, 0x118)], # BTN_LEFT - BTN_TASK
evdev.ecodes.EV_REL: [*range(0x00, 0x0A)], # all REL axis
evdev.ecodes.EV_REL: [*range(0x00, 0x0D)], # all REL axis
},
}

@ -37,7 +37,7 @@ from inputremapper.logger import logger
from inputremapper.groups import classify, GAMEPAD, _Group
from inputremapper.injection.context import Context
from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock
from inputremapper.injection.consumer_control import ConsumerControl
from inputremapper.injection.event_reader import EventReader
from inputremapper.event_combination import EventCombination
@ -94,7 +94,8 @@ class Injector(multiprocessing.Process):
context: Optional[Context]
_state: int
_msg_pipe: multiprocessing.Pipe
_consumer_controls: List[ConsumerControl]
_consumer_controls: List[EventReader]
_stop_event: asyncio.Event
regrab_timeout = 0.2
@ -118,6 +119,7 @@ class Injector(multiprocessing.Process):
self.context = None # only needed inside the injection process
self._consumer_controls = []
self._stop_event = None
super().__init__(name=group)
@ -193,18 +195,14 @@ class Injector(multiprocessing.Process):
capabilities = device.capabilities(absinfo=False)
needed = False
for key, _ in self.context.preset:
if is_in_capabilities(key, capabilities):
logger.debug('Grabbing "%s" because of "%s"', path, key)
for mapping in self.context.preset:
if is_in_capabilities(mapping.event_combination, capabilities):
logger.debug(
'Grabbing "%s" because of "%s"', path, mapping.event_combination
)
needed = True
break
gamepad = classify(device) == GAMEPAD
if gamepad and self.context.maps_joystick():
logger.debug('Grabbing "%s" because of maps_joystick', path)
needed = True
if not needed:
# skipping reading and checking on events from those devices
# may be beneficial for performance.
@ -266,6 +264,11 @@ class Injector(multiprocessing.Process):
msg = self._msg_pipe[0].recv()
if msg == CLOSE:
logger.debug("Received close signal")
self._stop_event.set()
# give the event pipeline some time to reset devices
# before shutting the loop down
await asyncio.sleep(0.1)
# stop the event loop and cause the process to reach its end
# cleanly. Using .terminate prevents coverage from working.
loop.stop()
@ -310,6 +313,7 @@ class Injector(multiprocessing.Process):
# create this within the process after the event loop creation,
# so that the macros use the correct loop
self.context = Context(self.preset)
self._stop_event = asyncio.Event()
# grab devices as early as possible. If events appear that won't get
# released anymore before the grab they appear to be held down
@ -353,9 +357,11 @@ class Injector(multiprocessing.Process):
raise e
# actually doing things
consumer_control = ConsumerControl(self.context, source, forward_to)
coroutines.append(consumer_control.run())
self._consumer_controls.append(consumer_control)
event_reader = EventReader(
self.context, source, forward_to, self._stop_event
)
coroutines.append(event_reader.run())
self._consumer_controls.append(event_reader)
coroutines.append(self._msg_listener())

@ -37,15 +37,29 @@ w(1000).m(Shift_L, r(2, k(a))).w(10).k(b): <1s> A A <10ms> b
import asyncio
import copy
import math
import re
from evdev.ecodes import ecodes, EV_KEY, EV_REL, REL_X, REL_Y, REL_WHEEL, REL_HWHEEL
from typing import Optional, List, Callable, Awaitable, Tuple
import evdev
from evdev.ecodes import (
ecodes,
EV_KEY,
EV_REL,
REL_X,
REL_Y,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
REL_WHEEL,
REL_HWHEEL,
)
from inputremapper.logger import logger
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.ipc.shared_dict import SharedDict
from inputremapper.utils import PRESS, PRESS_NEGATIVE
from inputremapper.exceptions import MacroParsingError
Handler = Callable[[Tuple[int, int, int]], None]
MacroTask = Callable[[Handler], Awaitable]
macro_variables = SharedDict()
@ -88,21 +102,26 @@ def _type_check(value, allowed_types, display_name=None, position=None):
continue
# try to parse "1" as 1 if possible
try:
return allowed_type(value)
except (TypeError, ValueError):
pass
if allowed_type != Macro:
# the macro constructor with a single argument always succeeds,
# but will definitely not result in the correct macro
try:
return allowed_type(value)
except (TypeError, ValueError):
pass
if isinstance(value, allowed_type):
return value
if display_name is not None and position is not None:
raise TypeError(
f"Expected parameter {position} for {display_name} to be "
raise MacroParsingError(
msg=f"Expected parameter {position} for {display_name} to be "
f"one of {allowed_types}, but got {value}"
)
raise TypeError(f"Expected parameter to be one of {allowed_types}, but got {value}")
raise MacroParsingError(
msg=f"Expected parameter to be one of {allowed_types}, but got {value}"
)
def _type_check_symbol(keyname):
@ -115,7 +134,7 @@ def _type_check_symbol(keyname):
code = system_mapping.get(symbol)
if code is None:
raise KeyError(f'Unknown key "{symbol}"')
raise MacroParsingError(msg=f'Unknown key "{symbol}"')
return code
@ -130,7 +149,7 @@ def _type_check_variablename(name):
Not allowed: "1_foo", "foo=blub", "$foo", "foo,1234", "foo()"
"""
if not isinstance(name, str) or not re.match(r"^[A-Za-z_][A-Za-z_0-9]*$", name):
raise SyntaxError(f'"{name}" is not a legit variable name')
raise MacroParsingError(msg=f'"{name}" is not a legit variable name')
def _resolve(argument, allowed_types=None):
@ -177,7 +196,7 @@ class Macro:
4. `Macro.run` will run all tasks in self.tasks
"""
def __init__(self, code, context):
def __init__(self, code: str, context=None, mapping=None):
"""Create a macro instance that can be populated with tasks.
Parameters
@ -188,10 +207,11 @@ class Macro:
"""
self.code = code
self.context = context
self.mapping = mapping
# List of coroutines that will be called sequentially.
# This is the compiled code
self.tasks = []
self.tasks: List[MacroTask] = []
# can be used to wait for the release of the event
self._trigger_release_event = asyncio.Event()
@ -202,47 +222,26 @@ class Macro:
self.running = False
self.child_macros = []
self.child_macros: List[Macro] = []
self.keystroke_sleep_ms = None
self._new_event_arrived = asyncio.Event()
self._newest_event = None
self._newest_action = None
def notify(self, event, action):
"""Tell the macro about the newest event."""
for macro in self.child_macros:
macro.notify(event, action)
self._newest_event = event
self._newest_action = action
self._new_event_arrived.set()
async def _wait_for_event(self, filter=None):
"""Wait until a specific event arrives.
The parameters can be used to provide a filter. It will block
until an event arrives that matches them.
def is_holding(self):
"""Check if the macro is waiting for a key to be released."""
return not self._trigger_release_event.is_set()
Parameters
----------
filter : function
Receives the event. Stop waiting if it returns true.
"""
while True:
await self._new_event_arrived.wait()
self._new_event_arrived.clear()
def get_capabilities(self):
"""Get the merged capabilities of the macro and its children."""
capabilities = copy.deepcopy(self.capabilities)
if filter is not None:
if not filter(self._newest_event, self._newest_action):
continue
for macro in self.child_macros:
macro_capabilities = macro.get_capabilities()
for ev_type in macro_capabilities:
if ev_type not in capabilities:
capabilities[ev_type] = set()
break
capabilities[ev_type].update(macro_capabilities[ev_type])
def is_holding(self):
"""Check if the macro is waiting for a key to be released."""
return not self._trigger_release_event.is_set()
return capabilities
async def run(self, handler):
"""Run the macro.
@ -259,10 +258,7 @@ class Macro:
logger.error('Tried to run already running macro "%s"', self.code)
return
# newly arriving events are only interesting if they arrive after the
# macro started
self._new_event_arrived.clear()
self.keystroke_sleep_ms = self.context.preset.get("macros.keystroke_sleep_ms")
self.keystroke_sleep_ms = self.mapping.macro_key_sleep_ms
self.running = True
for task in self.tasks:
@ -480,19 +476,22 @@ class Macro:
speed = _type_check(speed, [int], "wheel", 2)
code, value = {
"up": (REL_WHEEL, 1),
"down": (REL_WHEEL, -1),
"left": (REL_HWHEEL, 1),
"right": (REL_HWHEEL, -1),
"up": ([REL_WHEEL, REL_WHEEL_HI_RES], [1 / 120, 1]),
"down": ([REL_WHEEL, REL_WHEEL_HI_RES], [-1 / 120, -1]),
"left": ([REL_HWHEEL, REL_HWHEEL_HI_RES], [1 / 120, 1]),
"right": ([REL_HWHEEL, REL_HWHEEL_HI_RES], [-1 / 120, -1]),
}[direction.lower()]
async def task(handler):
resolved_speed = _resolve(speed, [int])
remainder = [0.0, 0.0]
while self.is_holding():
handler(EV_REL, code, value)
# scrolling moves much faster than mouse, so this
# waits between injections instead to make it slower
await asyncio.sleep(1 / resolved_speed)
for i in range(0, 2):
float_value = value[i] * resolved_speed + remainder[i]
remainder[i] = math.fmod(float_value, 1)
if abs(float_value) >= 1:
handler(EV_REL, code[i], int(float_value))
await asyncio.sleep(1 / self.mapping.rate)
self.tasks.append(task)
@ -612,40 +611,32 @@ class Macro:
self.child_macros.append(else_)
async def task(handler):
triggering_event = (self._newest_event.type, self._newest_event.code)
listener_done = asyncio.Event()
def event_filter(event, action):
"""Which event may wake if_single up."""
# release event of the actual key
if (event.type, event.code) == triggering_event:
return True
async def listener(event):
if event.type != EV_KEY:
# ignore anything that is not a key
return
# press event of another key
if action in (PRESS, PRESS_NEGATIVE):
return True
if event.value == 1:
# another key was pressed, trigger else
listener_done.set()
return
coroutine = self._wait_for_event(event_filter)
resolved_timeout = _resolve(timeout, allowed_types=[int, float, None])
try:
if resolved_timeout is not None:
await asyncio.wait_for(coroutine, resolved_timeout / 1000)
else:
await coroutine
self.context.listeners.add(listener)
newest_event = (self._newest_event.type, self._newest_event.code)
# if newest_event == triggering_event, then no other key was pressed.
# if it is !=, then a new key was pressed in the meantime.
new_key_pressed = triggering_event != newest_event
resolved_timeout = _resolve(timeout, allowed_types=[int, float, None])
await asyncio.wait(
[listener_done.wait(), self._trigger_release_event.wait()],
timeout=resolved_timeout / 1000 if resolved_timeout else None,
return_when=asyncio.FIRST_COMPLETED,
)
if not new_key_pressed:
# no timeout and not combined
if then:
await then.run(handler)
return
except asyncio.TimeoutError:
pass
self.context.listeners.remove(listener)
if else_:
if not listener_done.is_set() and self._trigger_release_event.is_set():
await then.run(handler) # was trigger release
else:
await else_.run(handler)
self.tasks.append(task)

@ -23,11 +23,11 @@
import re
import traceback
import inspect
from inputremapper.logger import logger
from inputremapper.injection.macros.macro import Macro, Variable
from inputremapper.exceptions import MacroParsingError
def is_this_a_macro(output):
@ -164,7 +164,9 @@ def _count_brackets(macro):
openings = macro.count("(")
closings = macro.count(")")
if openings != closings:
raise SyntaxError(f"Found {openings} opening and {closings} closing brackets")
raise MacroParsingError(
macro, f"Found {openings} opening and {closings} closing brackets"
)
brackets = 0
position = 0
@ -204,7 +206,7 @@ def _is_number(value):
return False
def _parse_recurse(code, context, macro_instance=None, depth=0):
def _parse_recurse(code, context, mapping, macro_instance=None, depth=0):
"""Handle a subset of the macro, e.g. one parameter or function call.
Not using eval for security reasons.
@ -254,14 +256,14 @@ def _parse_recurse(code, context, macro_instance=None, depth=0):
if call is not None:
if macro_instance is None:
# start a new chain
macro_instance = Macro(code, context)
macro_instance = Macro(code, context, mapping)
else:
# chain this call to the existing instance
assert isinstance(macro_instance, Macro)
function = FUNCTIONS.get(call)
if function is None:
raise Exception(f"Unknown function {call}")
raise MacroParsingError(code, f"Unknown function {call}")
# get all the stuff inbetween
position = _count_brackets(code)
@ -276,15 +278,17 @@ def _parse_recurse(code, context, macro_instance=None, depth=0):
keyword_args = {}
for param in raw_string_args:
key, value = _split_keyword_arg(param)
parsed = _parse_recurse(value.strip(), context, None, depth + 1)
parsed = _parse_recurse(value.strip(), context, mapping, None, depth + 1)
if key is None:
if len(keyword_args) > 0:
msg = f'Positional argument "{key}" follows keyword argument'
raise SyntaxError(msg)
raise MacroParsingError(code, msg)
positional_args.append(parsed)
else:
if key in keyword_args:
raise SyntaxError(f'The "{key}" argument was specified twice')
raise MacroParsingError(
code, f'The "{key}" argument was specified twice'
)
keyword_args[key] = parsed
logger.debug(
@ -306,17 +310,20 @@ def _parse_recurse(code, context, macro_instance=None, depth=0):
else:
msg = f"{call} takes {min_args}, not {num_provided_args} parameters"
raise ValueError(msg)
raise MacroParsingError(code, msg)
use_safe_argument_names(keyword_args)
function(macro_instance, *positional_args, **keyword_args)
try:
function(macro_instance, *positional_args, **keyword_args)
except TypeError as err:
raise MacroParsingError(msg=str(err))
# is after this another call? Chain it to the macro_instance
if len(code) > position and code[position] == ".":
chain = code[position + 1 :]
logger.debug("%sfollowed by %s", space, chain)
_parse_recurse(chain, context, macro_instance, depth)
_parse_recurse(chain, context, mapping, macro_instance, depth)
return macro_instance
@ -333,9 +340,9 @@ def handle_plus_syntax(macro):
return macro
if "(" in macro or ")" in macro:
raise ValueError(
f'Mixing "+" and macros is unsupported: "{ macro}"'
) # TODO: MacroParsingError
raise MacroParsingError(
macro, f'Mixing "+" and macros is unsupported: "{ macro}"'
)
chunks = [chunk.strip() for chunk in macro.split("+")]
output = ""
@ -343,7 +350,7 @@ def handle_plus_syntax(macro):
for chunk in chunks:
if chunk == "":
# invalid syntax
raise ValueError(f'Invalid syntax for "{macro}"')
raise MacroParsingError(macro, f'Invalid syntax for "{macro}"')
depth += 1
output += f"modify({chunk},"
@ -400,12 +407,9 @@ def clean(code):
return remove_whitespaces(remove_comments(code), '"')
def parse(macro, context=None, return_errors=False):
def parse(macro, context=None, mapping=None):
"""parse and generate a Macro that can be run as often as you want.
If it could not be parsed, possibly due to syntax errors, will log the
error and return None.
Parameters
----------
macro : string
@ -413,36 +417,14 @@ def parse(macro, context=None, return_errors=False):
"repeat(2, key(a).key(KEY_A)).key(b)"
"wait(1000).modify(Shift_L, repeat(2, k(a))).wait(10, 20).key(b)"
context : Context, or None for use in Frontend
return_errors : bool
If True, returns errors as a string or None if parsing worked.
If False, returns the parsed macro.
mapping : the mapping for the macro, or None for use in Frontend
"""
logger.debug("parsing macro %s", macro)
macro = clean(macro)
macro = handle_plus_syntax(macro)
try:
macro = handle_plus_syntax(macro)
except Exception as error:
logger.error('Failed to parse macro "%s": %s', macro, error.__repr__())
# print the traceback in case this is a bug of input-remapper
logger.debug("".join(traceback.format_tb(error.__traceback__)).strip())
return f"{error.__class__.__name__}: {str(error)}" if return_errors else None
if return_errors:
logger.debug("checking the syntax of %s", macro)
else:
logger.debug("preparing macro %s for later execution", macro)
macro_obj = _parse_recurse(macro, context, mapping)
if not isinstance(macro_obj, Macro):
raise MacroParsingError(macro, "The provided code was not a macro")
try:
macro_object = _parse_recurse(macro, context)
if not isinstance(macro_object, Macro):
# someone put a single parameter like a string into this function, and
# it was most likely returned without modification. Not a macro
raise ValueError("The provided code was not a macro")
return macro_object if not return_errors else None
except Exception as error:
logger.error('Failed to parse macro "%s": %s', macro, error.__repr__())
# print the traceback in case this is a bug of input-remapper
logger.debug("".join(traceback.format_tb(error.__traceback__)).strip())
return f"{error.__class__.__name__}: {str(error)}" if return_errors else None
return macro_obj

@ -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/>.
import evdev
from typing import Optional, Dict, Tuple
from evdev.ecodes import EV_ABS
from inputremapper.configs.mapping import Mapping
from inputremapper.event_combination import EventCombination
from inputremapper.logger import logger
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
ContextProtocol,
HandlerEnums,
InputEventHandler,
)
class AbsToBtnHandler(MappingHandler):
"""Handler which transforms an EV_ABS to a button event"""
_input_event: InputEvent
_active: bool
_sub_handler: InputEventHandler
def __init__(
self,
combination: EventCombination,
mapping: Mapping,
**_,
):
super().__init__(combination, mapping)
self._active = False
self._input_event = combination[0]
assert self._input_event.value != 0
assert len(combination) == 1
def __str__(self):
return f"AbsToBtnHandler for {self._input_event.event_tuple} <{id(self)}>:"
def __repr__(self):
return self.__str__()
@property
def child(self): # used for logging
return self._sub_handler
def _trigger_point(self, abs_min: int, abs_max: int) -> Tuple[float, float]:
"""calculate the axis mid and trigger point"""
# TODO: potentially cash this function
if abs_min == -1 and abs_max == 1:
# this is a hat switch
return (
self._input_event.value // abs(self._input_event.value),
0,
) # return +-1
half_range = (abs_max - abs_min) / 2
middle = half_range + abs_min
trigger_offset = half_range * self._input_event.value / 100
# threshold, middle
return middle + trigger_offset, middle
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
forward: evdev.UInput,
supress: bool = False,
) -> bool:
if event.type_and_code != self._input_event.type_and_code:
return False
absinfo = {
entry[0]: entry[1] for entry in source.capabilities(absinfo=True)[EV_ABS]
}
threshold, mid_point = self._trigger_point(
absinfo[event.code].min, absinfo[event.code].max
)
value = event.value
if (value < threshold > mid_point) or (value > threshold < mid_point):
if self._active:
event = event.modify(value=0, action=EventActions.as_key)
else:
# consume the event.
# We could return False to forward events
return True
else:
if not self._active:
event = event.modify(value=1, action=EventActions.as_key)
else:
# consume the event.
# We could return False to forward events
return True
self._active = bool(event.value)
logger.debug_key(event.event_tuple, "sending to sub_handler")
return self._sub_handler.notify(
event,
source=source,
forward=forward,
supress=supress,
)
def reset(self) -> None:
self._active = False
self._sub_handler.reset()

@ -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 functools import partial
import evdev
import time
import asyncio
import math
from typing import Dict, Tuple, Optional, List, Union
from evdev.ecodes import (
EV_REL,
EV_ABS,
REL_WHEEL,
REL_HWHEEL,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
)
from inputremapper.configs.mapping import Mapping
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
HandlerEnums,
InputEventHandler,
)
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
from inputremapper.logger import logger
from inputremapper.event_combination import EventCombination
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.injection.global_uinputs import global_uinputs
async def _run_normal(self) -> None:
"""start injecting events"""
self._running = True
self._stop = False
# logger.debug("starting AbsToRel loop")
remainder = 0.0
start = time.time()
while not self._stop:
float_value = self._value + remainder
# float_value % 1 will result in wrong calculations for negative values
remainder = math.fmod(float_value, 1)
value = int(float_value)
self._write(EV_REL, self.mapping.output_code, value)
time_taken = time.time() - start
await asyncio.sleep(max(0.0, (1 / self.mapping.rate) - time_taken))
start = time.time()
# logger.debug("stopping AbsToRel loop")
self._running = False
async def _run_wheel(
self, codes: Tuple[int, int], weights: Tuple[float, float]
) -> None:
"""start injecting events"""
self._running = True
self._stop = False
# logger.debug("starting AbsToRel loop")
remainder = [0.0, 0.0]
start = time.time()
while not self._stop:
for i in range(0, 2):
float_value = self._value * weights[i] + remainder[i]
# float_value % 1 will result in wrong calculations for negative values
remainder[i] = math.fmod(float_value, 1)
value = int(float_value)
self._write(EV_REL, codes[i], value)
time_taken = time.time() - start
await asyncio.sleep(max(0.0, (1 / self.mapping.rate) - time_taken))
start = time.time()
# logger.debug("stopping AbsToRel loop")
self._running = False
class AbsToRelHandler(MappingHandler):
"""Handler which transforms an EV_ABS to EV_REL events"""
_map_axis: Tuple[int, int] # the (type, code) of the axis we map
_value: float # the current output value
_running: bool # if the run method is active
_stop: bool # if the run loop should return
_transform: Optional[Transformation]
def __init__(
self,
combination: EventCombination,
mapping: Mapping,
**_,
) -> None:
super().__init__(combination, mapping)
# find the input event we are supposed to map
for event in combination:
if event.value == 0:
assert event.type == EV_ABS
self._map_axis = event.type_and_code
break
self._value = 0
self._running = False
self._stop = True
self._transform = None
# bind the correct run method
if self.mapping.output_code in (
REL_WHEEL,
REL_HWHEEL,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
):
if self.mapping.output_code in (REL_WHEEL, REL_WHEEL_HI_RES):
codes = (REL_WHEEL, REL_WHEEL_HI_RES)
else:
codes = (REL_HWHEEL, REL_HWHEEL_HI_RES)
if self.mapping.output_code in (REL_WHEEL, REL_HWHEEL):
weights = (1.0, 120.0)
else:
weights = (1 / 120, 1)
self._run = partial(_run_wheel, self, codes=codes, weights=weights)
else:
self._run = partial(_run_normal, self)
def __str__(self):
return f"AbsToRelHandler for {self._map_axis} <{id(self)}>:"
def __repr__(self):
return self.__str__()
@property
def child(self): # used for logging
return f"maps to: {self.mapping.output_code} at {self.mapping.target_uinput}"
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
forward: evdev.UInput = None,
supress: bool = False,
) -> bool:
if event.type_and_code != self._map_axis:
return False
if event.action == EventActions.recenter:
self._stop = True
return True
if not self._transform:
absinfo = {
entry[0]: entry[1]
for entry in source.capabilities(absinfo=True)[EV_ABS]
}
self._transform = Transformation(
max_=absinfo[event.code].max,
min_=absinfo[event.code].min,
deadzone=self.mapping.deadzone,
gain=self.mapping.gain,
expo=self.mapping.expo,
)
self._value = self._transform(event.value) * self.mapping.rel_speed
if self._value == 0:
self._stop = True
return True
if not self._running:
asyncio.ensure_future(self._run())
return True
def reset(self) -> None:
self._stop = True
def _write(self, ev_type, keycode, value):
"""Inject."""
# if the mouse won't move even though correct stuff is written here,
# the capabilities are probably wrong
if value == 0:
return # rel 0 does not make sense
try:
global_uinputs.write((ev_type, keycode, value), self.mapping.target_uinput)
except OverflowError:
# screwed up the calculation of mouse movements
logger.error("OverflowError (%s, %s, %s)", ev_type, keycode, value)
def needs_wrapping(self) -> bool:
return len(self.input_events) > 1
def set_sub_handler(self, handler: InputEventHandler) -> None:
assert False # cannot have a sub-handler
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
if self.needs_wrapping():
return {EventCombination(self.input_events): HandlerEnums.axisswitch}
return {}

@ -0,0 +1,141 @@
#!/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 typing import Dict, Tuple
import evdev
from inputremapper.logger import logger
from inputremapper.configs.mapping import Mapping
from inputremapper.event_combination import EventCombination
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
ContextProtocol,
HandlerEnums,
InputEventHandler,
)
from inputremapper.input_event import InputEvent, EventActions
class AxisSwitchHandler(MappingHandler):
"""enables or disables an axis"""
_map_axis: Tuple[int, int] # the axis we switch on or off (type and code)
_trigger_key: Tuple[Tuple[int, int]] # all events that can switch the axis
_active: bool # whether the axis is on or off
_last_value: int # the value of the last axis event that arrived
_axis_source: evdev.InputDevice # the cashed source of the axis
_forward_device: evdev.UInput # the cashed forward uinput
_sub_handler: InputEventHandler
def __init__(
self,
combination: EventCombination,
mapping: Mapping,
**_,
):
super().__init__(combination, mapping)
map_axis = [
event.type_and_code for event in combination if not event.is_key_event
]
trigger_keys = [
event.type_and_code for event in combination if event.is_key_event
]
assert len(map_axis) != 0
assert len(trigger_keys) >= 1
self._map_axis = map_axis[0]
self._trigger_keys = tuple(trigger_keys)
self._active = False
self._last_value = 0
self._axis_source = None
self._forward_device = None
def __str__(self):
return f"AxisSwitchHandler for {self._map_axis} <{id(self)}>"
def __repr__(self):
return self.__str__()
@property
def child(self):
return self._sub_handler
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
forward: evdev.UInput,
supress: bool = False,
) -> bool:
if (
event.type_and_code not in self._trigger_keys
and event.type_and_code != self._map_axis
):
return False
if event.is_key_event:
if self._active == bool(event.value):
# nothing changed
return False
self._active = bool(event.value)
if not self._active:
# recenter the axis
logger.debug_key(self.mapping.event_combination, "stopping axis")
event = InputEvent(
0, 0, *self._map_axis, 0, action=EventActions.recenter
)
self._sub_handler.notify(event, self._axis_source, self._forward_device)
elif self._map_axis[0] == evdev.ecodes.EV_ABS:
# send the last cached value so that the abs axis
# is at the correct position
logger.debug_key(self.mapping.event_combination, "starting axis")
event = InputEvent(0, 0, *self._map_axis, self._last_value)
self._sub_handler.notify(event, self._axis_source, self._forward_device)
else:
logger.debug_key(self.mapping.event_combination, "starting axis")
return True
# do some caching so that we can generate the
# recenter event and an initial abs event
if not self._forward_device:
self._forward_device = forward
self._axis_source = source
# always cache the value
self._last_value = event.value
if self._active:
return self._sub_handler.notify(event, source, forward, supress)
return False
def reset(self) -> None:
self._last_value = 0
self._active = False
self._sub_handler.reset()
def needs_wrapping(self) -> bool:
return True
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
combination = [event for event in self.input_events if event.is_key_event]
return {EventCombination(combination): HandlerEnums.combination}

@ -0,0 +1,130 @@
#!/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 math
from typing import Dict, Union
class Transformation:
"""callable that returns the axis transformation at x"""
def __init__(
self, max_: int, min_: int, deadzone: float, gain: float = 1, expo: float = 0
) -> None:
self._max = max_
self._min = min_
self._deadzone = deadzone
self._gain = gain
self._expo = expo
self._cache: Dict[float, float] = {}
def __call__(self, /, x: Union[int, float]) -> float:
if x not in self._cache:
y = (
self._calc_qubic(self._flatten_deadzone(self._normalize(x)))
* self._gain
)
self._cache[x] = y
return self._cache[x]
def _normalize(self, x: Union[int, float]) -> float:
"""
move and scale x to be between -1 and 1
return: x
"""
if self._min == -1 and self._max == 1:
return x
half_range = (self._max - self._min) / 2
middle = half_range + self._min
return (x - middle) / half_range
def _flatten_deadzone(self, x: float) -> float:
"""
y ^ y ^
| |
1 | / 1 | /
| / | /
| / ==> | ---
| / | /
-1 | / -1 | /
|------------> |------------>
-1 1 x -1 1 x
"""
if abs(x) <= self._deadzone:
return 0
return (x - self._deadzone * x / abs(x)) / (1 - self._deadzone)
def _calc_qubic(self, x: float) -> float:
"""
transforms an x value by applying a qubic function
k = 0 : will yield no transformation f(x) = x
1 > k > 0 : will yield low sensitivity for low x values
and high sensitivity for high x values
-1 < k < 0 : will yield high sensitivity for low x values
and low sensitivity for high x values
see also: https://www.geogebra.org/calculator/mkdqueky
Mathematical definition:
f(x,d) = d * x + (1 - d) * x ** 3 | d = 1 - k | k [0,1]
the function is designed such that if follows these constraints:
f'(0, d) = d and f(1, d) = 1 and f(-x,d) = -f(x,d)
for k [-1,0) the above function is mirrored at y = x
and d = 1 + k
"""
k = self._expo
if k == 0 or x == 0:
return x
if 0 < k <= 1:
d = 1 - k
return d * x + (1 - d) * x**3
if -1 <= k < 0:
# calculate return value with the real inverse solution
# of y = b * x + a * x ** 3
# LaTeX for better readability:
#
# y=\frac{{{\left( \sqrt{27 {{x}^{2}}+\frac{4 {{b}^{3}}}{a}}
# +{{3}^{\frac{3}{2}}} x\right) }^{\frac{1}{3}}}}
# {{{2}^{\frac{1}{3}}} \sqrt{3} {{a}^{\frac{1}{3}}}}
# -\frac{{{2}^{\frac{1}{3}}} b}
# {\sqrt{3} {{a}^{\frac{2}{3}}}
# {{\left( \sqrt{27 {{x}^{2}}+\frac{4 {{b}^{3}}}{a}}
# +{{3}^{\frac{3}{2}}} x\right) }^{\frac{1}{3}}}}
sign = x / abs(x)
x = math.fabs(x)
d = 1 + k
a = 1 - d
b = d
c = (math.sqrt(27 * x**2 + (4 * b**3) / a) + 3 ** (3 / 2) * x) ** (
1 / 3
)
y = c / (2 ** (1 / 3) * math.sqrt(3) * a ** (1 / 3)) - (
2 ** (1 / 3) * b
) / (math.sqrt(3) * a ** (2 / 3) * c)
return y * sign
raise ValueError("k must be between -1 and 1")

@ -0,0 +1,160 @@
#!/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 asyncio
import evdev
from typing import Dict, Tuple, Optional, List
from evdev.ecodes import EV_ABS, EV_REL, EV_KEY
from inputremapper.configs.mapping import Mapping
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.event_combination import EventCombination
from inputremapper.logger import logger
from inputremapper.injection.mapping_handlers.mapping_handler import (
ContextProtocol,
MappingHandler,
InputEventHandler,
HandlerEnums,
)
class CombinationHandler(MappingHandler):
"""keeps track of a combination and notifies a sub handler"""
# map of (event.type, event.code) -> bool , keep track of the combination state
_pressed_keys: Dict[Tuple[int, int], bool]
_output_state: bool # the last update we sent to a sub-handler
_sub_handler: InputEventHandler
def __init__(
self,
combination: EventCombination,
mapping: Mapping,
**_,
) -> None:
logger.debug(mapping)
super().__init__(combination, mapping)
self._pressed_keys = {}
self._output_state = False
# prepare a key map for all events with non-zero value
for event in combination:
assert event.is_key_event
self._pressed_keys[event.type_and_code] = False
assert len(self._pressed_keys) > 0 # no combination handler without a key
def __str__(self):
return f"CombinationHandler for {self.mapping.event_combination} <{id(self)}>:"
def __repr__(self):
return self.__str__()
@property
def child(self): # used for logging
return self._sub_handler
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
forward: evdev.UInput,
supress: bool = False,
) -> bool:
type_code = event.type_and_code
if type_code not in self._pressed_keys.keys():
return False # we are not responsible for the event
last_state = self.get_active()
self._pressed_keys[type_code] = event.value == 1
if self.get_active() == last_state or self.get_active() == self._output_state:
# nothing changed
if self._output_state:
# combination is active, consume the event
return True
else:
# combination inactive, forward the event
return False
if self.get_active():
# send key up events to the forwarded uinput
self.forward_release(forward)
event = event.modify(value=1)
else:
if self._output_state:
# we ignore the supress argument for release events
# otherwise we might end up with stuck keys
# (test_event_pipeline.test_combination)
supress = False
event = event.modify(value=0)
if supress:
return False
logger.debug_key(
self.mapping.event_combination, "triggered: sending to sub-handler"
)
self._output_state = bool(event.value)
return self._sub_handler.notify(event, source, forward, supress)
def reset(self) -> None:
self._sub_handler.reset()
for key in self._pressed_keys:
self._pressed_keys[key] = False
self._output_state = False
def get_active(self) -> bool:
"""return if all keys in the keymap are set to True"""
return False not in self._pressed_keys.values()
def forward_release(self, forward: evdev.UInput) -> None:
"""forward a button release for all keys if this is a combination
this might cause duplicate key-up events but those are ignored by evdev anyway
"""
if (
len(self.mapping.event_combination) == 1
or not self.mapping.release_combination_keys
):
return
for event in self.mapping.event_combination:
forward.write(*event.type_and_code, 0)
forward.syn()
def needs_ranking(self) -> bool:
return bool(self.input_events)
def rank_by(self) -> EventCombination:
return EventCombination(
event for event in self.input_events if event.value != 0
)
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
return_dict = {}
for event in self.input_events:
if event.type == EV_ABS and event.value != 0:
return_dict[EventCombination(event)] = HandlerEnums.abs2btn
if event.type == EV_REL and event.value != 0:
return_dict[EventCombination(event)] = HandlerEnums.rel2btn
return return_dict

@ -0,0 +1,94 @@
#!/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 asyncio
import evdev
from evdev.ecodes import EV_KEY, EV_ABS, EV_REL
from typing import List, Optional, Dict
from inputremapper.event_combination import EventCombination
from inputremapper.input_event import InputEvent
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
InputEventHandler,
HandlerEnums,
)
class HierarchyHandler(MappingHandler):
"""
handler consisting of an ordered list of MappingHandler
only the first handler which successfully handles the event will execute it,
all other handlers will be notified, but suppressed
"""
_input_event: InputEvent
def __init__(self, handlers: List[MappingHandler], event: InputEvent) -> None:
self.handlers = handlers
self._input_event = event
combination = EventCombination(event)
# use the mapping from the first child TODO: find a better solution
mapping = handlers[0].mapping
super().__init__(combination, mapping)
def __str__(self):
return f"HierarchyHandler for {self._input_event} <{id(self)}>:"
def __repr__(self):
return self.__str__()
@property
def child(self): # used for logging
return self.handlers
def notify(
self,
event: InputEvent,
source: evdev.InputDevice = None,
forward: evdev.UInput = None,
supress: bool = False,
) -> bool:
if event.type_and_code != self._input_event.type_and_code:
return False
success = False
for handler in self.handlers:
if not success:
success = handler.notify(event, source, forward)
else:
handler.notify(event, source, forward, supress=True)
return success
def reset(self) -> None:
for sub_handler in self.handlers:
sub_handler.reset()
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
if self._input_event.type == EV_ABS and self._input_event.value != 0:
return {EventCombination(self._input_event): HandlerEnums.abs2btn}
if self._input_event.type == EV_REL and self._input_event.value != 0:
return {EventCombination(self._input_event): HandlerEnums.rel2btn}
return {}
def set_sub_handler(self, handler: InputEventHandler) -> None:
assert False

@ -0,0 +1,92 @@
#!/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 typing import Tuple, Dict, Optional
from inputremapper import exceptions
from inputremapper.configs.mapping import Mapping
from inputremapper.event_combination import EventCombination
from inputremapper.exceptions import MappingParsingError
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
ContextProtocol,
HandlerEnums,
)
from inputremapper.logger import logger
from inputremapper.input_event import InputEvent
from inputremapper.injection.global_uinputs import global_uinputs
class KeyHandler(MappingHandler):
"""injects the target key if notified"""
_active: bool
_maps_to: Tuple[int, int]
def __init__(
self,
combination: EventCombination,
mapping: Mapping,
**_,
):
super().__init__(combination, mapping)
maps_to = mapping.get_output_type_code()
if not maps_to:
raise MappingParsingError(
"unable to create key handler from mapping", mapping=mapping
)
self._maps_to = maps_to
self._active = False
def __str__(self):
return f"KeyHandler <{id(self)}>:"
def __repr__(self):
return self.__str__()
@property
def child(self): # used for logging
return f"maps to: {self._maps_to} on {self.mapping.target_uinput}"
def notify(self, event: InputEvent, *_, **__) -> bool:
"""inject event.value to the target key"""
event_tuple = (*self._maps_to, event.value)
try:
global_uinputs.write(event_tuple, self.mapping.target_uinput)
logger.debug_key(event_tuple, "sending to %s", self.mapping.target_uinput)
self._active = bool(event.value)
return True
except exceptions.Error:
return False
def reset(self) -> None:
logger.debug("resetting key_handler")
if self._active:
event_tuple = (*self._maps_to, 0)
global_uinputs.write(event_tuple, self.mapping.target_uinput)
self._active = False
def needs_wrapping(self) -> bool:
return True
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
return {EventCombination(self.input_events): HandlerEnums.combination}

@ -0,0 +1,101 @@
#!/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 asyncio
from typing import Dict, Optional
from inputremapper.configs.mapping import Mapping
from inputremapper.event_combination import EventCombination
from inputremapper.logger import logger
from inputremapper.input_event import InputEvent
from inputremapper.injection.global_uinputs import global_uinputs
from inputremapper.injection.macros.parse import parse
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.mapping_handlers.mapping_handler import (
ContextProtocol,
MappingHandler,
HandlerEnums,
)
class MacroHandler(MappingHandler):
"""runs the target macro if notified"""
# TODO: replace this by the macro itself
_macro: Macro
_active: bool
def __init__(
self,
combination: EventCombination,
mapping: Mapping,
*,
context: ContextProtocol,
):
super().__init__(combination, mapping)
self._active = False
self._macro = parse(self.mapping.output_symbol, context, mapping)
def __str__(self):
return f"MacroHandler <{id(self)}>:"
def __repr__(self):
return self.__str__()
@property
def child(self): # used for logging
return f"maps to {self._macro} on {self.mapping.target_uinput}"
def notify(self, event: InputEvent, *_, **__) -> bool:
if event.value == 1:
self._active = True
self._macro.press_trigger()
if self._macro.running:
return True
def f(ev_type, code, value) -> None:
"""Handler for macros."""
logger.debug_key(
(ev_type, code, value),
"sending from macro to %s",
self.mapping.target_uinput,
)
global_uinputs.write((ev_type, code, value), self.mapping.target_uinput)
asyncio.ensure_future(self._macro.run(f))
return True
else:
self._active = False
if self._macro.is_holding():
self._macro.release_trigger()
return True
def reset(self) -> None:
self._active = False
if self._macro.is_holding():
self._macro.release_trigger()
def needs_wrapping(self) -> bool:
return True
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
return {EventCombination(self.input_events): HandlerEnums.combination}

@ -0,0 +1,198 @@
#!/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/>.
"""provides protocols for mapping handlers
*** The architecture behind mapping handlers ***
Handling an InputEvent is done in 3 steps:
1. Input Event Handling
A MappingHandler that does Input event handling receives Input Events directly from the EventReader.
To do so it must implement the InputEventHandler protocol.
A InputEventHandler may handle multiple events (InputEvent.type_and_code)
2. Event Transformation
The event gets transformed as described by the mapping.
e.g.: combining multiple events to a single one
transforming EV_ABS to EV_REL
macros
...
Multiple transformations may get chained
3. Event Injection
The transformed event gets injected to a global_uinput
MappingHandlers can implement one or more of these steps.
Overview of implemented handlers and the steps they implement:
Step 1:
- HierarchyHandler
Step 1 and 2:
- CombinationHandler
- AbsToBtnHandler
- RelToBtnHandler
Step 1, 2 and 3:
- AbsToRelHandler
Step 2 and 3:
- KeyHandler
- MacroHandler
"""
from __future__ import annotations
import enum
import evdev
from typing import Dict, Protocol, Set, Optional, List
from inputremapper.configs.mapping import Mapping
from inputremapper.configs.preset import Preset
from inputremapper.exceptions import MappingParsingError
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.event_combination import EventCombination
from inputremapper.logger import logger
class EventListener(Protocol):
async def __call__(self, event: evdev.InputEvent) -> None:
...
class ContextProtocol(Protocol):
"""the parts from context needed for macros"""
preset: Preset
listeners: Set[EventListener]
class InputEventHandler(Protocol):
"""the protocol any handler, which can be part of an event pipeline, must follow"""
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
forward: evdev.UInput,
supress: bool = False,
) -> bool:
...
def reset(self) -> None:
"""reset the state of the handler e.g. release any buttons"""
...
class HandlerEnums(enum.Enum):
# converting to btn
abs2btn = enum.auto()
rel2btn = enum.auto()
macro = enum.auto()
key = enum.auto()
# converting to "analog"
btn2rel = enum.auto()
rel2rel = enum.auto()
abs2rel = enum.auto()
btn2abs = enum.auto()
rel2abs = enum.auto()
abs2abs = enum.auto()
# special handlers
combination = enum.auto()
hierarchy = enum.auto()
axisswitch = enum.auto()
disable = enum.auto()
class MappingHandler(InputEventHandler):
"""
the protocol a InputEventHandler must follow if it should be
dynamically integrated in an event-pipeline by the mapping parser
"""
mapping: Mapping
# all input events this handler cares about
# should always be a subset of mapping.event_combination
input_events: List[InputEvent]
_sub_handler: Optional[InputEventHandler]
# https://bugs.python.org/issue44807
def __init__(
self,
combination: EventCombination,
mapping: Mapping,
**_,
) -> None:
"""initialize the handler
Parameters
----------
combination : EventCombination
the combination from sub_handler.wrap_with()
mapping : Mapping
"""
new_combination = []
for event in combination:
if event.value != 0:
event = event.modify(action=EventActions.as_key)
new_combination.append(event)
self.mapping = mapping
self.input_events = new_combination
self._sub_handler = None
def needs_wrapping(self) -> bool:
"""if this handler needs to be wrapped in another MappingHandler"""
return len(self.wrap_with()) > 0
def needs_ranking(self) -> bool:
"""if this handler needs ranking and wrapping with a HierarchyHandler"""
return False
def rank_by(self) -> Optional[EventCombination]:
"""the combination for which this handler needs ranking"""
pass
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
"""a dict of EventCombination -> HandlerEnums"""
# this handler should be wrapped with the MappingHandler corresponding
# to the HandlerEnums, and the EventCombination as first argument
# TODO: better explanation
return {}
def set_sub_handler(self, handler: InputEventHandler) -> None:
"""give this handler a sub_handler"""
self._sub_handler = handler
def occlude_input_event(self, event: InputEvent) -> None:
"""remove the event from self.input_events"""
if not self.input_events:
logger.debug_mapping_handler(self)
raise MappingParsingError(
"cannot remove a non existing event", mapping_handler=self
)
# should be called for each event a wrapping-handler
# has in its input_events EventCombination
self.input_events.remove(event)

@ -0,0 +1,320 @@
#!/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/>.
"""functions to assemble the mapping handlers"""
from typing import Dict, List, Type, Optional, Set, Iterable, Sized, Tuple, Sequence
from evdev.ecodes import (
EV_KEY,
EV_ABS,
EV_REL,
)
from inputremapper.exceptions import MappingParsingError
from inputremapper.logger import logger
from inputremapper.event_combination import EventCombination
from inputremapper.input_event import InputEvent
from inputremapper.injection.mapping_handlers.mapping_handler import (
HandlerEnums,
MappingHandler,
ContextProtocol,
)
from inputremapper.injection.mapping_handlers.combination_handler import (
CombinationHandler,
)
from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler
from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler
from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler
from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler
from inputremapper.injection.mapping_handlers.key_handler import KeyHandler
from inputremapper.injection.mapping_handlers.axis_switch_handler import (
AxisSwitchHandler,
)
from inputremapper.injection.mapping_handlers.null_handler import NullHandler
from inputremapper.injection.macros.parse import is_this_a_macro
from inputremapper.configs.preset import Preset
from inputremapper.configs.mapping import Mapping
from inputremapper.configs.system_mapping import DISABLE_CODE, DISABLE_NAME
EventPipelines = Dict[InputEvent, List[MappingHandler]]
mapping_handler_classes: Dict[HandlerEnums, Type[MappingHandler]] = {
# all available mapping_handlers
HandlerEnums.abs2btn: AbsToBtnHandler,
HandlerEnums.rel2btn: RelToBtnHandler,
HandlerEnums.macro: MacroHandler,
HandlerEnums.key: KeyHandler,
HandlerEnums.btn2rel: None, # type: ignore
HandlerEnums.rel2rel: None, # type: ignore
HandlerEnums.abs2rel: AbsToRelHandler,
HandlerEnums.btn2abs: None, # type: ignore
HandlerEnums.rel2abs: None, # type: ignore
HandlerEnums.abs2abs: None, # type: ignore
HandlerEnums.combination: CombinationHandler,
HandlerEnums.hierarchy: HierarchyHandler,
HandlerEnums.axisswitch: AxisSwitchHandler,
HandlerEnums.disable: NullHandler,
}
def parse_mappings(preset: Preset, context: ContextProtocol) -> EventPipelines:
"""create a dict with a list of MappingHandler for each InputEvent"""
handlers = []
for mapping in preset:
# start with the last handler in the chain, each mapping only has one output,
# but may have multiple inputs, therefore the last handler is a good starting
# point to assemble the pipeline
handler_enum = _get_output_handler(mapping)
constructor = mapping_handler_classes[handler_enum]
if not constructor:
raise NotImplementedError(
f"mapping handler {handler_enum} is not implemented"
)
output_handler = constructor(
mapping.event_combination, mapping, context=context
)
# layer other handlers on top until the outer handler needs ranking or can
# directly handle a input event
handlers.extend(_create_event_pipeline(output_handler, context))
# figure out which handlers need ranking and wrap them with hierarchy_handlers
need_ranking = {}
for handler in handlers.copy():
if handler.needs_ranking():
combination = handler.rank_by()
if not combination:
raise MappingParsingError(
f"{type(handler).__name__} claims to need ranking but does not "
f"return a combination to rank by",
mapping_handler=handler,
)
need_ranking[combination] = handler
handlers.remove(handler)
# the HierarchyHandler's might not be the starting point of the event pipeline
# layer other handlers on top again.
ranked_handlers = _create_hierarchy_handlers(need_ranking)
for handler in ranked_handlers:
handlers.extend(_create_event_pipeline(handler, context, ignore_ranking=True))
# group all handlers by the input events they take care of. One handler might end
# up in multiple groups if it takes care of multiple InputEvents
event_pipelines: EventPipelines = {}
for handler in handlers:
assert handler.input_events
for event in handler.input_events:
if event in event_pipelines.keys():
logger.debug("event-pipeline with entry point: %s", event.type_and_code)
logger.debug_mapping_handler(handler)
event_pipelines[event].append(handler)
else:
logger.debug("event-pipeline with entry point: %s", event.type_and_code)
logger.debug_mapping_handler(handler)
event_pipelines[event] = [handler]
return event_pipelines
def _create_event_pipeline(
handler: MappingHandler, context: ContextProtocol, ignore_ranking=False
) -> List[MappingHandler]:
"""
recursively wrap a handler with other handlers until the
outer handler needs ranking or is finished wrapping
"""
if not handler.needs_wrapping() or (handler.needs_ranking() and not ignore_ranking):
return [handler]
handlers = []
for combination, handler_enum in handler.wrap_with().items():
constructor = mapping_handler_classes[handler_enum]
if not constructor:
raise NotImplementedError(
f"mapping handler {handler_enum} is not implemented"
)
super_handler = constructor(combination, handler.mapping, context=context)
super_handler.set_sub_handler(handler)
for event in combination:
# the handler now has a super_handler which takes care about the events.
# so we need to hide them on the handler
handler.occlude_input_event(event)
handlers.extend(_create_event_pipeline(super_handler, context))
if handler.input_events:
# the handler was only partially wrapped,
# we need to return it as a toplevel handler
handlers.append(handler)
return handlers
def _get_output_handler(mapping: Mapping) -> HandlerEnums:
"""
determine the correct output handler
this is used as a starting point for the mapping parser
"""
if mapping.output_code == DISABLE_CODE or mapping.output_symbol == DISABLE_NAME:
return HandlerEnums.disable
if mapping.output_symbol:
if is_this_a_macro(mapping.output_symbol):
return HandlerEnums.macro
else:
return HandlerEnums.key
if mapping.output_type == EV_KEY:
return HandlerEnums.key
input_event = _maps_axis(mapping.event_combination)
if not input_event:
raise MappingParsingError(
f"this {mapping = } does not map to an axis, key or macro", mapping=Mapping
)
if mapping.output_type == EV_REL:
if input_event.type == EV_KEY:
return HandlerEnums.btn2rel
if input_event.type == EV_REL:
return HandlerEnums.rel2rel
if input_event.type == EV_ABS:
return HandlerEnums.abs2rel
if mapping.output_type == EV_ABS:
if input_event.type == EV_KEY:
return HandlerEnums.btn2abs
if input_event.type == EV_REL:
return HandlerEnums.rel2abs
if input_event.type == EV_ABS:
return HandlerEnums.abs2abs
raise MappingParsingError(f"the output of {mapping = } is unknown", mapping=Mapping)
def _maps_axis(combination: EventCombination) -> Optional[InputEvent]:
"""
whether this EventCombination contains an InputEvent that is treated as
an axis and not a binary (key or button) event.
"""
for event in combination:
if event.value == 0:
return event
return None
def _create_hierarchy_handlers(
handlers: Dict[EventCombination, MappingHandler]
) -> Set[MappingHandler]:
"""sort handlers by input events and create Hierarchy handlers"""
sorted_handlers = set()
all_combinations = handlers.keys()
events = set()
# gather all InputEvents from all handlers
for combination in all_combinations:
for event in combination:
events.add(event)
# create a ranking for each event
for event in events:
# find all combinations (from handlers) which contain the event
combinations_with_event = [
combination for combination in all_combinations if event in combination
]
if len(combinations_with_event) == 1:
# there was only one handler containing that event return it as is
sorted_handlers.add(handlers[combinations_with_event[0]])
continue
# there are multiple handler with the same event.
# rank them and create the HierarchyHandler
sorted_combinations = _order_combinations(combinations_with_event, event)
sub_handlers = []
for combination in sorted_combinations:
sub_handlers.append(handlers[combination])
sorted_handlers.add(HierarchyHandler(sub_handlers, event))
for handler in sub_handlers:
# the handler now has a HierarchyHandler which takes care about this event.
# so we hide need to hide it on the handler
handler.occlude_input_event(event)
return sorted_handlers
def _order_combinations(
combinations: List[EventCombination], common_event: InputEvent
) -> List[EventCombination]:
"""reorder the keys according to some rules
such that a combination a+b+c is in front of a+b which is in front of b
for a+b+c vs. b+d+e: a+b+c would be in front of b+d+e, because the common key b
has the higher index in the a+b+c (1), than in the b+c+d (0) list
in this example b would be the common key
as for combinations like a+b+c and e+d+c with the common key c: ¯\\_()_/¯
Parameters
----------
combinations : List[Key]
the list which needs ordering
common_event : InputEvent
the Key all members of Keys have in common
"""
combinations.sort(key=len)
for start, end in ranges_with_constant_length(combinations.copy()):
sub_list = combinations[start:end]
sub_list.sort(key=lambda x: x.index(common_event))
combinations[start:end] = sub_list
combinations.reverse()
return combinations
def ranges_with_constant_length(x: Sequence[Sized]) -> Iterable[Tuple[int, int]]:
"""
get all ranges of x for which the elements have constant length
Parameters
----------
x: Sequence[Sized]
l must be ordered by increasing length of elements
"""
start_idx = 0
last_len = 0
for idx, y in enumerate(x):
if len(y) > last_len and idx - start_idx > 1:
yield start_idx, idx
if len(y) == last_len and idx + 1 == len(x):
yield start_idx, idx + 1
if len(y) > last_len:
start_idx = idx
if len(y) < last_len:
raise MappingParsingError(
"ranges_with_constant_length " "was called with an unordered list"
)
last_len = len(y)

@ -0,0 +1,60 @@
#!/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 evdev
from typing import Optional, Dict
from evdev.ecodes import EV_KEY
from inputremapper.event_combination import EventCombination
from inputremapper.input_event import InputEvent
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
HandlerEnums,
)
class NullHandler(MappingHandler):
"""handler which consumes the event and does nothing"""
def __str__(self):
return f"NullHandler for {self.mapping.event_combination}<{id(self)}>"
@property
def child(self):
return "Voids all events"
def needs_wrapping(self) -> bool:
return True in [event.value != 0 for event in self.input_events]
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
return {EventCombination(self.input_events): HandlerEnums.combination}
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
forward: evdev.UInput,
supress: bool = False,
) -> bool:
return True
def reset(self) -> None:
pass

@ -0,0 +1,132 @@
#!/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 evdev
import time
import asyncio
from typing import Optional, Dict
from evdev.ecodes import EV_REL
from inputremapper.configs.mapping import Mapping
from inputremapper.logger import logger
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.event_combination import EventCombination
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
ContextProtocol,
HandlerEnums,
InputEventHandler,
)
class RelToBtnHandler(MappingHandler):
"""
Handler which transforms an EV_REL to a button event
and sends that to a sub_handler
adheres to the MappingHandler protocol
"""
_active: bool
_input_event: InputEvent
_last_activation: float
_sub_handler: InputEventHandler
def __init__(
self,
combination: EventCombination,
mapping: Mapping,
**_,
) -> None:
super().__init__(combination, mapping)
self._active = False
self._input_event = combination[0]
self._last_activation = time.time()
self._abort_release = False
assert self._input_event.value != 0
assert len(combination) == 1
def __str__(self):
return f"RelToBtnHandler for {self._input_event.event_tuple} <{id(self)}>:"
def __repr__(self):
return self.__str__()
@property
def child(self): # used for logging
return self._sub_handler
async def _stage_release(self, source, forward, supress):
while time.time() < self._last_activation + self.mapping.release_timeout:
await asyncio.sleep(1 / self.mapping.rate)
if self._abort_release:
self._abort_release = False
return
event = self._input_event.modify(value=0, action=EventActions.as_key)
logger.debug_key(event.event_tuple, "sending to sub_handler")
self._sub_handler.notify(event, source, forward, supress)
self._active = False
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
forward: evdev.UInput,
supress: bool = False,
) -> bool:
assert event.type == EV_REL
if event.type_and_code != self._input_event.type_and_code:
return False
threshold = self._input_event.value
value = event.value
if (value < threshold > 0) or (value > threshold < 0):
if self._active:
# the axis is below the threshold and the stage_release function is running
event = event.modify(value=0, action=EventActions.as_key)
logger.debug_key(event.event_tuple, "sending to sub_handler")
self._abort_release = True
self._active = False
return self._sub_handler.notify(event, source, forward, supress)
else:
# don't consume the event.
# We could return True to consume events
return False
# the axis is above the threshold
event = event.modify(value=1, action=EventActions.as_key)
self._last_activation = time.time()
if not self._active:
logger.debug_key(event.event_tuple, "sending to sub_handler")
asyncio.ensure_future(self._stage_release(source, forward, supress))
self._active = True
return self._sub_handler.notify(event, source, forward, supress)
def reset(self) -> None:
if self._active:
self._abort_release = True
self._active = False
self._sub_handler.reset()

@ -19,17 +19,35 @@
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import enum
import evdev
from dataclasses import dataclass
from typing import Tuple
from typing import Tuple, Union, Sequence, Callable
from inputremapper.exceptions import InputEventCreationError
@dataclass(
frozen=True
) # Todo: add slots=True as soon as python 3.10 is in common distros
InputEventValidationType = Union[
str,
Tuple[int, int, int],
evdev.InputEvent,
]
class EventActions(enum.Enum):
"""
Additional information a InputEvent can send through the event pipeline
"""
as_key = enum.auto()
recenter = enum.auto()
none = enum.auto()
# Todo: add slots=True as soon as python 3.10 is in common distros
@dataclass(frozen=True)
class InputEvent:
"""
the evnet used by inputremapper
@ -42,6 +60,7 @@ class InputEvent:
type: int
code: int
value: int
action: EventActions = EventActions.none
def __hash__(self):
return hash((self.type, self.code, self.value))
@ -56,9 +75,31 @@ class InputEvent:
@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
yield cls.validate
@classmethod
def validate(cls, init_arg: InputEventValidationType) -> InputEvent:
"""try all the different methods, and raise an error if none succeed"""
if isinstance(init_arg, InputEvent):
return init_arg
event = None
validators: Sequence[Callable[..., InputEvent]] = (
cls.from_event,
cls.from_string,
cls.from_tuple,
)
for validator in validators:
try:
event = validator(init_arg)
break
except InputEventCreationError:
pass
if event:
return event
raise ValueError(f"failed to create InputEvent with {init_arg = }")
@classmethod
def from_event(cls, event: evdev.InputEvent) -> InputEvent:
@ -116,6 +157,11 @@ class InputEvent:
"""event type, code, value"""
return self.type, self.code, self.value
@property
def is_key_event(self) -> bool:
"""whether this is interpreted as a key event"""
return self.type == evdev.ecodes.EV_KEY or self.action == EventActions.as_key
def __str__(self):
if self.type == evdev.ecodes.EV_KEY:
key_name = evdev.ecodes.bytype[self.type].get(self.code, "unknown")
@ -135,6 +181,7 @@ class InputEvent:
type: int = None,
code: int = None,
value: int = None,
action: EventActions = EventActions.none,
) -> InputEvent:
"""return a new modified event"""
return InputEvent(
@ -143,6 +190,7 @@ class InputEvent:
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,
action if action is not EventActions.none else self.action,
)
def json_str(self) -> str:

@ -23,10 +23,13 @@
import os
import re
import sys
import shutil
import time
import logging
from typing import cast
import pkg_resources
from datetime import datetime
@ -43,39 +46,77 @@ start = time.time()
previous_key_debug_log = None
def debug_key(self, key, msg, *args):
"""Log a spam message custom tailored to keycode_mapper.
def parse_mapping_handler(mapping_handler):
indent = 0
lines_and_indent = []
while True:
if isinstance(handler, str):
lines_and_indent.append([mapping_handler, indent])
break
Parameters
----------
key : tuple of int
anything that can be string formatted, but usually a tuple of
(type, code, value) tuples
"""
# pylint: disable=protected-access
if not self.isEnabledFor(logging.DEBUG):
return
if isinstance(mapping_handler, list):
for sub_handler in mapping_handler:
sub_list = parse_mapping_handler(sub_handler)
for line in sub_list:
line[1] += indent
lines_and_indent.extend(sub_list)
break
global previous_key_debug_log
lines_and_indent.append([mapping_handler.__str__(), indent])
try:
mapping_handler = mapping_handler.child
except AttributeError:
break
indent += 1
return lines_and_indent
class Logger(logging.Logger):
def debug_mapping_handler(self, mapping_handler):
"""
parse the structure of a mapping_handler an log it
"""
if not self.isEnabledFor(logging.DEBUG):
return
lines_and_indent = parse_mapping_handler(mapping_handler)
for line in lines_and_indent:
indent = " "
msg = indent * line[1] + line[0]
self._log(logging.DEBUG, msg, args=None)
def debug_key(self, key, msg, *args):
"""Log a spam message custom tailored to keycode_mapper.
Parameters
----------
key : tuple of int
anything that can be string formatted, but usually a tuple of
(type, code, value) tuples
"""
# pylint: disable=protected-access
if not self.isEnabledFor(logging.DEBUG):
return
msg = msg % args
str_key = str(key)
str_key = str_key.replace(",)", ")")
spacing = " " + "·" * max(0, 30 - len(msg))
if len(spacing) == 1:
spacing = ""
msg = f"{msg}{spacing} {str_key}"
global previous_key_debug_log
if msg == previous_key_debug_log:
# avoid some super spam from EV_ABS events
return
msg = msg % args
str_key = str(key)
str_key = str_key.replace(",)", ")")
spacing = " " + "·" * max(0, 30 - len(msg))
if len(spacing) == 1:
spacing = ""
msg = f"{msg}{spacing} {str_key}"
previous_key_debug_log = msg
if msg == previous_key_debug_log:
# avoid some super spam from EV_ABS events
return
self._log(logging.DEBUG, msg, args=None)
previous_key_debug_log = msg
self._log(logging.DEBUG, msg, args=None)
logging.Logger.debug_key = debug_key
LOG_PATH = (
"/var/log/input-remapper"
@ -83,7 +124,9 @@ LOG_PATH = (
else f"{HOME}/.log/input-remapper"
)
logger = logging.getLogger("input-remapper")
# https://github.com/python/typeshed/issues/1801
logging.setLoggerClass(Logger)
logger = cast(Logger, logging.getLogger("input-remapper"))
def is_debug():
@ -222,6 +265,9 @@ except pkg_resources.DistributionNotFound as error:
logger.info("Could not figure out the version")
logger.debug(error)
# check if the version is something like 1.5b20 or 2.1.3b5
IS_BETA = bool(re.match("[0-9]+b[0-9]+", VERSION.split(".")[-1]))
def log_info(name="input-remapper"):
"""Log version and name to the console."""
@ -292,6 +338,9 @@ def trim_logfile(log_path):
with open(log_path, "w") as file:
file.truncate(0)
file.writelines(content)
except PermissionError:
# let the outermost PermissionError handler handle it
raise
except Exception as e:
logger.error('Failed to trim logfile: "%s"', str(e))

@ -66,5 +66,3 @@ def get_home(user):
USER = get_user()
HOME = get_home(USER)
CONFIG_PATH = os.path.join(HOME, ".config/input-remapper")

@ -170,13 +170,10 @@ def should_map_as_btn(event, preset, gamepad):
if not gamepad:
return False
l_purpose = preset.get("gamepad.joystick.left_purpose")
r_purpose = preset.get("gamepad.joystick.right_purpose")
if event.code in [ABS_X, ABS_Y] and l_purpose == BUTTONS:
if event.code in [ABS_X, ABS_Y]:
return True
if event.code in [ABS_RX, ABS_RY] and r_purpose == BUTTONS:
if event.code in [ABS_RX, ABS_RY]:
return True
else:
# for non-joystick buttons just always offer mapping them to

@ -88,7 +88,7 @@ ssh/login into a debian/ubuntu environment
./scripts/build.sh
```
This will generate `input-remapper/deb/input-remapper-1.4.2.deb`
This will generate `input-remapper/deb/input-remapper-1.5b1.deb`
## Badges

@ -14,7 +14,7 @@ First, select your device (like your keyboard) from the large dropdown on the to
Then you can already edit your keys, as shown in the screenshots.
In the text input field, type the key to which you would like to map this key.
More information about the possible mappings can be found [below](#key-names-and-macros).
More information about the possible mappings can be found [below](#key-names).
Changes are saved automatically. Afterwards press the "Apply" button.
@ -82,7 +82,7 @@ names can be chained using ` + `.
Check the autocompletion of the GUI for possible values. You can also
obtain a complete list of possiblities using `input-remapper-control --symbol-names`.
Input-remapper only recognizes symbol names, but not the symbols themselfes. So for
Input-remapper only recognizes symbol names, but not the symbols themselves. So for
example, input-remapper might (depending on the system layout) know what a `minus` is, but
it doesn't know `-`.
@ -123,65 +123,110 @@ that I can't review their code, so use them at your own risk (just like everythi
## Configuration Files
If you don't have a graphical user interface, you'll need to edit the
configuration files.
configuration files. All configuration files need to be valid json files, otherwise the
parser refuses to work.
Note for the Beta branch: All configuration files are copied to:
`~/.config/input-remapper/beta_VERSION/`
The default configuration is stored at `~/.config/input-remapper/config.json`,
which doesn't include any mappings, but rather other parameters that
are interesting for injections. The current default configuration as of 1.2.1
are interesting for injections. The current default configuration as of 1.5
looks like, with an example autoload entry:
```json
{
"autoload": {
"Logitech USB Keyboard": "preset name"
},
"macros": {
"keystroke_sleep_ms": 10
"autoload": {
"Logitech USB Keyboard": "preset name"
},
"gamepad": {
"joystick": {
"non_linearity": 4,
"pointer_speed": 80,
"left_purpose": "none",
"right_purpose": "none",
"x_scroll_speed": 2,
"y_scroll_speed": 0.5
}
}
"version": "1.5"
}
```
`preset name` refers to `~/.config/input-remapper/presets/device name/preset name.json`.
The device name can be found with `sudo input-remapper-control --list-devices`.
Anything that is relevant to presets can be overwritten in them as well.
#### Preset
The preset files are a collection of mappings.
Here is an example configuration for preset "a" for the "gamepad" device:
`~/.config/input-remapper/presets/gamepad/a.json`
```json
{
"macros": {
"keystroke_sleep_ms": 100
"1,307,1": {
"target_uinput": "keyboard",
"output_symbol": "k(2).k(3)",
"macro_key_sleep_ms": 100
},
"1,315,1+1,16,1": {
"target_uinput": "keyboard",
"output_symbol": "1"
},
"mapping": {
"1,315,1+1,16,-1": "1",
"1,307,1": "k(2).k(3)"
"3,1,0": {
"target_uinput": "mouse",
"output_type": 2,
"output_code": 1,
"gain": 0.5
}
}
```
This preset consists of three mappings.
Both need to be valid json files, otherwise the parser refuses to work. This
preset maps the EV_KEY down event with code 307 to a macro and sets the time
between injected events of macros to 100 ms. Note that a complete keystroke
consists of two events: down and up. The other mapping is a key combination,
chained using `+`.
* The first maps the key event with code 307 to a macro and sets the time between
injected events of macros to 100 ms. The macro injects its events to the virtual keyboard.
* The second mapping is a key combination, chained using `+`.
* The third maps the y-Axis to the y-Axis on the virtual mouse.
Other than that, it inherits all configurations from
`~/.config/input-remapper/config.json`. If config.json is missing some stuff,
it will query the hardcoded default values.
#### Mapping
As shown above, the mapping is part of the preset. It consists of the input-combination
and the mapping parameters.
```
<input-combination>: {
<parameter 1>: <value1>,
<parameter 2>: <value2>
}
```
The input-combination is a string like `"EV_TYPE, EV_CODE, EV_VALUE + ..."`.
`EV_TYPE` and `EV_CODE` describe the input event. Use the program `evtest` to find
Available types and codes. See also the [evdev documentation](https://www.kernel.org/doc/html/latest/input/event-codes.html#input-event-codes)
The `EV_VALUE` describes the intention of the input.
A value of `0` means that the event will be mapped to an axis. A non-zero value means
that the event will be treated as a key input.
If the event type is `3 (EV_ABS)` (as in: map a joystick axis to a key or macro) the
value can be between `-100 [%]` and `100 [%]`. The mapping will be triggered once the joystick
reaches the position described by the value.
If the event type is `2 (EV_REL)` (as in: map a relative axis (e.g. mouse wheel) to a key or macro)
the value can be anything. The mapping will be triggered once the speed and direction of
the axis is higher than described by the value.
The following table contains all possible parameters and their default values:
| Parameter | Default | Type | Description |
|---------------------------|---------|-----------------|--------------------------------------------------------------------------------------------------------|
| target_uinput | | string | The UInput to which the mapped event will be sent |
| output_symbol | | string | The symbol or macro string if applicable |
| output_type | | int | The event type of the mapped event |
| output_code | | int | The event code of the mapped event |
| release_combination_keys | true | bool | If release events will be sent to the forwarded device as soon as a combination triggers see also #229 |
| **Macro settings** |
| macro_key_sleep_ms | 20 | positive int | |
| **Axis settings** |
| deadzone | 0.1 | float ∈ (0, 1) | The deadzone of the input axis |
| gain | 1.0 | float | Scale factor when mapping an axis to an axis |
| expo | 0 | float ∈ (-1, 1) | Non liniarity factor see also [GeoGebra](https://www.geogebra.org/calculator/mkdqueky) |
| **EV_REL output** |
| rate | 60 | positive int | The frequency `[Hz]` at which `EV_REL` events get generated (also effects mouse and wheel macro) |
| rel_speed | 100 | positive int | The base speed of the relative axis, compounds with the gain (also effects mouse and wheel macro) |
| **EV_REL as input** |
| rel_input_cutoff | 100 | positive int | The absolute value at which a `EV_REL` axis is considered at its maximum |
| release_timeout | 0.05 | positive float | The time `[s]` until a relative axis is considered stationary if no new events arrive |
The event codes can be read using `evtest`. Available names in the mapping
can be listed with `input-remapper-control --symbol-names`.
## CLI
@ -193,14 +238,14 @@ running (or without sudo if your user has the appropriate permissions).
Examples:
| Description | Command |
|-----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------|
| Load all configured presets for all devices | `input-remapper-control --command autoload` |
| If you are running as root user, provide information about the whereabouts of the input-remapper config | `input-remapper-control --command autoload --config-dir "~/.config/input-remapper/"` |
| List available device names for the `--device` parameter | `sudo input-remapper-control --list-devices` |
| Stop injecting | `input-remapper-control --command stop --device "Razer Razer Naga Trinity"` |
| Load `~/.config/input-remapper/presets/Razer Razer Naga Trinity/a.json` | `input-remapper-control --command start --device "Razer Razer Naga Trinity" --preset "a"` |
| Loads the configured preset for whatever device is using this /dev path | `/bin/input-remapper-control --command autoload --device /dev/input/event5` |
| Description | Command |
|----------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------|
| Load all configured presets for all devices | `input-remapper-control --command autoload` |
| If you are running as root user, provide information about the whereabouts of the input-remapper config | `input-remapper-control --command autoload --config-dir "~/.config/input-remapper/"` |
| List available device names for the `--device` parameter | `sudo input-remapper-control --list-devices` |
| Stop injecting | `input-remapper-control --command stop --device "Razer Razer Naga Trinity"` |
| Load `~/.config/input-remapper/presets/Razer Razer Naga Trinity/a.json` | `input-remapper-control --command start --device "Razer Razer Naga Trinity" --preset "a"` |
| Loads the configured preset for whatever device is using this /dev path | `/bin/input-remapper-control --command autoload --device /dev/input/event5` |
**systemctl**

@ -9,8 +9,8 @@ build_deb() {
mv build/deb/usr/local/lib/python3.*/ build/deb/usr/lib/python3/
cp ./DEBIAN build/deb/ -r
mkdir dist -p
rm dist/input-remapper-1.4.2.deb || true
dpkg -b build/deb dist/input-remapper-1.4.2.deb
rm dist/input-remapper-1.5b1.deb || true
dpkg -b build/deb dist/input-remapper-1.5b1.deb
}
build_deb &

@ -11,4 +11,4 @@ python -m pip install --upgrade pip
pip install wheel setuptools
# install test deps which aren't in setup.py
pip install psutil
pip install psutil pylint-pydantic

@ -56,7 +56,7 @@ class Install(install):
def get_packages(base="inputremapper"):
"""Return all modules used in input-remapper.
For example 'inputremapper.gui' or 'inputremapper.injection.consumers'
For example 'inputremapper.gui' or 'inputremapper.injection.mapping_handlers'
"""
if not os.path.exists(os.path.join(base, "__init__.py")):
# only python modules
@ -102,7 +102,7 @@ for po_file in glob.glob(PO_FILES):
setup(
name="input-remapper",
version="1.4.2",
version="1.5b1",
description="A tool to change the mapping of your input device buttons",
author="Sezanzeb",
author_email="proxima@sezanzeb.de",

@ -33,6 +33,7 @@ from tests.test import (
EVENT_READ_TIMEOUT,
send_event_to_reader,
MIN_ABS,
get_ui_mapping,
)
import sys
@ -64,8 +65,9 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk
from inputremapper.configs.system_mapping import system_mapping, XMODMAP_FILENAME
from inputremapper.configs.mapping import UIMapping
from inputremapper.gui.active_preset import active_preset
from inputremapper.configs.paths import CONFIG_PATH, get_preset_path, get_config_path
from inputremapper.configs.paths import get_preset_path, get_config_path, CONFIG_PATH
from inputremapper.configs.global_config import global_config, WHEEL, MOUSE, BUTTONS
from inputremapper.gui.reader import reader
from inputremapper.gui.helper import RootHelper
@ -364,7 +366,7 @@ class GuiTestBase(unittest.TestCase):
return status_bar.get_message_area().get_children()[0].get_label()
def get_unfiltered_symbol_input_text(self):
buffer = self.editor.get_text_input().get_buffer()
buffer = self.editor.get_code_editor().get_buffer()
return buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True)
def select_mapping(self, i: int):
@ -505,7 +507,7 @@ class GuiTestBase(unittest.TestCase):
self.assertEqual(self.editor.get_symbol_input_text(), correct_case)
self.assertFalse(active_preset.has_unsaved_changes())
self.set_focus(self.editor.get_text_input())
self.set_focus(self.editor.get_code_editor())
self.set_focus(None)
return selection_label
@ -519,6 +521,21 @@ class GuiTestBase(unittest.TestCase):
gtk_iteration()
def set_combination(self, combination: EventCombination) -> None:
"""
partial implementation of editor.consume_newest_keycode
simplifies setting combination without going through the add mapping via ui function
"""
previous_key = self.editor.get_combination()
# keycode didn't change, do nothing
if combination == previous_key:
return
self.editor.set_combination(combination)
self.editor.active_mapping.event_combination = combination
if previous_key is None and combination is not None:
active_preset.add(self.editor.active_mapping)
class TestGui(GuiTestBase):
"""For tests that use the window.
@ -706,9 +723,18 @@ class TestGui(GuiTestBase):
def test_select_device(self):
# creates a new empty preset when no preset exists for the device
self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device"))
active_preset.change(EventCombination([EV_KEY, 50, 1]), "keyboard", "q")
active_preset.change(EventCombination([EV_KEY, 51, 1]), "keyboard", "u")
active_preset.change(EventCombination([EV_KEY, 52, 1]), "keyboard", "x")
m1 = UIMapping(
event_combination="1,50,1", output_symbol="q", target_uinput="keyboard"
)
m2 = UIMapping(
event_combination="1,51,1", output_symbol="u", target_uinput="keyboard"
)
m3 = UIMapping(
event_combination="1,52,1", output_symbol="x", target_uinput="keyboard"
)
active_preset.add(m1)
active_preset.add(m2)
active_preset.add(m3)
self.assertEqual(len(active_preset), 3)
self.user_interface.on_select_device(FakeDeviceDropdown("Bar Device"))
self.assertEqual(len(active_preset), 0)
@ -718,8 +744,7 @@ class TestGui(GuiTestBase):
path = get_preset_path("Bar Device", "new preset")
self.assertTrue(os.path.exists(path))
with open(path, "r") as file:
preset = json.load(file)
self.assertEqual(len(preset["mapping"]), 0)
self.assertEqual(file.read(), "")
def test_permission_error_on_create_preset_clicked(self):
def save(_=None):
@ -842,7 +867,7 @@ class TestGui(GuiTestBase):
)
self.disable_recording_toggle()
self.set_focus(self.editor.get_text_input())
self.set_focus(self.editor.get_code_editor())
self.assertFalse(self.editor.is_waiting_for_input())
self.editor.set_symbol_input_text("Shift_L")
@ -899,7 +924,7 @@ class TestGui(GuiTestBase):
self.assertIsNone(selection_label.get_combination())
# focus the text input instead
self.set_focus(self.editor.get_text_input())
self.set_focus(self.editor.get_code_editor())
send_event_to_reader(new_event(1, 61, 1))
self.user_interface.consume_newest_keycode()
@ -973,7 +998,7 @@ class TestGui(GuiTestBase):
self.select_mapping(0)
self.assertEqual(self.editor.get_combination(), ev_1)
self.set_focus(self.editor.get_text_input())
self.set_focus(self.editor.get_code_editor())
self.editor.set_symbol_input_text("c")
self.set_focus(None)
@ -1195,17 +1220,20 @@ class TestGui(GuiTestBase):
self.assertEqual(self.user_interface.group.name, "Foo Device")
self.assertFalse(global_config.is_autoloaded("Foo Device", "new preset"))
active_preset.change(EventCombination([EV_KEY, 14, 1]), "keyboard", "a", None)
m1 = get_ui_mapping()
active_preset.add(m1)
self.assertEqual(self.user_interface.preset_name, "new preset")
self.user_interface.save_preset()
self.assertEqual(
active_preset.get_mapping(EventCombination([EV_KEY, 14, 1])),
("a", "keyboard"),
active_preset.get_mapping(EventCombination([99, 99, 99])),
m1,
)
global_config.set_autoload_preset("Foo Device", "new preset")
self.assertTrue(global_config.is_autoloaded("Foo Device", "new preset"))
active_preset.change(EventCombination([EV_KEY, 14, 1]), "keyboard", "b", None)
m2 = get_ui_mapping()
m2.output_symbol = "b"
active_preset.get_mapping(EventCombination([99, 99, 99])).output_symbol = "b"
self.user_interface.get("preset_name_input").set_text("asdf")
self.user_interface.save_preset()
self.user_interface.on_rename_button_clicked(None)
@ -1213,8 +1241,8 @@ class TestGui(GuiTestBase):
preset_path = f"{CONFIG_PATH}/presets/Foo Device/asdf.json"
self.assertTrue(os.path.exists(preset_path))
self.assertEqual(
active_preset.get_mapping(EventCombination([EV_KEY, 14, 1])),
("b", "keyboard"),
active_preset.get_mapping(EventCombination([99, 99, 99])),
m2,
)
# after renaming the preset it is still set to autoload
@ -1227,10 +1255,11 @@ class TestGui(GuiTestBase):
self.assertFalse(error_icon.get_visible())
# otherwise save won't do anything
active_preset.change(EventCombination([EV_KEY, 14, 1]), "keyboard", "c", None)
m2.output_symbol = "c"
active_preset.get_mapping(EventCombination([99, 99, 99])).output_symbol = "c"
self.assertTrue(active_preset.has_unsaved_changes())
def save(_):
def save():
raise PermissionError
with patch.object(active_preset, "save", save):
@ -1245,7 +1274,8 @@ class TestGui(GuiTestBase):
def test_rename_create_switch(self):
# after renaming a preset and saving it, new presets
# start with "new preset" again
active_preset.change(EventCombination([EV_KEY, 14, 1]), "keyboard", "a", None)
m1 = get_ui_mapping()
active_preset.add(m1)
self.user_interface.get("preset_name_input").set_text("asdf")
self.user_interface.save_preset()
self.user_interface.on_rename_button_clicked(None)
@ -1259,15 +1289,16 @@ class TestGui(GuiTestBase):
self.user_interface.save_preset()
# symbol and code in the gui won't be carried over after selecting a preset
self.editor.set_combination(EventCombination([EV_KEY, 15, 1]))
combination = EventCombination([EV_KEY, 15, 1])
self.set_combination(combination)
self.editor.set_symbol_input_text("b")
# selecting the first preset again loads the saved mapping, and saves
# the current changes in the gui
self.user_interface.on_select_preset(FakePresetDropdown("asdf"))
self.assertEqual(
active_preset.get_mapping(EventCombination([EV_KEY, 14, 1])),
("a", "keyboard"),
active_preset.get_mapping(EventCombination([99, 99, 99])),
m1,
)
self.assertEqual(len(active_preset), 1)
self.assertEqual(len(self.selection_label_listbox.get_children()), 2)
@ -1281,9 +1312,12 @@ class TestGui(GuiTestBase):
# and that added number is correctly used in the autoload
# configuration as well
self.assertTrue(global_config.is_autoloaded("Foo Device", "asdf 2"))
m2 = get_ui_mapping()
m2.event_combination = "1,15,1"
m2.output_symbol = "b"
self.assertEqual(
active_preset.get_mapping(EventCombination([EV_KEY, 15, 1])),
("b", "keyboard"),
active_preset.get_mapping(EventCombination([EV_KEY, 15, 1])).dict(),
m2.dict(),
)
self.assertEqual(len(active_preset), 1)
self.assertEqual(len(self.selection_label_listbox.get_children()), 2)
@ -1305,25 +1339,6 @@ class TestGui(GuiTestBase):
self.user_interface.on_rename_button_clicked(None)
self.assertEqual(self.user_interface.preset_name, "asdf 2")
def test_avoids_redundant_saves(self):
active_preset.change(
EventCombination([EV_KEY, 14, 1]), "keyboard", "abcd", None
)
active_preset.set_has_unsaved_changes(False)
self.user_interface.save_preset()
with open(get_preset_path("Foo Device", "new preset")) as f:
content = f.read()
self.assertNotIn("abcd", content)
active_preset.set_has_unsaved_changes(True)
self.user_interface.save_preset()
with open(get_preset_path("Foo Device", "new preset")) as f:
content = f.read()
self.assertIn("abcd", content)
def test_check_for_unknown_symbols(self):
status = self.user_interface.get("status_bar")
error_icon = self.user_interface.get("error_status_icon")
@ -1707,9 +1722,11 @@ class TestGui(GuiTestBase):
self.user_interface.can_modify_preset()
text = self.get_status_text()
self.assertNotIn("Stop Injection", text)
active_preset.change(EventCombination([EV_KEY, KEY_A, 1]), "keyboard", "b")
active_preset.save(get_preset_path(group_name, preset_name))
active_preset.path = get_preset_path(group_name, preset_name)
active_preset.add(
get_ui_mapping(EventCombination([EV_KEY, KEY_A, 1]), "keyboard", "b")
)
active_preset.save()
self.user_interface.on_apply_preset_clicked(None)
# wait for the injector to start
@ -1998,7 +2015,7 @@ class TestGui(GuiTestBase):
def test_enable_disable_symbol_input(self):
# should be disabled by default since no key is recorded yet
self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST)
self.assertFalse(self.editor.get_text_input().get_sensitive())
self.assertFalse(self.editor.get_code_editor().get_sensitive())
self.editor.enable_symbol_input()
self.assertEqual(self.get_unfiltered_symbol_input_text(), "")
@ -2022,7 +2039,7 @@ class TestGui(GuiTestBase):
send_event_to_reader(InputEvent.from_tuple((EV_KEY, 101, 1)))
self.user_interface.consume_newest_keycode()
self.assertEqual(self.get_unfiltered_symbol_input_text(), "")
self.assertTrue(self.editor.get_text_input().get_sensitive())
self.assertTrue(self.editor.get_code_editor().get_sensitive())
# it wouldn't clear user input, if for whatever reason (a bug?) there is user
# input in there when enable_symbol_input is called.
@ -2054,7 +2071,7 @@ class TestAutocompletion(GuiTestBase):
def test_autocomplete_key(self):
self.add_mapping_via_ui(EventCombination([1, 99, 1]), "")
source_view = self.editor.get_text_input()
source_view = self.editor.get_code_editor()
self.set_focus(source_view)
complete_key_name = "Test_Foo_Bar"
@ -2097,7 +2114,7 @@ class TestAutocompletion(GuiTestBase):
def test_autocomplete_function(self):
self.add_mapping_via_ui(EventCombination([1, 99, 1]), "")
source_view = self.editor.get_text_input()
source_view = self.editor.get_code_editor()
self.set_focus(source_view)
incomplete = "key(KEY_A).\nepea"
@ -2118,7 +2135,7 @@ class TestAutocompletion(GuiTestBase):
def test_close_autocompletion(self):
self.add_mapping_via_ui(EventCombination([1, 99, 1]), "")
source_view = self.editor.get_text_input()
source_view = self.editor.get_code_editor()
self.set_focus(source_view)
Gtk.TextView.do_insert_at_cursor(source_view, "KEY_")
@ -2139,7 +2156,7 @@ class TestAutocompletion(GuiTestBase):
def test_writing_still_works(self):
self.add_mapping_via_ui(EventCombination([1, 99, 1]), "")
source_view = self.editor.get_text_input()
source_view = self.editor.get_code_editor()
self.set_focus(source_view)
Gtk.TextView.do_insert_at_cursor(source_view, "KEY_")
@ -2168,7 +2185,7 @@ class TestAutocompletion(GuiTestBase):
def test_cycling(self):
self.add_mapping_via_ui(EventCombination([1, 99, 1]), "")
source_view = self.editor.get_text_input()
source_view = self.editor.get_code_editor()
self.set_focus(source_view)
Gtk.TextView.do_insert_at_cursor(source_view, "KEY_")

@ -218,6 +218,7 @@ fixtures = {
evdev.ecodes.ABS_Z,
evdev.ecodes.ABS_RZ,
evdev.ecodes.ABS_HAT0X,
evdev.ecodes.ABS_HAT0Y,
],
evdev.ecodes.EV_KEY: [evdev.ecodes.BTN_A],
},
@ -304,9 +305,9 @@ def new_event(type, code, value, timestamp=None, offset=0):
def patch_paths():
from inputremapper.configs import paths
from inputremapper import user
paths.CONFIG_PATH = tmp
user.HOME = tmp
class InputDevice:
@ -423,9 +424,21 @@ class InputDevice:
resolution=None,
max=MAX_ABS,
)
result[evdev.ecodes.EV_ABS] = [
(stuff, absinfo_obj) for stuff in result[evdev.ecodes.EV_ABS]
]
ev_abs = []
for ev_code in result[evdev.ecodes.EV_ABS]:
if ev_code in range(0x10, 0x18): # ABS_HAT0X - ABS_HAT3Y
absinfo_obj = evdev.AbsInfo(
value=None,
min=-1,
fuzz=None,
flat=None,
resolution=None,
max=1,
)
ev_abs.append((ev_code, absinfo_obj))
result[evdev.ecodes.EV_ABS] = ev_abs
return result
@ -540,15 +553,18 @@ from inputremapper.logger import update_verbosity
update_verbosity(True)
from inputremapper.input_event import InputEvent as InternalInputEvent
from inputremapper.injection.injector import Injector
from inputremapper.configs.global_config import global_config
from inputremapper.configs.mapping import Mapping, UIMapping
from inputremapper.gui.reader import reader
from inputremapper.groups import groups
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.active_preset import active_preset
from inputremapper.configs.paths import get_config_path
from inputremapper.injection.macros.macro import macro_variables
from inputremapper.injection.consumers.keycode_mapper import active_macros, unreleased
# from inputremapper.injection.mapping_handlers.keycode_mapper import active_macros, unreleased
from inputremapper.injection.global_uinputs import global_uinputs
# no need for a high number in tests
@ -559,6 +575,33 @@ _fixture_copy = copy.deepcopy(fixtures)
environ_copy = copy.deepcopy(os.environ)
def convert_to_internal_events(events):
"""convert a iterable of InputEvent to a list of inputremapper.InputEvent"""
return [InternalInputEvent.from_event(event) for event in events]
def get_key_mapping(
combination="99,99,99", target_uinput="keyboard", output_symbol="a"
) -> Mapping:
"""convenient function to get a valid mapping"""
return Mapping(
event_combination=combination,
target_uinput=target_uinput,
output_symbol=output_symbol,
)
def get_ui_mapping(
combination="99,99,99", target_uinput="keyboard", output_symbol="a"
) -> UIMapping:
"""convenient function to get a valid mapping"""
return UIMapping(
event_combination=combination,
target_uinput=target_uinput,
output_symbol=output_symbol,
)
def send_event_to_reader(event):
"""Act like the helper and send input events to the reader."""
reader._results._unread.append(
@ -619,20 +662,17 @@ def quick_cleanup(log=True):
global_config._save_config()
system_mapping.populate()
active_preset.empty()
active_preset.clear_config()
active_preset.set_has_unsaved_changes(False)
clear_write_history()
for name in list(uinputs.keys()):
del uinputs[name]
for device in list(active_macros.keys()):
del active_macros[device]
for device in list(unreleased.keys()):
del unreleased[device]
# for device in list(active_macros.keys()):
# del active_macros[device]
# for device in list(unreleased.keys()):
# del unreleased[device]
for path in list(fixtures.keys()):
if path not in _fixture_copy:

@ -0,0 +1,188 @@
#!/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 dataclasses
import functools
import unittest
import itertools
from typing import Iterable, List
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
class TestAxisTransformation(unittest.TestCase):
@dataclasses.dataclass
class InitArgs:
max_: int
min_: int
deadzone: float
gain: float
expo: float
def values(self):
return self.__dict__.values()
def get_init_args(
self,
max_=(255, 1000, 2**15),
min_=(50, 0, -255),
deadzone=(0, 0.5),
gain=(0.5, 1, 2),
expo=(-0.9, 0, 0.3),
) -> Iterable[InitArgs]:
for args in itertools.product(max_, min_, deadzone, gain, expo):
yield self.InitArgs(*args)
@staticmethod
def scale_to_range(min_, max_, x=(-1, -0.2, 0, 0.6, 1)) -> List[float]:
"""scale values between -1 and 1 up, such that they are between min and max"""
half_range = (max_ - min_) / 2
return [float_x * half_range + min_ + half_range for float_x in x]
def test_scale_to_range(self):
"""make sure scale_to_range will actually return the min and max values
(avoid "off by one" errors)"""
max_ = (255, 1000, 2**15)
min_ = (50, 0, -255)
for x1, x2 in itertools.product(min_, max_):
scaled = self.scale_to_range(x1, x2, (-1, 1))
self.assertEqual(scaled, [x1, x2])
def test_expo_symmetry(self):
"""
test that the transformation is symmetric for expo parameter
x = f(g(x)), if f._expo == - g._expo
with the following constraints:
min = -1, max = 1
gain = 1
deadzone = 0
we can remove the constraints for min, max and gain,
by scaling the values appropriately after each transformation
"""
for init_args in self.get_init_args(deadzone=(0,)):
f = Transformation(*init_args.values())
init_args.expo = -init_args.expo
g = Transformation(*init_args.values())
scale = functools.partial(
self.scale_to_range, init_args.min_, init_args.max_
)
for x in scale():
y1 = g(x)
y1 = y1 / init_args.gain # remove the gain
y1 = scale((y1,))[0] # remove the min/max constraint
y2 = f(y1)
y2 = y2 / init_args.gain # remove the gain
y2 = scale((y2,))[0] # remove the min/max constraint
self.assertAlmostEqual(x, y2, msg=f"test expo symmetry for {init_args}")
def test_origin_symmetry(self):
"""
test that the transformation is symmetric to the origin
f(x) = - f(-x)
within the constraints: min = -max
"""
for init_args in self.get_init_args():
init_args.min_ = -init_args.max_
f = Transformation(*init_args.values())
for x in self.scale_to_range(init_args.min_, init_args.max_):
self.assertAlmostEqual(
f(x), -f(-x), msg=f"test origin symmetry at {x=} for {init_args}"
)
def test_gain(self):
"""test that f(max) = gain and f(min) = -gain"""
for init_args in self.get_init_args():
f = Transformation(*init_args.values())
self.assertAlmostEqual(
f(init_args.max_), init_args.gain, msg=f"test gain for {init_args}"
)
self.assertAlmostEqual(
f(init_args.min_), -init_args.gain, msg=f"test gain for {init_args}"
)
def test_deadzone(self):
"""test the Transfomation returns exactly 0 in the range of the deadzone"""
for init_args in self.get_init_args(deadzone=(0.1, 0.2, 0.9)):
f = Transformation(*init_args.values())
for x in self.scale_to_range(
init_args.min_,
init_args.max_,
x=(
init_args.deadzone * 0.999,
-init_args.deadzone * 0.999,
0.3 * init_args.deadzone,
0,
),
):
self.assertEqual(f(x), 0, msg=f"test deadzone at {x=} for {init_args}")
def test_continuity_near_deadzone(self):
"""test that the Transfomation is continues (no sudden jump) next to the
deadzone"""
for init_args in self.get_init_args(deadzone=(0.1, 0.2, 0.9)):
f = Transformation(*init_args.values())
scale = functools.partial(
self.scale_to_range,
init_args.min_,
init_args.max_,
)
x = (
init_args.deadzone * 1.00001,
init_args.deadzone * 1.001,
-init_args.deadzone * 1.00001,
-init_args.deadzone * 1.001,
)
scaled_x = scale(x=x)
p1 = (x[0], f(scaled_x[0])) # first point right of deadzone
p2 = (x[1], f(scaled_x[1])) # second point right of deadzone
# calculate a linear function y = m * x + b from p1 and p2
m = (p1[1] - p2[1]) / (p1[0] - p2[0])
b = p1[1] - m * p1[0]
# the zero intersection of that function must be close to the
# edge of the deadzone
self.assertAlmostEqual(
-b / m,
init_args.deadzone,
places=5,
msg=f"test continuity at {init_args.deadzone} for {init_args}",
)
# same thing on the other side
p1 = (x[2], f(scaled_x[2]))
p2 = (x[3], f(scaled_x[3]))
m = (p1[1] - p2[1]) / (p1[0] - p2[0])
b = p1[1] - m * p1[0]
self.assertAlmostEqual(
-b / m,
-init_args.deadzone,
places=5,
msg=f"test continuity at {- init_args.deadzone} for {init_args}",
)

@ -33,13 +33,6 @@ class TestConfig(unittest.TestCase):
quick_cleanup()
self.assertEqual(len(global_config.iterate_autoload_presets()), 0)
def test_get_default(self):
global_config._config = {}
self.assertEqual(global_config.get("gamepad.joystick.non_linearity"), 4)
global_config.set("gamepad.joystick.non_linearity", 3)
self.assertEqual(global_config.get("gamepad.joystick.non_linearity"), 3)
def test_basic(self):
self.assertEqual(global_config.get("a"), None)
@ -96,6 +89,9 @@ class TestConfig(unittest.TestCase):
)
self.assertEqual(global_config.get(["autoload", "d1"]), "a")
self.assertRaises(ValueError, global_config.is_autoloaded, "d1", None)
self.assertRaises(ValueError, global_config.is_autoloaded, None, "a")
def test_initial(self):
# when loading for the first time, create a config file with
# the default values
@ -106,7 +102,7 @@ class TestConfig(unittest.TestCase):
with open(global_config.path, "r") as file:
contents = file.read()
self.assertIn('"keystroke_sleep_ms": 10', contents)
self.assertIn('"autoload": {}', contents)
def test_save_load(self):
self.assertEqual(len(global_config.iterate_autoload_presets()), 0)

@ -1,211 +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/>.
from tests.test import new_event, quick_cleanup
import unittest
import asyncio
import evdev
from evdev.ecodes import EV_KEY, EV_ABS, ABS_Y, EV_REL
from inputremapper.injection.consumers.keycode_mapper import active_macros
from inputremapper.configs.global_config import BUTTONS, MOUSE, WHEEL
from inputremapper.injection.context import Context
from inputremapper.configs.preset import Preset
from inputremapper.event_combination import EventCombination
from inputremapper.injection.consumer_control import ConsumerControl, consumer_classes
from inputremapper.injection.consumers.consumer import Consumer
from inputremapper.injection.consumers.keycode_mapper import KeycodeMapper
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.injection.global_uinputs import global_uinputs
class ExampleConsumer(Consumer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def is_enabled(self):
return True
async def notify(self, event):
pass
def is_handled(self, event):
pass
async def run(self):
pass
class TestConsumerControl(unittest.IsolatedAsyncioTestCase):
def setUp(self):
consumer_classes.append(ExampleConsumer)
self.gamepad_source = evdev.InputDevice("/dev/input/event30")
self.mapping = Preset()
def tearDown(self):
quick_cleanup()
consumer_classes.remove(ExampleConsumer)
def setup(self, source, mapping):
"""Set a a ConsumerControl up for the test and run it in the background."""
forward_to = evdev.UInput()
context = Context(mapping)
context.uinput = evdev.UInput()
consumer_control = ConsumerControl(context, source, forward_to)
for consumer in consumer_control._consumers:
consumer._abs_range = (-10, 10)
asyncio.ensure_future(consumer_control.run())
return context, consumer_control
async def test_no_keycode_mapper_needed(self):
self.mapping.change(EventCombination([EV_KEY, 1, 1]), "keyboard", "b")
_, consumer_control = self.setup(self.gamepad_source, self.mapping)
consumer_types = [type(consumer) for consumer in consumer_control._consumers]
self.assertIn(KeycodeMapper, consumer_types)
self.mapping.empty()
_, consumer_control = self.setup(self.gamepad_source, self.mapping)
consumer_types = [type(consumer) for consumer in consumer_control._consumers]
self.assertNotIn(KeycodeMapper, consumer_types)
self.mapping.change(EventCombination([EV_KEY, 1, 1]), "keyboard", "k(a)")
_, consumer_control = self.setup(self.gamepad_source, self.mapping)
consumer_types = [type(consumer) for consumer in consumer_control._consumers]
self.assertIn(KeycodeMapper, consumer_types)
async def test_if_single_joystick_then(self):
# Integration test style for if_single.
# won't care about the event, because the purpose is not set to BUTTON
code_a = system_mapping.get("a")
code_shift = system_mapping.get("KEY_LEFTSHIFT")
trigger = 1
self.mapping.change(
EventCombination([EV_KEY, trigger, 1]),
"keyboard",
"if_single(k(a), k(KEY_LEFTSHIFT))",
)
self.mapping.change(EventCombination([EV_ABS, ABS_Y, 1]), "keyboard", "b")
self.mapping.set("gamepad.joystick.left_purpose", MOUSE)
self.mapping.set("gamepad.joystick.right_purpose", WHEEL)
context, _ = self.setup(self.gamepad_source, self.mapping)
self.gamepad_source.push_events(
[
new_event(EV_KEY, trigger, 1), # start the macro
new_event(EV_ABS, ABS_Y, 10), # ignored
new_event(EV_KEY, 2, 2), # ignored
new_event(EV_KEY, 2, 0), # ignored
new_event(EV_REL, 1, 1), # ignored
new_event(
EV_KEY, trigger, 0
), # stop it, the only way to trigger `then`
]
)
await asyncio.sleep(0.1)
self.assertFalse(active_macros[(EV_KEY, 1)].running)
history = [a.t for a in global_uinputs.get_uinput("keyboard").write_history]
self.assertIn((EV_KEY, code_a, 1), history)
self.assertIn((EV_KEY, code_a, 0), history)
self.assertNotIn((EV_KEY, code_shift, 1), history)
self.assertNotIn((EV_KEY, code_shift, 0), history)
async def test_if_single_joystickelse_(self):
"""triggers else + delayed_handle_keycode"""
# Integration test style for if_single.
# If a joystick that is mapped to a button is moved, if_single stops
code_b = system_mapping.get("b")
code_shift = system_mapping.get("KEY_LEFTSHIFT")
trigger = 1
self.mapping.change(
EventCombination([EV_KEY, trigger, 1]),
"keyboard",
"if_single(k(a), k(KEY_LEFTSHIFT))",
)
self.mapping.change(EventCombination([EV_ABS, ABS_Y, 1]), "keyboard", "b")
self.mapping.set("gamepad.joystick.left_purpose", BUTTONS)
self.mapping.set("gamepad.joystick.right_purpose", BUTTONS)
context, _ = self.setup(self.gamepad_source, self.mapping)
self.gamepad_source.push_events(
[
new_event(EV_KEY, trigger, 1), # start the macro
new_event(EV_ABS, ABS_Y, 10), # not ignored, stops it
]
)
await asyncio.sleep(0.1)
self.assertFalse(active_macros[(EV_KEY, 1)].running)
history = [a.t for a in global_uinputs.get_uinput("keyboard").write_history]
# the key that triggered if_single should be injected after
# if_single had a chance to inject keys (if the macro is fast enough),
# so that if_single can inject a modifier to e.g. capitalize the
# triggering key. This is important for the space cadet shift
self.assertListEqual(
history,
[
(EV_KEY, code_shift, 1),
(EV_KEY, code_b, 1), # would be capitalized now
(EV_KEY, code_shift, 0),
],
)
async def test_if_single_joystick_under_threshold(self):
"""triggers then because the joystick events value is too low."""
code_a = system_mapping.get("a")
trigger = 1
self.mapping.change(
EventCombination([EV_KEY, trigger, 1]),
"keyboard",
"if_single(k(a), k(KEY_LEFTSHIFT))",
)
self.mapping.change(EventCombination([EV_ABS, ABS_Y, 1]), "keyboard", "b")
self.mapping.set("gamepad.joystick.left_purpose", BUTTONS)
self.mapping.set("gamepad.joystick.right_purpose", BUTTONS)
context, _ = self.setup(self.gamepad_source, self.mapping)
self.gamepad_source.push_events(
[
new_event(EV_KEY, trigger, 1), # start the macro
new_event(EV_ABS, ABS_Y, 1), # ignored because value too low
new_event(EV_KEY, trigger, 0), # stop, only way to trigger `then`
]
)
await asyncio.sleep(0.1)
self.assertFalse(active_macros[(EV_KEY, 1)].running)
history = [a.t for a in global_uinputs.get_uinput("keyboard").write_history]
# the key that triggered if_single should be injected after
# if_single had a chance to inject keys (if the macro is fast enough),
# so that if_single can inject a modifier to e.g. capitalize the
# triggering key. This is important for the space cadet shift
self.assertListEqual(
history,
[
(EV_KEY, code_a, 1),
(EV_KEY, code_a, 0),
],
)

@ -17,17 +17,22 @@
#
# 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 tests.test import quick_cleanup
from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler
from tests.test import quick_cleanup, get_key_mapping
from evdev.ecodes import (
EV_REL,
EV_ABS,
ABS_X,
ABS_Y,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
)
import unittest
from inputremapper.injection.context import Context
from inputremapper.configs.preset import Preset
from inputremapper.event_combination import EventCombination
from inputremapper.configs.global_config import NONE, MOUSE, WHEEL, BUTTONS
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.configs.mapping import Mapping
class TestContext(unittest.TestCase):
@ -35,95 +40,56 @@ class TestContext(unittest.TestCase):
def setUpClass(cls):
quick_cleanup()
def setUp(self):
self.mapping = Preset()
self.mapping.set("gamepad.joystick.left_purpose", WHEEL)
self.mapping.set("gamepad.joystick.right_purpose", WHEEL)
self.mapping.change(EventCombination([1, 31, 1]), "keyboard", "k(a)")
self.mapping.change(EventCombination([1, 32, 1]), "keyboard", "b")
self.mapping.change(
EventCombination((1, 33, 1), (1, 34, 1), (1, 35, 1)), "keyboard", "c"
def test_callbacks(self):
preset = Preset()
cfg = {
"event_combination": ",".join((str(EV_ABS), str(ABS_X), "0")),
"target_uinput": "mouse",
"output_type": EV_REL,
"output_code": REL_HWHEEL_HI_RES,
}
preset.add(Mapping(**cfg)) # abs x -> wheel
cfg["event_combination"] = ",".join((str(EV_ABS), str(ABS_Y), "0"))
cfg["output_code"] = REL_WHEEL_HI_RES
preset.add(Mapping(**cfg)) # abs y -> wheel
preset.add(get_key_mapping(EventCombination((1, 31, 1)), "keyboard", "k(a)"))
preset.add(get_key_mapping(EventCombination((1, 32, 1)), "keyboard", "b"))
# overlapping combination for (1, 32, 1)
preset.add(
get_key_mapping(
EventCombination(((1, 32, 1), (1, 33, 1), (1, 34, 1))), "keyboard", "c"
)
)
self.context = Context(self.mapping)
def test_update_purposes(self):
self.mapping.set("gamepad.joystick.left_purpose", BUTTONS)
self.mapping.set("gamepad.joystick.right_purpose", MOUSE)
self.context.update_purposes()
self.assertEqual(self.context.left_purpose, BUTTONS)
self.assertEqual(self.context.right_purpose, MOUSE)
def test_parse_macros(self):
self.assertEqual(len(self.context.macros), 1)
self.assertEqual(self.context.macros[((1, 31, 1),)][1], "keyboard")
self.assertEqual(self.context.macros[((1, 31, 1),)][0].code, "k(a)")
def test_map_keys_to_codes(self):
b = system_mapping.get("b")
c = system_mapping.get("c")
self.assertEqual(len(self.context.key_to_code), 3)
self.assertEqual(self.context.key_to_code[((1, 32, 1),)], (b, "keyboard"))
self.assertEqual(
self.context.key_to_code[(1, 33, 1), (1, 34, 1), (1, 35, 1)],
(c, "keyboard"),
)
self.assertEqual(
self.context.key_to_code[(1, 34, 1), (1, 33, 1), (1, 35, 1)],
(c, "keyboard"),
# map abs x to key "b"
preset.add(
get_key_mapping(EventCombination([EV_ABS, ABS_X, 20]), "keyboard", "d")
)
context = Context(preset)
# expected callbacks and their lengths:
callbacks = {
(
EV_ABS,
ABS_X,
): 2, # ABS_X -> "d" and ABS_X -> wheel have the same type and code
(EV_ABS, ABS_Y): 1,
(1, 31): 1,
# even though we have 2 mappings with this type and code, we only expect one callback
# because they both map to keys. We don't want to trigger two mappings with the same key press
(1, 32): 1,
(1, 33): 1,
(1, 34): 1,
}
self.assertEqual(set(callbacks.keys()), set(context.callbacks.keys()))
for key, val in callbacks.items():
self.assertEqual(val, len(context.callbacks[key]))
def test_is_mapped(self):
self.assertTrue(self.context.is_mapped(((1, 32, 1),)))
self.assertTrue(self.context.is_mapped(((1, 33, 1), (1, 34, 1), (1, 35, 1))))
self.assertTrue(self.context.is_mapped(((1, 34, 1), (1, 33, 1), (1, 35, 1))))
self.assertFalse(self.context.is_mapped(((1, 34, 1), (1, 35, 1), (1, 33, 1))))
self.assertFalse(self.context.is_mapped(((1, 36, 1),)))
def test_maps_joystick(self):
self.assertTrue(self.context.maps_joystick())
self.mapping.set("gamepad.joystick.left_purpose", NONE)
self.mapping.set("gamepad.joystick.right_purpose", NONE)
self.context.update_purposes()
self.assertFalse(self.context.maps_joystick())
def test_joystick_as_dpad(self):
self.assertTrue(self.context.maps_joystick())
self.mapping.set("gamepad.joystick.left_purpose", WHEEL)
self.mapping.set("gamepad.joystick.right_purpose", MOUSE)
self.context.update_purposes()
self.assertFalse(self.context.joystick_as_dpad())
self.mapping.set("gamepad.joystick.left_purpose", BUTTONS)
self.mapping.set("gamepad.joystick.right_purpose", NONE)
self.context.update_purposes()
self.assertTrue(self.context.joystick_as_dpad())
self.mapping.set("gamepad.joystick.left_purpose", MOUSE)
self.mapping.set("gamepad.joystick.right_purpose", BUTTONS)
self.context.update_purposes()
self.assertTrue(self.context.joystick_as_dpad())
def test_joystick_as_mouse(self):
self.assertTrue(self.context.maps_joystick())
self.mapping.set("gamepad.joystick.right_purpose", MOUSE)
self.context.update_purposes()
self.assertTrue(self.context.joystick_as_mouse())
self.mapping.set("gamepad.joystick.left_purpose", NONE)
self.mapping.set("gamepad.joystick.right_purpose", NONE)
self.context.update_purposes()
self.assertFalse(self.context.joystick_as_mouse())
self.mapping.set("gamepad.joystick.right_purpose", BUTTONS)
self.context.update_purposes()
self.assertFalse(self.context.joystick_as_mouse())
def test_writes_keys(self):
self.assertTrue(self.context.writes_keys())
self.assertFalse(Context(Preset()).writes_keys())
self.assertEqual(
7, len(context._handlers)
) # 7 unique input events in the preset
if __name__ == "__main__":

@ -77,9 +77,9 @@ class TestControl(unittest.TestCase):
get_preset_path(groups_[1].name, presets[2]),
]
Preset().save(paths[0])
Preset().save(paths[1])
Preset().save(paths[2])
Preset(paths[0]).save()
Preset(paths[1]).save()
Preset(paths[2]).save()
daemon = Daemon()
@ -201,8 +201,8 @@ class TestControl(unittest.TestCase):
os.path.join(config_dir, "presets", device_names[1], presets[1] + ".json"),
]
Preset().save(paths[0])
Preset().save(paths[1])
Preset(paths[0]).save()
Preset(paths[1]).save()
daemon = Daemon()

@ -27,6 +27,7 @@ from tests.test import (
is_service_running,
fixtures,
tmp,
get_key_mapping,
)
import os
@ -36,7 +37,7 @@ import subprocess
import json
import evdev
from evdev.ecodes import EV_KEY, EV_ABS, KEY_B, KEY_A
from evdev.ecodes import EV_KEY, EV_ABS, KEY_B, KEY_A, ABS_X, BTN_A
from pydbus import SystemBus
from inputremapper.configs.system_mapping import system_mapping
@ -116,21 +117,21 @@ class TestDaemon(unittest.TestCase):
if os.path.exists(get_config_path("xmodmap.json")):
os.remove(get_config_path("xmodmap.json"))
ev_1 = (EV_KEY, 9)
ev_2 = (EV_ABS, 12)
preset_name = "foo"
group = groups.find(name="Bar Device")
ev_1 = (EV_KEY, BTN_A)
ev_2 = (EV_ABS, ABS_X)
# unrelated group that shouldn't be affected at all
group2 = groups.find(name="gamepad")
active_preset.change(EventCombination([*ev_1, 1]), "keyboard", "a")
active_preset.change(EventCombination([*ev_2, -1]), "keyboard", "b")
group = groups.find(name="gamepad")
preset = "foo"
# unrelated group that shouldn't be affected at all
group2 = groups.find(name="Bar Device")
active_preset.save(group.get_preset_path(preset))
global_config.set_autoload_preset(group.key, preset)
preset = Preset(group.get_preset_path(preset_name))
preset.add(get_key_mapping(EventCombination([*ev_1, 1]), "keyboard", "a"))
preset.add(get_key_mapping(EventCombination([*ev_2, -1]), "keyboard", "b"))
preset.save()
global_config.set_autoload_preset(group.key, preset_name)
"""injection 1"""
@ -144,7 +145,7 @@ class TestDaemon(unittest.TestCase):
# has been cleanedUp in setUp
self.assertNotIn("keyboard", global_uinputs.devices)
self.daemon.start_injecting(group.key, preset)
self.daemon.start_injecting(group.key, preset_name)
# created on demand
self.assertIn("keyboard", global_uinputs.devices)
@ -175,7 +176,7 @@ class TestDaemon(unittest.TestCase):
# -1234 will be classified as -1 by the injector
push_events(group.key, [new_event(*ev_2, -1234)])
self.daemon.start_injecting(group.key, preset)
self.daemon.start_injecting(group.key, preset_name)
time.sleep(0.1)
self.assertTrue(uinput_write_history_pipe[0].poll())
@ -203,6 +204,7 @@ class TestDaemon(unittest.TestCase):
if os.path.exists(get_config_path("xmodmap.json")):
os.remove(get_config_path("xmodmap.json"))
preset_name = "foo"
ev = (EV_KEY, 9)
group_name = "9876 name"
@ -212,18 +214,20 @@ class TestDaemon(unittest.TestCase):
group = groups.find(name=group_name)
# this test only makes sense if this device is unknown yet
self.assertIsNone(group)
active_preset.change(EventCombination([*ev, 1]), "keyboard", "a")
system_mapping.clear()
system_mapping._set("a", KEY_A)
preset = Preset(get_preset_path(group_name, preset_name))
preset.add(get_key_mapping(EventCombination([*ev, 1]), "keyboard", "a"))
# make the daemon load the file instead
with open(get_config_path("xmodmap.json"), "w") as file:
json.dump(system_mapping._mapping, file, indent=4)
system_mapping.clear()
preset = "foo"
active_preset.save(get_preset_path(group_name, preset))
global_config.set_autoload_preset(group_key, preset)
preset.save()
global_config.set_autoload_preset(group_key, preset_name)
push_events(group_key, [new_event(*ev, 1)])
self.daemon = Daemon()
@ -238,7 +242,7 @@ class TestDaemon(unittest.TestCase):
"name": group_name,
}
self.daemon.start_injecting(group_key, preset)
self.daemon.start_injecting(group_key, preset_name)
# test if the injector called groups.refresh successfully
group = groups.find(key=group_key)
@ -282,20 +286,21 @@ class TestDaemon(unittest.TestCase):
def test_xmodmap_file(self):
from_keycode = evdev.ecodes.KEY_A
target = "keyboard"
to_name = "qux"
to_name = "q"
to_keycode = 100
event = (EV_KEY, from_keycode, 1)
name = "Bar Device"
preset = "foo"
preset_name = "foo"
group = groups.find(name=name)
config_dir = os.path.join(tmp, "foo")
path = os.path.join(config_dir, "presets", name, f"{preset}.json")
path = os.path.join(config_dir, "presets", name, f"{preset_name}.json")
active_preset.change(EventCombination(event), target, to_name)
active_preset.save(path)
preset = Preset(path)
preset.add(get_key_mapping(EventCombination(event), target, to_name))
preset.save()
system_mapping.clear()
@ -314,7 +319,7 @@ class TestDaemon(unittest.TestCase):
self.daemon = Daemon()
self.daemon.set_config_dir(config_dir)
self.daemon.start_injecting(group.key, preset)
self.daemon.start_injecting(group.key, preset_name)
time.sleep(0.1)
self.assertTrue(uinput_write_history_pipe[0].poll())
@ -326,29 +331,29 @@ class TestDaemon(unittest.TestCase):
def test_start_stop(self):
group = groups.find(key="Foo Device 2")
preset = "preset8"
preset_name = "preset8"
daemon = Daemon()
self.daemon = daemon
mapping = Preset()
mapping.change(EventCombination([3, 2, 1]), "keyboard", "a")
mapping.save(group.get_preset_path(preset))
pereset = Preset(group.get_preset_path(preset_name))
pereset.add(get_key_mapping(EventCombination([3, 2, 1]), "keyboard", "a"))
pereset.save()
# start
daemon.start_injecting(group.key, preset)
daemon.start_injecting(group.key, preset_name)
# explicit start, not autoload, so the history stays empty
self.assertNotIn(group.key, daemon.autoload_history._autoload_history)
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset))
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset_name))
# path got translated to the device name
self.assertIn(group.key, daemon.injectors)
# start again
previous_injector = daemon.injectors[group.key]
self.assertNotEqual(previous_injector.get_state(), STOPPED)
daemon.start_injecting(group.key, preset)
daemon.start_injecting(group.key, preset_name)
self.assertNotIn(group.key, daemon.autoload_history._autoload_history)
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset))
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset_name))
self.assertIn(group.key, daemon.injectors)
self.assertEqual(previous_injector.get_state(), STOPPED)
# a different injetor is now running
@ -369,53 +374,53 @@ class TestDaemon(unittest.TestCase):
# after all that stuff autoload_history is still unharmed
self.assertNotIn(group.key, daemon.autoload_history._autoload_history)
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset))
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset_name))
# stop
daemon.stop_injecting(group.key)
self.assertNotIn(group.key, daemon.autoload_history._autoload_history)
self.assertEqual(daemon.injectors[group.key].get_state(), STOPPED)
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset))
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset_name))
def test_autoload(self):
preset = "preset7"
preset_name = "preset7"
group = groups.find(key="Foo Device 2")
daemon = Daemon()
self.daemon = daemon
mapping = Preset()
mapping.change(EventCombination([3, 2, 1]), "keyboard", "a")
mapping.save(group.get_preset_path(preset))
preset = Preset(group.get_preset_path(preset_name))
preset.add(get_key_mapping(EventCombination([3, 2, 1]), "keyboard", "a"))
preset.save()
# no autoloading is configured yet
self.daemon._autoload(group.key)
self.assertNotIn(group.key, daemon.autoload_history._autoload_history)
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset))
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset_name))
global_config.set_autoload_preset(group.key, preset)
global_config.set_autoload_preset(group.key, preset_name)
len_before = len(self.daemon.autoload_history._autoload_history)
# now autoloading is configured, so it will autoload
self.daemon._autoload(group.key)
len_after = len(self.daemon.autoload_history._autoload_history)
self.assertEqual(
daemon.autoload_history._autoload_history[group.key][1], preset
daemon.autoload_history._autoload_history[group.key][1], preset_name
)
self.assertFalse(daemon.autoload_history.may_autoload(group.key, preset))
self.assertFalse(daemon.autoload_history.may_autoload(group.key, preset_name))
injector = daemon.injectors[group.key]
self.assertEqual(len_before + 1, len_after)
# calling duplicate _autoload does nothing
self.daemon._autoload(group.key)
self.assertEqual(
daemon.autoload_history._autoload_history[group.key][1], preset
daemon.autoload_history._autoload_history[group.key][1], preset_name
)
self.assertEqual(injector, daemon.injectors[group.key])
self.assertFalse(daemon.autoload_history.may_autoload(group.key, preset))
self.assertFalse(daemon.autoload_history.may_autoload(group.key, preset_name))
# explicit start_injecting clears the autoload history
self.daemon.start_injecting(group.key, preset)
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset))
self.daemon.start_injecting(group.key, preset_name)
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset_name))
# calling autoload for (yet) unknown devices does nothing
len_before = len(self.daemon.autoload_history._autoload_history)
@ -434,30 +439,30 @@ class TestDaemon(unittest.TestCase):
history = self.daemon.autoload_history._autoload_history
# existing device
preset = "preset7"
preset_name = "preset7"
group = groups.find(key="Foo Device 2")
mapping = Preset()
mapping.change(EventCombination([3, 2, 1]), "keyboard", "a")
mapping.save(group.get_preset_path(preset))
global_config.set_autoload_preset(group.key, preset)
preset = Preset(group.get_preset_path(preset_name))
preset.add(get_key_mapping(EventCombination([3, 2, 1]), "keyboard", "a"))
preset.save()
global_config.set_autoload_preset(group.key, preset_name)
# ignored, won't cause problems:
global_config.set_autoload_preset("non-existant-key", "foo")
self.daemon.autoload()
self.assertEqual(len(history), 1)
self.assertEqual(history[group.key][1], preset)
self.assertEqual(history[group.key][1], preset_name)
def test_autoload_3(self):
# based on a bug
preset = "preset7"
preset_name = "preset7"
group = groups.find(key="Foo Device 2")
mapping = Preset()
mapping.change(EventCombination([3, 2, 1]), "keyboard", "a")
mapping.save(group.get_preset_path(preset))
preset = Preset(group.get_preset_path(preset_name))
preset.add(get_key_mapping(EventCombination([3, 2, 1]), "keyboard", "a"))
preset.save()
global_config.set_autoload_preset(group.key, preset)
global_config.set_autoload_preset(group.key, preset_name)
self.daemon = Daemon()
groups.set_groups([]) # caused the bug
@ -467,7 +472,7 @@ class TestDaemon(unittest.TestCase):
# it should try to refresh the groups because all the
# group_keys are unknown at the moment
history = self.daemon.autoload_history._autoload_history
self.assertEqual(history[group.key][1], preset)
self.assertEqual(history[group.key][1], preset_name)
self.assertEqual(self.daemon.get_state(group.key), STARTING)
self.assertIsNotNone(groups.find(key="Foo Device 2"))

@ -111,18 +111,7 @@ class TestDevUtils(unittest.TestCase):
"""joysticks"""
# without a purpose of BUTTONS it won't map any button, even for
# gamepads
self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_RX, 1234)))
self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_RX, 1234)))
self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_Y, -1)))
self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_Y, -1)))
self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_RY, -1)))
self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_RY, -1)))
mapping.set("gamepad.joystick.right_purpose", BUTTONS)
global_config.set("gamepad.joystick.left_purpose", BUTTONS)
# but only for gamepads
# we no longer track the purpose for the gamepad sticks, it is always allowed to map them as buttons
self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_Y, -1)))
self.assertTrue(do(1, new_event(EV_ABS, ecodes.ABS_Y, -1)))
self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_RY, -1)))

@ -30,7 +30,7 @@ from inputremapper.input_event import InputEvent
class TestKey(unittest.TestCase):
def test_key(self):
# its very similar to regular tuples, but with some extra stuff
key_1 = EventCombination((1, 3, 1), (1, 5, 1))
key_1 = EventCombination(((1, 3, 1), (1, 5, 1)))
self.assertEqual(len(key_1), 2)
self.assertEqual(key_1[0], (1, 3, 1))
self.assertEqual(key_1[1], (1, 5, 1))
@ -53,7 +53,7 @@ class TestKey(unittest.TestCase):
self.assertEqual(key_4, key_3)
self.assertEqual(hash(key_4), hash(key_3))
key_5 = EventCombination(*key_4, *key_4, (1, 7, 1))
key_5 = EventCombination((*key_4, *key_4, (1, 7, 1)))
self.assertEqual(len(key_5), 3)
self.assertNotEqual(key_5, key_4)
self.assertNotEqual(hash(key_5), hash(key_4))
@ -65,57 +65,57 @@ class TestKey(unittest.TestCase):
self.assertEqual(len(key_1.get_permutations()), 1)
self.assertEqual(key_1.get_permutations()[0], key_1)
key_2 = EventCombination((1, 3, 1), (1, 5, 1))
key_2 = EventCombination(((1, 3, 1), (1, 5, 1)))
self.assertEqual(len(key_2.get_permutations()), 1)
self.assertEqual(key_2.get_permutations()[0], key_2)
key_3 = EventCombination((1, 3, 1), (1, 5, 1), (1, 7, 1))
key_3 = EventCombination(((1, 3, 1), (1, 5, 1), (1, 7, 1)))
self.assertEqual(len(key_3.get_permutations()), 2)
self.assertEqual(
key_3.get_permutations()[0],
EventCombination((1, 3, 1), (1, 5, 1), (1, 7, 1)),
EventCombination(((1, 3, 1), (1, 5, 1), (1, 7, 1))),
)
self.assertEqual(key_3.get_permutations()[1], ((1, 5, 1), (1, 3, 1), (1, 7, 1)))
def test_is_problematic(self):
key_1 = EventCombination((1, KEY_LEFTSHIFT, 1), (1, 5, 1))
key_1 = EventCombination(((1, KEY_LEFTSHIFT, 1), (1, 5, 1)))
self.assertTrue(key_1.is_problematic())
key_2 = EventCombination((1, KEY_RIGHTALT, 1), (1, 5, 1))
key_2 = EventCombination(((1, KEY_RIGHTALT, 1), (1, 5, 1)))
self.assertTrue(key_2.is_problematic())
key_3 = EventCombination((1, 3, 1), (1, KEY_LEFTCTRL, 1))
key_3 = EventCombination(((1, 3, 1), (1, KEY_LEFTCTRL, 1)))
self.assertTrue(key_3.is_problematic())
key_4 = EventCombination((1, 3, 1))
self.assertFalse(key_4.is_problematic())
key_5 = EventCombination((1, 3, 1), (1, 5, 1))
key_5 = EventCombination(((1, 3, 1), (1, 5, 1)))
self.assertFalse(key_5.is_problematic())
def test_init(self):
self.assertRaises(ValueError, lambda: EventCombination(1))
self.assertRaises(ValueError, lambda: EventCombination(None))
self.assertRaises(TypeError, lambda: EventCombination(1))
self.assertRaises(TypeError, lambda: EventCombination(None))
self.assertRaises(ValueError, lambda: EventCombination([1]))
self.assertRaises(ValueError, lambda: EventCombination((1,)))
self.assertRaises(ValueError, lambda: EventCombination((1, 2)))
self.assertRaises(ValueError, lambda: EventCombination("1"))
self.assertRaises(ValueError, lambda: EventCombination("(1,2,3)"))
self.assertRaises(
ValueError, lambda: EventCombination((1, 2, 3), (1, 2, 3), None)
ValueError, lambda: EventCombination(((1, 2, 3), (1, 2, 3), None))
)
# those don't raise errors
EventCombination((1, 2, 3), (1, 2, 3))
EventCombination(((1, 2, 3), (1, 2, 3)))
EventCombination((1, 2, 3))
EventCombination(("1", "2", "3"))
EventCombination("1, 2, 3")
EventCombination("1, 2, 3", (1, 3, 4), InputEvent.from_string(" 1,5 , 1 "))
EventCombination((1, 2, 3), (1, 2, "3"))
EventCombination(("1, 2, 3", (1, 3, 4), InputEvent.from_string(" 1,5 , 1 ")))
EventCombination(((1, 2, 3), (1, 2, "3")))
def test_json_str(self):
c1 = EventCombination((1, 2, 3))
c2 = EventCombination((1, 2, 3), (4, 5, 6))
c2 = EventCombination(((1, 2, 3), (4, 5, 6)))
self.assertEqual(c1.json_str(), "1,2,3")
self.assertEqual(c2.json_str(), "1,2,3+4,5,6")

File diff suppressed because it is too large Load Diff

@ -1,216 +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/>.
from tests.test import (
InputDevice,
UInput,
MAX_ABS,
clear_write_history,
uinput_write_history,
quick_cleanup,
new_event,
MIN_ABS,
)
import unittest
import asyncio
from evdev.ecodes import (
EV_REL,
REL_X,
REL_Y,
REL_WHEEL,
REL_HWHEEL,
EV_ABS,
ABS_X,
ABS_Y,
ABS_RX,
ABS_RY,
)
from inputremapper.configs.global_config import global_config
from inputremapper.configs.preset import Preset
from inputremapper.injection.context import Context
from inputremapper.injection.consumers.joystick_to_mouse import (
JoystickToMouse,
MOUSE,
WHEEL,
)
abs_state = [0, 0, 0, 0]
class TestJoystickToMouse(unittest.IsolatedAsyncioTestCase):
def setUp(self):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self.mapping = Preset()
self.context = Context(self.mapping)
uinput = UInput()
self.context.uinput = uinput
source = InputDevice("/dev/input/event30")
self.joystick_to_mouse = JoystickToMouse(self.context, source)
global_config.set("gamepad.joystick.x_scroll_speed", 1)
global_config.set("gamepad.joystick.y_scroll_speed", 1)
def tearDown(self):
quick_cleanup()
def assertClose(self, a, b, within):
"""a has to be within b - b * within, b + b * within."""
self.assertLess(a - abs(a) * within, b)
self.assertGreater(a + abs(a) * within, b)
async def test_assertClose(self):
self.assertClose(5, 5, 0.1)
self.assertClose(5, 5, 1)
self.assertClose(6, 5, 0.2)
self.assertClose(4, 5, 0.3)
self.assertRaises(AssertionError, lambda: self.assertClose(6, 5, 0.1))
self.assertRaises(AssertionError, lambda: self.assertClose(4, 5, 0.1))
self.assertClose(-5, -5, 0.1)
self.assertClose(-5, -5, 1)
self.assertClose(-6, -5, 0.2)
self.assertClose(-4, -5, 0.3)
self.assertRaises(AssertionError, lambda: self.assertClose(-6, -5, 0.1))
self.assertRaises(AssertionError, lambda: self.assertClose(-4, -5, 0.1))
async def do(self, a, b, c, d, expectation):
"""Present fake values to the loop and observe the outcome.
Depending on the configuration, the cursor or wheel should move.
"""
clear_write_history()
self.joystick_to_mouse.context.update_purposes()
await self.joystick_to_mouse.notify(new_event(EV_ABS, ABS_X, a))
await self.joystick_to_mouse.notify(new_event(EV_ABS, ABS_Y, b))
await self.joystick_to_mouse.notify(new_event(EV_ABS, ABS_RX, c))
await self.joystick_to_mouse.notify(new_event(EV_ABS, ABS_RY, d))
# sleep long enough to test if multiple events are written
await asyncio.sleep(5 / 60)
history = [h.t for h in uinput_write_history]
self.assertGreater(len(history), 1)
self.assertIn(expectation, history)
for history_entry in history:
self.assertEqual(history_entry[:2], expectation[:2])
# if the injected cursor movement is 19 or 20 doesn't really matter
self.assertClose(history_entry[2], expectation[2], 0.1)
async def test_joystick_purpose_1(self):
asyncio.ensure_future(self.joystick_to_mouse.run())
speed = 20
self.mapping.set("gamepad.joystick.non_linearity", 1)
self.mapping.set("gamepad.joystick.pointer_speed", speed)
self.mapping.set("gamepad.joystick.left_purpose", MOUSE)
self.mapping.set("gamepad.joystick.right_purpose", WHEEL)
min_abs = 0
# if `rest` is not exactly `max_abs / 2` decimal places might add up
# and cause higher or lower values to be written after a few events,
# which might be difficult to test.
max_abs = 256
rest = 128 # resting position of the cursor
self.joystick_to_mouse.set_abs_range(min_abs, max_abs)
await self.do(max_abs, rest, rest, rest, (EV_REL, REL_X, speed))
await self.do(min_abs, rest, rest, rest, (EV_REL, REL_X, -speed))
await self.do(rest, max_abs, rest, rest, (EV_REL, REL_Y, speed))
await self.do(rest, min_abs, rest, rest, (EV_REL, REL_Y, -speed))
# vertical wheel event values are negative
await self.do(rest, rest, max_abs, rest, (EV_REL, REL_HWHEEL, 1))
await self.do(rest, rest, min_abs, rest, (EV_REL, REL_HWHEEL, -1))
await self.do(rest, rest, rest, max_abs, (EV_REL, REL_WHEEL, -1))
await self.do(rest, rest, rest, min_abs, (EV_REL, REL_WHEEL, 1))
async def test_joystick_purpose_2(self):
asyncio.ensure_future(self.joystick_to_mouse.run())
speed = 30
global_config.set("gamepad.joystick.non_linearity", 1)
global_config.set("gamepad.joystick.pointer_speed", speed)
global_config.set("gamepad.joystick.left_purpose", WHEEL)
global_config.set("gamepad.joystick.right_purpose", MOUSE)
global_config.set("gamepad.joystick.x_scroll_speed", 1)
global_config.set("gamepad.joystick.y_scroll_speed", 2)
# vertical wheel event values are negative
await self.do(MAX_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, 1))
await self.do(MIN_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, -1))
await self.do(0, MAX_ABS, 0, 0, (EV_REL, REL_WHEEL, -2))
await self.do(0, MIN_ABS, 0, 0, (EV_REL, REL_WHEEL, 2))
await self.do(0, 0, MAX_ABS, 0, (EV_REL, REL_X, speed))
await self.do(0, 0, MIN_ABS, 0, (EV_REL, REL_X, -speed))
await self.do(0, 0, 0, MAX_ABS, (EV_REL, REL_Y, speed))
await self.do(0, 0, 0, MIN_ABS, (EV_REL, REL_Y, -speed))
async def test_joystick_purpose_3(self):
asyncio.ensure_future(self.joystick_to_mouse.run())
speed = 40
self.mapping.set("gamepad.joystick.non_linearity", 1)
global_config.set("gamepad.joystick.pointer_speed", speed)
self.mapping.set("gamepad.joystick.left_purpose", MOUSE)
global_config.set("gamepad.joystick.right_purpose", MOUSE)
await self.do(MAX_ABS, 0, 0, 0, (EV_REL, REL_X, speed))
await self.do(MIN_ABS, 0, 0, 0, (EV_REL, REL_X, -speed))
await self.do(0, MAX_ABS, 0, 0, (EV_REL, REL_Y, speed))
await self.do(0, MIN_ABS, 0, 0, (EV_REL, REL_Y, -speed))
await self.do(0, 0, MAX_ABS, 0, (EV_REL, REL_X, speed))
await self.do(0, 0, MIN_ABS, 0, (EV_REL, REL_X, -speed))
await self.do(0, 0, 0, MAX_ABS, (EV_REL, REL_Y, speed))
await self.do(0, 0, 0, MIN_ABS, (EV_REL, REL_Y, -speed))
async def test_joystick_purpose_4(self):
asyncio.ensure_future(self.joystick_to_mouse.run())
global_config.set("gamepad.joystick.left_purpose", WHEEL)
global_config.set("gamepad.joystick.right_purpose", WHEEL)
self.mapping.set("gamepad.joystick.x_scroll_speed", 2)
self.mapping.set("gamepad.joystick.y_scroll_speed", 3)
await self.do(MAX_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, 2))
await self.do(MIN_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, -2))
await self.do(0, MAX_ABS, 0, 0, (EV_REL, REL_WHEEL, -3))
await self.do(0, MIN_ABS, 0, 0, (EV_REL, REL_WHEEL, 3))
# vertical wheel event values are negative
await self.do(0, 0, MAX_ABS, 0, (EV_REL, REL_HWHEEL, 2))
await self.do(0, 0, MIN_ABS, 0, (EV_REL, REL_HWHEEL, -2))
await self.do(0, 0, 0, MAX_ABS, (EV_REL, REL_WHEEL, -3))
await self.do(0, 0, 0, MIN_ABS, (EV_REL, REL_WHEEL, 3))
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,177 @@
#!/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 inputremapper.configs.mapping import Mapping
from tests.test import new_event, quick_cleanup, get_key_mapping
import unittest
import asyncio
import evdev
from evdev.ecodes import (
EV_KEY,
EV_ABS,
ABS_X,
ABS_Y,
ABS_RX,
ABS_RY,
EV_REL,
REL_X,
REL_Y,
REL_HWHEEL_HI_RES,
REL_WHEEL_HI_RES,
)
from inputremapper.configs.global_config import BUTTONS, MOUSE, WHEEL
from inputremapper.injection.context import Context
from inputremapper.configs.preset import Preset
from inputremapper.event_combination import EventCombination
from inputremapper.injection.event_reader import EventReader
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.injection.global_uinputs import global_uinputs
class TestEventReader(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.gamepad_source = evdev.InputDevice("/dev/input/event30")
self.stop_event = asyncio.Event()
self.preset = Preset()
def tearDown(self):
quick_cleanup()
def setup(self, source, mapping):
"""Set a a EventReader up for the test and run it in the background."""
forward_to = evdev.UInput()
context = Context(mapping)
context.uinput = evdev.UInput()
consumer_control = EventReader(context, source, forward_to, self.stop_event)
# for consumer in consumer_control._consumers:
# consumer._abs_range = (-10, 10)
asyncio.ensure_future(consumer_control.run())
return context, consumer_control
async def test_if_single_joystick_then(self):
# TODO: Move this somewhere more sensible
# Integration test style for if_single.
# won't care about the event, because the purpose is not set to BUTTON
code_a = system_mapping.get("a")
code_shift = system_mapping.get("KEY_LEFTSHIFT")
trigger = 1
self.preset.add(
get_key_mapping(
EventCombination([EV_KEY, trigger, 1]),
"keyboard",
"if_single(key(a), key(KEY_LEFTSHIFT))",
)
)
self.preset.add(
get_key_mapping(EventCombination([EV_ABS, ABS_Y, 1]), "keyboard", "b")
)
# left x to mouse x
cfg = {
"event_combination": ",".join((str(EV_ABS), str(ABS_X), "0")),
"target_uinput": "mouse",
"output_type": EV_REL,
"output_code": REL_X,
}
self.preset.add(Mapping(**cfg))
# left y to mouse y
cfg["event_combination"] = ",".join((str(EV_ABS), str(ABS_Y), "0"))
cfg["output_code"] = REL_Y
self.preset.add(Mapping(**cfg))
# right x to wheel x
cfg["event_combination"] = ",".join((str(EV_ABS), str(ABS_RX), "0"))
cfg["output_code"] = REL_HWHEEL_HI_RES
self.preset.add(Mapping(**cfg))
# right y to wheel y
cfg["event_combination"] = ",".join((str(EV_ABS), str(ABS_RY), "0"))
cfg["output_code"] = REL_WHEEL_HI_RES
self.preset.add(Mapping(**cfg))
context, _ = self.setup(self.gamepad_source, self.preset)
self.gamepad_source.push_events(
[
new_event(EV_KEY, trigger, 1), # start the macro
new_event(EV_ABS, ABS_Y, 10), # ignored
new_event(EV_KEY, 2, 2), # ignored
new_event(EV_KEY, 2, 0), # ignored
new_event(EV_REL, 1, 1), # ignored
# stop it, the only way to trigger `then`
new_event(EV_KEY, trigger, 0),
]
)
await asyncio.sleep(0.1)
self.assertEqual(len(context.listeners), 0)
history = [a.t for a in global_uinputs.get_uinput("keyboard").write_history]
self.assertIn((EV_KEY, code_a, 1), history)
self.assertIn((EV_KEY, code_a, 0), history)
self.assertNotIn((EV_KEY, code_shift, 1), history)
self.assertNotIn((EV_KEY, code_shift, 0), history)
async def test_if_single_joystick_under_threshold(self):
"""triggers then because the joystick events value is too low."""
# TODO: Move this somewhere more sensible
code_a = system_mapping.get("a")
trigger = 1
self.preset.add(
get_key_mapping(
EventCombination([EV_KEY, trigger, 1]),
"keyboard",
"if_single(k(a), k(KEY_LEFTSHIFT))",
)
)
self.preset.add(
get_key_mapping(EventCombination([EV_ABS, ABS_Y, 1]), "keyboard", "b")
)
# self.preset.set("gamepad.joystick.left_purpose", BUTTONS)
# self.preset.set("gamepad.joystick.right_purpose", BUTTONS)
context, _ = self.setup(self.gamepad_source, self.preset)
self.gamepad_source.push_events(
[
new_event(EV_KEY, trigger, 1), # start the macro
new_event(EV_ABS, ABS_Y, 1), # ignored because value too low
new_event(EV_KEY, trigger, 0), # stop, only way to trigger `then`
]
)
await asyncio.sleep(0.1)
self.assertEqual(len(context.listeners), 0)
history = [a.t for a in global_uinputs.get_uinput("keyboard").write_history]
# the key that triggered if_single should be injected after
# if_single had a chance to inject keys (if the macro is fast enough),
# so that if_single can inject a modifier to e.g. capitalize the
# triggering key. This is important for the space cadet shift
self.assertListEqual(
history,
[
(EV_KEY, code_a, 1),
(EV_KEY, code_a, 0),
],
)

@ -17,8 +17,9 @@
#
# 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 pydantic import ValidationError
from inputremapper.configs.mapping import Mapping
from tests.test import (
new_event,
push_events,
@ -32,6 +33,7 @@ from tests.test import (
uinputs,
keyboard_keys,
MIN_ABS,
get_key_mapping,
)
import unittest
@ -58,7 +60,6 @@ from evdev.ecodes import (
KEY_C,
)
from inputremapper.injection.consumers.joystick_to_mouse import JoystickToMouse
from inputremapper.injection.injector import (
Injector,
is_in_capabilities,
@ -120,24 +121,16 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
quick_cleanup()
def find_joystick_to_mouse(self):
# this object became somewhat a pain to retreive
return [
consumer
for consumer in self.injector._consumer_controls[0]._consumers
if isinstance(consumer, JoystickToMouse)
][0]
def test_grab(self):
# path is from the fixtures
path = "/dev/input/event10"
preset = Preset()
preset.add(get_key_mapping(EventCombination([EV_KEY, 10, 1]), "keyboard", "a"))
active_preset.change(EventCombination([EV_KEY, 10, 1]), "keyboard", "a")
self.injector = Injector(groups.find(key="Foo Device 2"), active_preset)
self.injector = Injector(groups.find(key="Foo Device 2"), preset)
# this test needs to pass around all other constraints of
# _grab_device
self.injector.context = Context(active_preset)
self.injector.context = Context(preset)
device = self.injector._grab_device(path)
gamepad = classify(device) == GAMEPAD
self.assertFalse(gamepad)
@ -147,11 +140,12 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
def test_fail_grab(self):
self.make_it_fail = 999
active_preset.change(EventCombination([EV_KEY, 10, 1]), "keyboard", "a")
preset = Preset()
preset.add(get_key_mapping(EventCombination([EV_KEY, 10, 1]), "keyboard", "a"))
self.injector = Injector(groups.find(key="Foo Device 2"), active_preset)
self.injector = Injector(groups.find(key="Foo Device 2"), preset)
path = "/dev/input/event10"
self.injector.context = Context(active_preset)
self.injector.context = Context(preset)
device = self.injector._grab_device(path)
self.assertIsNone(device)
self.assertGreaterEqual(self.failed, 1)
@ -166,9 +160,12 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
self.assertEqual(self.injector.get_state(), NO_GRAB)
def test_grab_device_1(self):
active_preset.change(EventCombination([EV_ABS, ABS_HAT0X, 1]), "keyboard", "a")
self.injector = Injector(groups.find(name="gamepad"), active_preset)
self.injector.context = Context(active_preset)
preset = Preset()
preset.add(
get_key_mapping(EventCombination([EV_ABS, ABS_HAT0X, 1]), "keyboard", "a")
)
self.injector = Injector(groups.find(name="gamepad"), preset)
self.injector.context = Context(preset)
_grab_device = self.injector._grab_device
# doesn't have the required capability
@ -178,62 +175,42 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
# this doesn't exist
self.assertIsNone(_grab_device("/dev/input/event1234"))
def test_gamepad_purpose_none(self):
def test_forward_gamepad_events(self):
# forward abs joystick events
active_preset.set("gamepad.joystick.left_purpose", NONE)
global_config.set("gamepad.joystick.right_purpose", NONE)
self.injector = Injector(groups.find(name="gamepad"), active_preset)
self.injector.context = Context(active_preset)
preset = Preset()
self.injector = Injector(groups.find(name="gamepad"), preset)
self.injector.context = Context(preset)
path = "/dev/input/event30"
device = self.injector._grab_device(path)
self.assertIsNone(device) # no capability is used, so it won't grab
active_preset.change(EventCombination([EV_KEY, BTN_A, 1]), "keyboard", "a")
device = self.injector._grab_device(path)
self.assertIsNotNone(device)
gamepad = classify(device) == GAMEPAD
self.assertTrue(gamepad)
def test_gamepad_purpose_none_2(self):
# forward abs joystick events for the left joystick only
active_preset.set("gamepad.joystick.left_purpose", NONE)
global_config.set("gamepad.joystick.right_purpose", MOUSE)
self.injector = Injector(groups.find(name="gamepad"), active_preset)
self.injector.context = Context(active_preset)
path = "/dev/input/event30"
preset.add(
get_key_mapping(EventCombination([EV_KEY, BTN_A, 1]), "keyboard", "a")
)
device = self.injector._grab_device(path)
# the right joystick maps as mouse, so it is grabbed
# even with an empty preset
self.assertIsNotNone(device)
gamepad = classify(device) == GAMEPAD
self.assertTrue(gamepad)
active_preset.change(EventCombination([EV_KEY, BTN_A, 1]), "keyboard", "a")
device = self.injector._grab_device(path)
gamepad = classify(device) == GAMEPAD
self.assertIsNotNone(device)
self.assertTrue(gamepad)
def test_skip_unused_device(self):
# skips a device because its capabilities are not used in the preset
active_preset.change(EventCombination([EV_KEY, 10, 1]), "keyboard", "a")
self.injector = Injector(groups.find(key="Foo Device 2"), active_preset)
self.injector.context = Context(active_preset)
preset = Preset()
preset.add(get_key_mapping(EventCombination([EV_KEY, 10, 1]), "keyboard", "a"))
self.injector = Injector(groups.find(key="Foo Device 2"), preset)
self.injector.context = Context(preset)
path = "/dev/input/event11"
device = self.injector._grab_device(path)
self.assertIsNone(device)
self.assertEqual(self.failed, 0)
def test_skip_unknown_device(self):
active_preset.change(EventCombination([EV_KEY, 10, 1]), "keyboard", "a")
preset = Preset()
preset.add(get_key_mapping(EventCombination([EV_KEY, 10, 1]), "keyboard", "a"))
# skips a device because its capabilities are not used in the preset
self.injector = Injector(groups.find(key="Foo Device 2"), active_preset)
self.injector.context = Context(active_preset)
self.injector = Injector(groups.find(key="Foo Device 2"), preset)
self.injector.context = Context(preset)
path = "/dev/input/event11"
device = self.injector._grab_device(path)
@ -241,163 +218,6 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
self.assertEqual(self.failed, 0)
self.assertIsNone(device)
def test_gamepad_to_mouse(self):
# maps gamepad joystick events to mouse events
global_config.set("gamepad.joystick.non_linearity", 1)
pointer_speed = 80
global_config.set("gamepad.joystick.pointer_speed", pointer_speed)
global_config.set("gamepad.joystick.left_purpose", MOUSE)
# they need to sum up before something is written
divisor = 10
x = MAX_ABS / pointer_speed / divisor
y = MAX_ABS / pointer_speed / divisor
push_events(
"gamepad",
[
new_event(EV_ABS, ABS_X, x),
new_event(EV_ABS, ABS_Y, y),
new_event(EV_ABS, ABS_X, -x),
new_event(EV_ABS, ABS_Y, -y),
],
)
self.injector = Injector(groups.find(name="gamepad"), active_preset)
self.injector.start()
# wait for the injector to start sending, at most 1s
uinput_write_history_pipe[0].poll(1)
# wait a bit more for it to sum up
sleep = 0.5
time.sleep(sleep)
# convert the write history to some easier to manage list
history = read_write_history_pipe()
if history[0][0] == EV_ABS:
raise AssertionError(
"The injector probably just forwarded them unchanged"
# possibly in addition to writing mouse events
)
# movement is written at 60hz and it takes `divisor` steps to
# move 1px. take it times 2 for both x and y events.
self.assertGreater(len(history), 60 * sleep * 0.9 * 2 / divisor)
self.assertLess(len(history), 60 * sleep * 1.1 * 2 / divisor)
# those may be in arbitrary order
count_x = history.count((EV_REL, REL_X, -1))
count_y = history.count((EV_REL, REL_Y, -1))
self.assertGreater(count_x, 1)
self.assertGreater(count_y, 1)
# only those two types of events were written
self.assertEqual(len(history), count_x + count_y)
def test_gamepad_forward_joysticks(self):
push_events(
"gamepad",
[
# should forward them unmodified
new_event(EV_ABS, ABS_X, 10),
new_event(EV_ABS, ABS_Y, 20),
new_event(EV_ABS, ABS_X, -30),
new_event(EV_ABS, ABS_Y, -40),
new_event(EV_KEY, BTN_A, 1),
new_event(EV_KEY, BTN_A, 0),
]
* 2,
)
active_preset.set("gamepad.joystick.left_purpose", NONE)
active_preset.set("gamepad.joystick.right_purpose", NONE)
# BTN_A -> 77
active_preset.change(EventCombination([1, BTN_A, 1]), "keyboard", "b")
system_mapping._set("b", 77)
self.injector = Injector(groups.find(name="gamepad"), active_preset)
self.injector.start()
# wait for the injector to start sending, at most 1s
uinput_write_history_pipe[0].poll(1)
time.sleep(0.2)
# convert the write history to some easier to manage list
history = read_write_history_pipe()
self.assertEqual(history.count((EV_ABS, ABS_X, 10)), 2)
self.assertEqual(history.count((EV_ABS, ABS_Y, 20)), 2)
self.assertEqual(history.count((EV_ABS, ABS_X, -30)), 2)
self.assertEqual(history.count((EV_ABS, ABS_Y, -40)), 2)
self.assertEqual(history.count((EV_KEY, 77, 1)), 2)
self.assertEqual(history.count((EV_KEY, 77, 0)), 2)
def test_gamepad_trigger(self):
# map one of the triggers to BTN_NORTH, while the other one
# should be forwarded unchanged
value = MAX_ABS // 2
push_events(
"gamepad",
[
new_event(EV_ABS, ABS_Z, value),
new_event(EV_ABS, ABS_RZ, value),
],
)
# ABS_Z -> 77
# ABS_RZ is not mapped
active_preset.change(EventCombination((EV_ABS, ABS_Z, 1)), "keyboard", "b")
system_mapping._set("b", 77)
self.injector = Injector(groups.find(name="gamepad"), active_preset)
self.injector.start()
# wait for the injector to start sending, at most 1s
uinput_write_history_pipe[0].poll(1)
time.sleep(0.2)
# convert the write history to some easier to manage list
history = read_write_history_pipe()
self.assertEqual(history.count((EV_KEY, 77, 1)), 1)
self.assertEqual(history.count((EV_ABS, ABS_RZ, value)), 1)
@mock.patch("evdev.InputDevice.ungrab")
def test_gamepad_to_mouse_joystick_to_mouse(self, ungrab_patch):
active_preset.set("gamepad.joystick.left_purpose", MOUSE)
active_preset.set("gamepad.joystick.right_purpose", NONE)
self.injector = Injector(groups.find(name="gamepad"), active_preset)
# the stop message will be available in the pipe right away,
# so run won't block and just stop. all the stuff
# will be initialized though, so that stuff can be tested
self.injector.stop_injecting()
# the context serves no purpose in the main process (which runs the
# tests). The context is only accessible in the newly created process.
self.assertIsNone(self.injector.context)
# not in a process because this doesn't call start, so the
# joystick_to_mouse state can be checked
self.injector.run()
joystick_to_mouse = self.find_joystick_to_mouse()
self.assertEqual(joystick_to_mouse._abs_range[0], MIN_ABS)
self.assertEqual(joystick_to_mouse._abs_range[1], MAX_ABS)
self.assertEqual(
self.injector.context.preset.get("gamepad.joystick.left_purpose"), MOUSE
)
self.assertEqual(ungrab_patch.call_count, 1)
def test_device1_not_a_gamepad(self):
active_preset.set("gamepad.joystick.left_purpose", MOUSE)
active_preset.set("gamepad.joystick.right_purpose", WHEEL)
self.injector = Injector(groups.find(key="Foo Device 2"), active_preset)
self.injector.stop_injecting()
self.injector.run()
# not a gamepad, so nothing should happen
self.assertEqual(len(self.injector._consumer_controls), 0)
def test_get_udev_name(self):
self.injector = Injector(groups.find(key="Foo Device 2"), active_preset)
suffix = "mapped"
@ -414,11 +234,14 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
@mock.patch("evdev.InputDevice.ungrab")
def test_capabilities_and_uinput_presence(self, ungrab_patch):
active_preset.change(EventCombination([EV_KEY, KEY_A, 1]), "keyboard", "c")
active_preset.change(
EventCombination([EV_REL, REL_HWHEEL, 1]), "keyboard", "k(b)"
preset = Preset()
m1 = get_key_mapping(EventCombination([EV_KEY, KEY_A, 1]), "keyboard", "c")
m2 = get_key_mapping(
EventCombination([EV_REL, REL_HWHEEL, 1]), "keyboard", "key(b)"
)
self.injector = Injector(groups.find(key="Foo Device 2"), active_preset)
preset.add(m1)
preset.add(m2)
self.injector = Injector(groups.find(key="Foo Device 2"), preset)
self.injector.stop_injecting()
self.injector.run()
@ -426,20 +249,13 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
self.injector.context.preset.get_mapping(
EventCombination([EV_KEY, KEY_A, 1])
),
("c", "keyboard"),
)
self.assertEqual(
self.injector.context.key_to_code[((EV_KEY, KEY_A, 1),)],
(KEY_C, "keyboard"),
m1,
)
self.assertEqual(
self.injector.context.preset.get_mapping(
EventCombination([EV_REL, REL_HWHEEL, 1])
),
("k(b)", "keyboard"),
)
self.assertEqual(
self.injector.context.macros[((EV_REL, REL_HWHEEL, 1),)][0].code, "k(b)"
m2,
)
self.assertListEqual(
@ -467,18 +283,9 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
self.assertEqual(ungrab_patch.call_count, 2)
def test_injector(self):
# the tests in test_keycode_mapper.py test this stuff in detail
numlock_before = is_numlock_on()
combination = EventCombination((EV_KEY, 8, 1), (EV_KEY, 9, 1))
active_preset.change(combination, "keyboard", "k(KEY_Q).k(w)")
active_preset.change(EventCombination([EV_ABS, ABS_HAT0X, -1]), "keyboard", "a")
# one mapping that is unknown in the system_mapping on purpose
input_b = 10
active_preset.change(EventCombination([EV_KEY, input_b, 1]), "keyboard", "b")
# stuff the active_preset outputs (except for the unknown b)
# stuff the preset outputs
system_mapping.clear()
code_a = 100
code_q = 101
@ -487,14 +294,32 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
system_mapping._set("key_q", code_q)
system_mapping._set("w", code_w)
preset = Preset()
preset.add(
get_key_mapping(
EventCombination(((EV_KEY, 8, 1), (EV_KEY, 9, 1))),
"keyboard",
"k(KEY_Q).k(w)",
)
)
preset.add(
get_key_mapping(EventCombination([EV_ABS, ABS_HAT0X, -1]), "keyboard", "a")
)
# one mapping that is unknown in the system_mapping on purpose
input_b = 10
with self.assertRaises(ValidationError):
preset.add(
get_key_mapping(EventCombination([EV_KEY, input_b, 1]), "keyboard", "b")
)
push_events(
"Bar Device",
"gamepad",
[
# should execute a macro...
new_event(EV_KEY, 8, 1),
new_event(EV_KEY, 9, 1), # ...now
new_event(EV_KEY, 8, 0),
new_event(EV_KEY, 9, 0),
new_event(EV_KEY, 8, 1), # forwarded
new_event(EV_KEY, 9, 1), # triggers macro
new_event(EV_KEY, 8, 0), # releases macro
new_event(EV_KEY, 9, 0), # forwarded
# gamepad stuff. trigger a combination
new_event(EV_ABS, ABS_HAT0X, -1),
new_event(EV_ABS, ABS_HAT0X, 0),
@ -505,7 +330,7 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
],
)
self.injector = Injector(groups.find(name="Bar Device"), active_preset)
self.injector = Injector(groups.find(name="gamepad"), preset)
self.assertEqual(self.injector.get_state(), UNKNOWN)
self.injector.start()
self.assertEqual(self.injector.get_state(), STARTING)
@ -521,17 +346,26 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
# convert the write history to some easier to manage list
history = read_write_history_pipe()
# 1 event before the combination was triggered (+1 for release)
# 1 event before the combination was triggered
# 2 events for releasing the combination trigger (by combination handler)
# 4 events for the macro
# 1 release of the event that didn't release the macro
# 2 for mapped keys
# 3 for forwarded events
self.assertEqual(len(history), 11)
self.assertEqual(len(history), 13)
# the first bit is ordered properly
self.assertEqual(history[0], (EV_KEY, 8, 1)) # forwarded
del history[0]
self.assertIn((EV_KEY, 8, 0), history[0:2]) # released by combination handler
self.assertIn((EV_KEY, 9, 0), history[0:2]) # released by combination handler
del history[0]
del history[0]
# since the macro takes a little bit of time to execute, its
# keystrokes are all over the place.
# just check if they are there and if so, remove them from the list.
self.assertIn((EV_KEY, 8, 1), history)
self.assertIn((EV_KEY, code_q, 1), history)
# the macro itself
self.assertIn((EV_KEY, code_q, 1), history)
self.assertIn((EV_KEY, code_q, 0), history)
self.assertIn((EV_KEY, code_w, 1), history)
@ -543,27 +377,22 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
self.assertGreater(index_q_0, index_q_1)
self.assertGreater(index_w_1, index_q_0)
self.assertGreater(index_w_0, index_w_1)
del history[index_q_1]
index_q_0 = history.index((EV_KEY, code_q, 0))
del history[index_q_0]
index_w_1 = history.index((EV_KEY, code_w, 1))
del history[index_w_1]
index_w_0 = history.index((EV_KEY, code_w, 0))
del history[index_w_0]
del history[index_w_1]
del history[index_q_0]
del history[index_q_1]
# the rest should be in order.
# first the incomplete combination key that wasn't mapped to anything
# and just forwarded. The input event that triggered the macro
# won't appear here.
self.assertEqual(history[0], (EV_KEY, 8, 1))
self.assertEqual(history[1], (EV_KEY, 8, 0))
# the rest should be in order now.
# first the released combination key which did not release the macro.
# the combination key which released the macro won't appear here.
self.assertEqual(history[0], (EV_KEY, 9, 0))
# value should be 1, even if the input event was -1.
# Injected keycodes should always be either 0 or 1
self.assertEqual(history[2], (EV_KEY, code_a, 1))
self.assertEqual(history[3], (EV_KEY, code_a, 0))
self.assertEqual(history[4], (EV_KEY, input_b, 1))
self.assertEqual(history[5], (EV_KEY, input_b, 0))
self.assertEqual(history[6], (3124, 3564, 6542))
self.assertEqual(history[1], (EV_KEY, code_a, 1))
self.assertEqual(history[2], (EV_KEY, code_a, 0))
self.assertEqual(history[3], (EV_KEY, input_b, 1))
self.assertEqual(history[4], (EV_KEY, input_b, 0))
self.assertEqual(history[5], (3124, 3564, 6542))
time.sleep(0.1)
self.assertTrue(self.injector.is_alive())
@ -572,229 +401,19 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
self.assertEqual(numlock_before, numlock_after)
self.assertEqual(self.injector.get_state(), RUNNING)
def test_any_funky_event_as_button(self):
# as long as should_map_as_btn says it should be a button,
# it will be.
EV_TYPE = 4531
CODE_1 = 754
CODE_2 = 4139
w_down = (EV_TYPE, CODE_1, -1)
w_up = (EV_TYPE, CODE_1, 0)
d_down = (EV_TYPE, CODE_2, 1)
d_up = (EV_TYPE, CODE_2, 0)
active_preset.change(EventCombination([*w_down[:2], -1]), "keyboard", "w")
active_preset.change(EventCombination([*d_down[:2], 1]), "keyboard", "k(d)")
system_mapping.clear()
code_w = 71
code_d = 74
system_mapping._set("w", code_w)
system_mapping._set("d", code_d)
def do_stuff():
if self.injector is not None:
# discard the previous injector
self.injector.stop_injecting()
time.sleep(0.1)
while uinput_write_history_pipe[0].poll():
uinput_write_history_pipe[0].recv()
push_events(
"gamepad",
[
new_event(*w_down),
new_event(*d_down),
new_event(*w_up),
new_event(*d_up),
],
)
self.injector = Injector(groups.find(name="gamepad"), active_preset)
# the injector will otherwise skip the device because
# the capabilities don't contain EV_TYPE
input = InputDevice("/dev/input/event30")
self.injector._grab_device = lambda *args: input
self.injector.start()
uinput_write_history_pipe[0].poll(timeout=1)
time.sleep(EVENT_READ_TIMEOUT * 10)
return read_write_history_pipe()
"""no"""
history = do_stuff()
self.assertEqual(history.count((EV_KEY, code_w, 1)), 0)
self.assertEqual(history.count((EV_KEY, code_d, 1)), 0)
self.assertEqual(history.count((EV_KEY, code_w, 0)), 0)
self.assertEqual(history.count((EV_KEY, code_d, 0)), 0)
"""yes"""
with mock.patch("inputremapper.utils.should_map_as_btn", lambda *_: True):
history = do_stuff()
self.assertEqual(history.count((EV_KEY, code_w, 1)), 1)
self.assertEqual(history.count((EV_KEY, code_d, 1)), 1)
self.assertEqual(history.count((EV_KEY, code_w, 0)), 1)
self.assertEqual(history.count((EV_KEY, code_d, 0)), 1)
def test_wheel(self):
# wheel release events are made up with a debouncer
# map those two to stuff
w_up = (EV_REL, REL_WHEEL, -1)
hw_right = (EV_REL, REL_HWHEEL, 1)
# should be forwarded and present in the capabilities
hw_left = (EV_REL, REL_HWHEEL, -1)
active_preset.change(EventCombination(hw_right), "keyboard", "k(b)")
active_preset.change(EventCombination(w_up), "keyboard", "c")
system_mapping.clear()
code_b = 91
code_c = 92
system_mapping._set("b", code_b)
system_mapping._set("c", code_c)
group_key = "Foo Device 2"
push_events(
group_key,
[new_event(*w_up)] * 10
+ [new_event(*hw_right), new_event(*w_up)] * 5
+ [new_event(*hw_left)],
)
group = groups.find(key=group_key)
self.injector = Injector(group, active_preset)
device = InputDevice("/dev/input/event11")
# make sure this test uses a device that has the needed capabilities
# for the injector to grab it
self.assertIn(EV_REL, device.capabilities())
self.assertIn(REL_WHEEL, device.capabilities()[EV_REL])
self.assertIn(REL_HWHEEL, device.capabilities()[EV_REL])
self.assertIn(device.path, group.paths)
self.injector.start()
# wait for the first injected key down event
uinput_write_history_pipe[0].poll(timeout=1)
self.assertTrue(uinput_write_history_pipe[0].poll())
event = uinput_write_history_pipe[0].recv()
self.assertEqual(event.t, (EV_KEY, code_c, 1))
# in 5 more read-loop ticks, nothing new should have happened.
# add a bit of a head-start of one EVENT_READ_TIMEOUT to avoid race-conditions
# in tests
self.assertFalse(
uinput_write_history_pipe[0].poll(timeout=EVENT_READ_TIMEOUT * 6)
)
# 5 more and it should be within the second phase in which
# the horizontal wheel is used. add some tolerance
self.assertAlmostEqual(
wait_for_uinput_write(), EVENT_READ_TIMEOUT * 5, delta=EVENT_READ_TIMEOUT
)
event = uinput_write_history_pipe[0].recv()
self.assertEqual(event.t, (EV_KEY, code_b, 1))
time.sleep(EVENT_READ_TIMEOUT * 10 + 5 / 60)
# after 21 read-loop ticks all events should be consumed, wait for
# at least 3 (lets use 5 so that the test passes even if it lags)
# ticks so that the debouncers are triggered.
# Key-up events for both wheel events should be written now that no
# new key-down event arrived.
events = read_write_history_pipe()
self.assertEqual(events.count((EV_KEY, code_b, 0)), 1)
self.assertEqual(events.count((EV_KEY, code_c, 0)), 1)
self.assertEqual(events.count(hw_left), 1) # the unmapped wheel
# the unmapped wheel won't get a debounced release command, it's
# forwarded as is
self.assertNotIn((EV_REL, REL_HWHEEL, 0), events)
self.assertEqual(len(events), 3)
def test_store_permutations_for_macros(self):
mapping = Preset()
ev_1 = (EV_KEY, 41, 1)
ev_2 = (EV_KEY, 42, 1)
ev_3 = (EV_KEY, 43, 1)
# a combination
mapping.change(EventCombination(ev_1, ev_2, ev_3), "keyboard", "k(a)")
self.injector = Injector(groups.find(key="Foo Device 2"), mapping)
history = []
class Stop(Exception):
pass
def _copy_capabilities(*args):
history.append(args)
# avoid going into any mainloop
raise Stop()
with mock.patch.object(self.injector, "_copy_capabilities", _copy_capabilities):
try:
self.injector.run()
except Stop:
pass
# one call
self.assertEqual(len(history), 1)
# first argument of the first call
macros = self.injector.context.macros
self.assertEqual(len(macros), 2)
self.assertEqual(macros[(ev_1, ev_2, ev_3)][0].code, "k(a)")
self.assertEqual(macros[(ev_2, ev_1, ev_3)][0].code, "k(a)")
def test_key_to_code(self):
mapping = Preset()
ev_1 = (EV_KEY, 41, 1)
ev_2 = (EV_KEY, 42, 1)
ev_3 = (EV_KEY, 43, 1)
ev_4 = (EV_KEY, 44, 1)
mapping.change(EventCombination(ev_1), "keyboard", "a")
# a combination
mapping.change(EventCombination(ev_2, ev_3, ev_4), "keyboard", "b")
self.assertEqual(
mapping.get_mapping(EventCombination(ev_2, ev_3, ev_4)), ("b", "keyboard")
)
system_mapping.clear()
system_mapping._set("a", 51)
system_mapping._set("b", 52)
injector = Injector(groups.find(key="Foo Device 2"), mapping)
injector.context = Context(mapping)
self.assertEqual(injector.context.key_to_code.get((ev_1,)), (51, "keyboard"))
# permutations to make matching combinations easier
self.assertEqual(
injector.context.key_to_code.get((ev_2, ev_3, ev_4)), (52, "keyboard")
)
self.assertEqual(
injector.context.key_to_code.get((ev_3, ev_2, ev_4)), (52, "keyboard")
)
self.assertEqual(len(injector.context.key_to_code), 3)
def test_is_in_capabilities(self):
key = EventCombination([1, 2, 1])
key = EventCombination((1, 2, 1))
capabilities = {1: [9, 2, 5]}
self.assertTrue(is_in_capabilities(key, capabilities))
key = EventCombination((1, 2, 1), (1, 3, 1))
key = EventCombination(((1, 2, 1), (1, 3, 1)))
capabilities = {1: [9, 2, 5]}
# only one of the codes of the combination is required.
# The goal is to make combinations= across those sub-devices possible,
# that make up one hardware device
self.assertTrue(is_in_capabilities(key, capabilities))
key = EventCombination((1, 2, 1), (1, 5, 1))
key = EventCombination(((1, 2, 1), (1, 5, 1)))
capabilities = {1: [9, 2, 5]}
self.assertTrue(is_in_capabilities(key, capabilities))
@ -841,18 +460,24 @@ class TestModifyCapabilities(unittest.TestCase):
assert absinfo is True
return self._capabilities
mapping = Preset()
mapping.change(EventCombination([EV_KEY, 80, 1]), "keyboard", "a")
mapping.change(EventCombination([EV_KEY, 81, 1]), "keyboard", DISABLE_NAME)
preset = Preset()
preset.add(get_key_mapping(EventCombination([EV_KEY, 80, 1]), "keyboard", "a"))
preset.add(
get_key_mapping(EventCombination([EV_KEY, 81, 1]), "keyboard", DISABLE_NAME)
)
macro_code = "r(2, m(sHiFt_l, r(2, k(1).k(2))))"
macro = parse(macro_code, mapping)
macro = parse(macro_code, preset)
mapping.change(EventCombination([EV_KEY, 60, 111]), "keyboard", macro_code)
preset.add(
get_key_mapping(EventCombination([EV_KEY, 60, 111]), "keyboard", macro_code)
)
# going to be ignored, because EV_REL cannot be mapped, that's
# mouse movements.
mapping.change(EventCombination([EV_REL, 1234, 3]), "keyboard", "b")
preset.add(
get_key_mapping(EventCombination([EV_REL, 1234, 3]), "keyboard", "b")
)
self.a = system_mapping.get("a")
self.shift_l = system_mapping.get("ShIfT_L")
@ -860,7 +485,7 @@ class TestModifyCapabilities(unittest.TestCase):
self.two = system_mapping.get("2")
self.left = system_mapping.get("BtN_lEfT")
self.fake_device = FakeDevice()
self.mapping = mapping
self.preset = preset
self.macro = macro
def check_keys(self, capabilities):
@ -877,13 +502,15 @@ class TestModifyCapabilities(unittest.TestCase):
quick_cleanup()
def test_copy_capabilities(self):
self.mapping.change(
EventCombination([EV_KEY, 60, 1]), "keyboard", self.macro.code
self.preset.add(
get_key_mapping(
EventCombination([EV_KEY, 60, 1]), "keyboard", self.macro.code
)
)
# I don't know what ABS_VOLUME is, for now I would like to just always
# remove it until somebody complains, since its presence broke stuff
self.injector = Injector(None, self.mapping)
self.injector = Injector(None, self.preset)
self.fake_device._capabilities = {
EV_ABS: [ABS_VOLUME, (ABS_X, evdev.AbsInfo(0, 0, 500, 0, 0, 0))],
EV_KEY: [1, 2, 3],

File diff suppressed because it is too large Load Diff

@ -17,7 +17,7 @@
#
# 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 evdev._ecodes import EV_ABS, ABS_Y
from tests.test import logger, quick_cleanup, new_event
@ -35,6 +35,8 @@ from evdev.ecodes import (
REL_X,
REL_WHEEL,
REL_HWHEEL,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
KEY_A,
KEY_B,
KEY_C,
@ -62,6 +64,7 @@ from inputremapper.injection.macros.parse import (
get_macro_argument_names,
get_num_parameters,
)
from inputremapper.exceptions import MacroParsingError
from inputremapper.injection.context import Context
from inputremapper.configs.global_config import global_config
from inputremapper.configs.preset import Preset
@ -85,7 +88,6 @@ class MacroTestBase(unittest.IsolatedAsyncioTestCase):
def tearDown(self):
self.result = []
self.context.preset.clear_config()
quick_cleanup()
def handler(self, ev_type, code, value):
@ -93,6 +95,33 @@ class MacroTestBase(unittest.IsolatedAsyncioTestCase):
print(f"\033[90mmacro wrote{(ev_type, code, value)}\033[0m")
self.result.append((ev_type, code, value))
async def trigger_sequence(self, macro: Macro, event):
for listener in self.context.listeners:
asyncio.ensure_future(listener(event))
await asyncio.sleep(
0
) # this still might cause race conditions and the test to fail
macro.press_trigger()
if macro.running:
return
asyncio.ensure_future(macro.run(self.handler))
async def release_sequence(self, macro, event):
for listener in self.context.listeners:
asyncio.ensure_future(listener(event))
await asyncio.sleep(
0
) # this still might cause race conditions and the test to fail
if macro.is_holding:
macro.release_trigger()
class DummyMapping:
macro_key_sleep_ms = 10
rate = 60
class TestMacros(MacroTestBase):
async def test_named_parameter(self):
@ -103,8 +132,12 @@ class TestMacros(MacroTestBase):
functions = {"key": patch}
with mock.patch("inputremapper.injection.macros.parse.FUNCTIONS", functions):
await parse("key(1, d=4, b=2, c=3)", self.context).run(self.handler)
await parse("key(1, b=2, c=3)", self.context).run(self.handler)
await parse("key(1, d=4, b=2, c=3)", self.context, DummyMapping).run(
self.handler
)
await parse("key(1, b=2, c=3)", self.context, DummyMapping).run(
self.handler
)
self.assertListEqual(result, [(1, 2, 3, 4), (1, 2, 3, 400)])
def test_get_macro_argument_names(self):
@ -197,37 +230,45 @@ class TestMacros(MacroTestBase):
self.assertEqual(_type_check("1", [int, None], "foo", 1), 1)
self.assertEqual(_type_check(1.2, [str], "foo", 2), "1.2")
self.assertRaises(TypeError, lambda: _type_check("1.2", [int], "foo", 3))
self.assertRaises(TypeError, lambda: _type_check("a", [None], "foo", 0))
self.assertRaises(TypeError, lambda: _type_check("a", [int], "foo", 1))
self.assertRaises(TypeError, lambda: _type_check("a", [int, float], "foo", 2))
self.assertRaises(TypeError, lambda: _type_check("a", [int, None], "foo", 3))
self.assertRaises(
MacroParsingError, lambda: _type_check("1.2", [int], "foo", 3)
)
self.assertRaises(MacroParsingError, lambda: _type_check("a", [None], "foo", 0))
self.assertRaises(MacroParsingError, lambda: _type_check("a", [int], "foo", 1))
self.assertRaises(
MacroParsingError, lambda: _type_check("a", [int, float], "foo", 2)
)
self.assertRaises(
MacroParsingError, lambda: _type_check("a", [int, None], "foo", 3)
)
self.assertEqual(_type_check("a", [int, float, None, str], "foo", 4), "a")
# variables are expected to be of the Variable type here, not a $string
self.assertRaises(TypeError, lambda: _type_check("$a", [int], "foo", 4))
self.assertRaises(MacroParsingError, lambda: _type_check("$a", [int], "foo", 4))
variable = Variable("a")
self.assertEqual(_type_check(variable, [int], "foo", 4), variable)
self.assertRaises(TypeError, lambda: _type_check("a", [Macro], "foo", 0))
self.assertRaises(TypeError, lambda: _type_check(1, [Macro], "foo", 0))
self.assertRaises(
MacroParsingError, lambda: _type_check("a", [Macro], "foo", 0)
)
self.assertRaises(MacroParsingError, lambda: _type_check(1, [Macro], "foo", 0))
self.assertEqual(_type_check("1", [Macro, int], "foo", 4), 1)
def test_type_check_variablename(self):
self.assertRaises(SyntaxError, lambda: _type_check_variablename("1a"))
self.assertRaises(SyntaxError, lambda: _type_check_variablename("$a"))
self.assertRaises(SyntaxError, lambda: _type_check_variablename("a()"))
self.assertRaises(SyntaxError, lambda: _type_check_variablename("1"))
self.assertRaises(SyntaxError, lambda: _type_check_variablename("+"))
self.assertRaises(SyntaxError, lambda: _type_check_variablename("-"))
self.assertRaises(SyntaxError, lambda: _type_check_variablename("*"))
self.assertRaises(SyntaxError, lambda: _type_check_variablename("a,b"))
self.assertRaises(SyntaxError, lambda: _type_check_variablename("a,b"))
self.assertRaises(SyntaxError, lambda: _type_check_variablename("#"))
self.assertRaises(SyntaxError, lambda: _type_check_variablename(1))
self.assertRaises(SyntaxError, lambda: _type_check_variablename(None))
self.assertRaises(SyntaxError, lambda: _type_check_variablename([]))
self.assertRaises(SyntaxError, lambda: _type_check_variablename(()))
self.assertRaises(MacroParsingError, lambda: _type_check_variablename("1a"))
self.assertRaises(MacroParsingError, lambda: _type_check_variablename("$a"))
self.assertRaises(MacroParsingError, lambda: _type_check_variablename("a()"))
self.assertRaises(MacroParsingError, lambda: _type_check_variablename("1"))
self.assertRaises(MacroParsingError, lambda: _type_check_variablename("+"))
self.assertRaises(MacroParsingError, lambda: _type_check_variablename("-"))
self.assertRaises(MacroParsingError, lambda: _type_check_variablename("*"))
self.assertRaises(MacroParsingError, lambda: _type_check_variablename("a,b"))
self.assertRaises(MacroParsingError, lambda: _type_check_variablename("a,b"))
self.assertRaises(MacroParsingError, lambda: _type_check_variablename("#"))
self.assertRaises(MacroParsingError, lambda: _type_check_variablename(1))
self.assertRaises(MacroParsingError, lambda: _type_check_variablename(None))
self.assertRaises(MacroParsingError, lambda: _type_check_variablename([]))
self.assertRaises(MacroParsingError, lambda: _type_check_variablename(()))
# doesn't raise
_type_check_variablename("a")
@ -277,7 +318,7 @@ class TestMacros(MacroTestBase):
# invalid
strings = ["+", "a+", "+b", "key(a + b)"]
for string in strings:
with self.assertRaises(ValueError):
with self.assertRaises(MacroParsingError):
logger.info(f'testing "%s"', string)
handle_plus_syntax(string)
@ -294,7 +335,7 @@ class TestMacros(MacroTestBase):
self.assertEqual(macro.code, "key(a)")
async def test_run_plus_syntax(self):
macro = parse("a + b + c + d", self.context)
macro = parse("a + b + c + d", self.context, DummyMapping)
macro.press_trigger()
asyncio.ensure_future(macro.run(self.handler))
@ -355,36 +396,33 @@ class TestMacros(MacroTestBase):
expect(",,", ["", "", ""])
async def test_parse_params(self):
self.assertEqual(_parse_recurse("", self.context), None)
self.assertEqual(_parse_recurse("", self.context, DummyMapping), None)
# strings. If it is wrapped in quotes, don't parse the contents
self.assertEqual(_parse_recurse('"foo"', self.context), "foo")
self.assertEqual(_parse_recurse('"\tf o o\n"', self.context), "\tf o o\n")
self.assertEqual(_parse_recurse('"foo(a,b)"', self.context), "foo(a,b)")
self.assertEqual(_parse_recurse('",,,()"', self.context), ",,,()")
self.assertEqual(_parse_recurse('"foo"', self.context, DummyMapping), "foo")
self.assertEqual(
_parse_recurse('"\tf o o\n"', self.context, DummyMapping), "\tf o o\n"
)
self.assertEqual(
_parse_recurse('"foo(a,b)"', self.context, DummyMapping), "foo(a,b)"
)
self.assertEqual(_parse_recurse('",,,()"', self.context, DummyMapping), ",,,()")
# strings without quotes only work as long as there is no function call or
# anything. This is only really acceptable for constants like KEY_A and for
# variable names, which are not allowed to contain special characters that may
# have a meaning in the macro syntax.
self.assertEqual(_parse_recurse("foo", self.context), "foo")
self.assertEqual(_parse_recurse("5", self.context), 5)
self.assertEqual(_parse_recurse("5.2", self.context), 5.2)
self.assertIsInstance(_parse_recurse("$foo", self.context), Variable)
self.assertEqual(_parse_recurse("$foo", self.context).name, "foo")
self.assertEqual(_parse_recurse("foo", self.context, DummyMapping), "foo")
async def test_fails(self):
self.assertIsNone(parse("repeat(1, a)", self.context))
self.assertIsNone(parse("repeat(a, key(b))", self.context))
self.assertIsNone(parse("modify(a, b)", self.context))
# passing a string parameter. This is not a macro, even though
# it might look like it without the string quotes.
self.assertIsNone(parse('"modify(a, b)"', self.context))
self.assertEqual(_parse_recurse("5", self.context, DummyMapping), 5)
self.assertEqual(_parse_recurse("5.2", self.context, DummyMapping), 5.2)
self.assertIsInstance(
_parse_recurse("$foo", self.context, DummyMapping), Variable
)
self.assertEqual(_parse_recurse("$foo", self.context, DummyMapping).name, "foo")
async def test_0(self):
macro = parse("key(1)", self.context)
macro = parse("key(1)", self.context, DummyMapping)
one_code = system_mapping.get("1")
await macro.run(self.handler)
@ -394,7 +432,7 @@ class TestMacros(MacroTestBase):
self.assertEqual(len(macro.child_macros), 0)
async def test_1(self):
macro = parse('key(1).key("KEY_A").key(3)', self.context)
macro = parse('key(1).key("KEY_A").key(3)', self.context, DummyMapping)
await macro.run(self.handler)
self.assertListEqual(
@ -410,131 +448,87 @@ class TestMacros(MacroTestBase):
)
self.assertEqual(len(macro.child_macros), 0)
async def test_return_errors(self):
error = parse("k(1).h(k(a)).k(3)", self.context, True)
self.assertIsNone(error)
error = parse("k(1))", self.context, True)
async def test_raises_error(self):
# passing a string parameter. This is not a macro, even though
# it might look like it without the string quotes.
self.assertRaises(MacroParsingError, parse, '"modify(a, b)"', self.context)
parse("k(1).h(k(a)).k(3)", self.context) # No error
with self.assertRaises(MacroParsingError) as cm:
parse("k(1))", self.context)
error = str(cm.exception)
self.assertIn("bracket", error)
error = parse("key((1)", self.context, True)
with self.assertRaises(MacroParsingError) as cm:
parse("key((1)", self.context)
error = str(cm.exception)
self.assertIn("bracket", error)
error = parse("k((1).k)", self.context, True)
self.assertIsNotNone(error)
error = parse("k()", self.context, True)
self.assertIsNotNone(error)
error = parse("key(1)", self.context, True)
self.assertIsNone(error)
error = parse("k(1, 1)", self.context, True)
self.assertIsNotNone(error)
error = parse("key($a)", self.context, True)
self.assertIsNone(error)
error = parse("h(1, 1)", self.context, True)
self.assertIsNotNone(error)
error = parse("h(hold(h(1, 1)))", self.context, True)
self.assertIsNotNone(error)
error = parse("r(1)", self.context, True)
self.assertIsNotNone(error)
error = parse("repeat(a, k(1))", self.context, True)
self.assertIsNotNone(error)
error = parse("repeat($a, k(1))", self.context, True)
self.assertIsNone(error)
error = parse("r(1, 1)", self.context, True)
self.assertIsNotNone(error)
error = parse("r(k(1), 1)", self.context, True)
self.assertIsNotNone(error)
error = parse("r(1, macro=k(1))", self.context, True)
self.assertIsNone(error)
error = parse("r(a=1, b=k(1))", self.context, True)
self.assertIsNotNone(error)
error = parse("r(repeats=1, macro=k(1), a=2)", self.context, True)
self.assertIsNotNone(error)
error = parse("r(repeats=1, macro=k(1), repeats=2)", self.context, True)
self.assertIsNotNone(error)
error = parse("modify(asdf, k(a))", self.context, True)
self.assertIsNotNone(error)
error = parse("if_tap(, k(a), 1000)", self.context, True)
self.assertIsNone(error)
error = parse("if_tap(, k(a), timeout=1000)", self.context, True)
self.assertIsNone(error)
error = parse("if_tap(, k(a), $timeout)", self.context, True)
self.assertIsNone(error)
error = parse("if_tap(, k(a), timeout=$t)", self.context, True)
self.assertIsNone(error)
error = parse("if_tap(, key(a))", self.context, True)
self.assertIsNone(error)
error = parse("if_tap(k(a),)", self.context, True)
self.assertIsNone(error)
error = parse("if_tap(k(a), b)", self.context, True)
self.assertIsNotNone(error)
error = parse("if_single(k(a),)", self.context, True)
self.assertIsNone(error)
error = parse("if_single(1,)", self.context, True)
self.assertIsNotNone(error)
error = parse("if_single(,1)", self.context, True)
self.assertIsNotNone(error)
error = parse("mouse(up, 3)", self.context, True)
self.assertIsNone(error)
error = parse("mouse(up, speed=$a)", self.context, True)
self.assertIsNone(error)
error = parse("mouse(3, up)", self.context, True)
self.assertIsNotNone(error)
error = parse("wheel(left, 3)", self.context, True)
self.assertIsNone(error)
error = parse("wheel(3, left)", self.context, True)
self.assertIsNotNone(error)
error = parse("w(2)", self.context, True)
self.assertIsNone(error)
error = parse("wait(a)", self.context, True)
self.assertIsNotNone(error)
error = parse("ifeq(a, 2, k(a),)", self.context, True)
self.assertIsNone(error)
error = parse("ifeq(a, 2, , k(a))", self.context, True)
self.assertIsNone(error)
error = parse("ifeq(a, 2, 1,)", self.context, True)
self.assertIsNotNone(error)
error = parse("ifeq(a, 2, , 2)", self.context, True)
self.assertIsNotNone(error)
error = parse("if_eq(2, $a, k(a),)", self.context, True)
self.assertIsNone(error)
error = parse("if_eq(2, $a, , else=k(a))", self.context, True)
self.assertIsNone(error)
error = parse("if_eq(2, $a, 1,)", self.context, True)
self.assertIsNotNone(error)
error = parse("if_eq(2, $a, , 2)", self.context, True)
self.assertIsNotNone(error)
error = parse("foo(a)", self.context, True)
self.assertRaises(MacroParsingError, parse, "k((1).k)", self.context)
self.assertRaises(MacroParsingError, parse, "k()", self.context)
parse("key(1)", self.context) # no error
self.assertRaises(MacroParsingError, parse, "k(1, 1)", self.context)
parse("key($a)", self.context) # no error
self.assertRaises(MacroParsingError, parse, "h(1, 1)", self.context)
self.assertRaises(MacroParsingError, parse, "h(hold(h(1, 1)))", self.context)
self.assertRaises(MacroParsingError, parse, "r(1)", self.context)
self.assertRaises(MacroParsingError, parse, "repeat(a, k(1))", self.context)
parse("repeat($a, k(1))", self.context) # no error
self.assertRaises(MacroParsingError, parse, "r(1, 1)", self.context)
self.assertRaises(MacroParsingError, parse, "r(k(1), 1)", self.context)
parse("r(1, macro=k(1))", self.context) # no error
self.assertRaises(MacroParsingError, parse, "r(a=1, b=k(1))", self.context)
self.assertRaises(
MacroParsingError, parse, "r(repeats=1, macro=k(1), a=2)", self.context
)
self.assertRaises(
MacroParsingError,
parse,
"r(repeats=1, macro=k(1), repeats=2)",
self.context,
)
self.assertRaises(MacroParsingError, parse, "modify(asdf, k(a))", self.context)
parse("if_tap(, k(a), 1000)", self.context) # no error
parse("if_tap(, k(a), timeout=1000)", self.context) # no error
parse("if_tap(, k(a), $timeout)", self.context) # no error
parse("if_tap(, k(a), timeout=$t)", self.context) # no error
parse("if_tap(, key(a))", self.context) # no error
parse("if_tap(k(a),)", self.context) # no error
self.assertRaises(MacroParsingError, parse, "if_tap(k(a), b)", self.context)
parse("if_single(k(a),)", self.context) # no error
self.assertRaises(MacroParsingError, parse, "if_single(1,)", self.context)
self.assertRaises(MacroParsingError, parse, "if_single(,1)", self.context)
parse("mouse(up, 3)", self.context) # no error
parse("mouse(up, speed=$a)", self.context) # no error
self.assertRaises(MacroParsingError, parse, "mouse(3, up)", self.context)
parse("wheel(left, 3)", self.context) # no error
self.assertRaises(MacroParsingError, parse, "wheel(3, left)", self.context)
parse("w(2)", self.context) # no error
self.assertRaises(MacroParsingError, parse, "wait(a)", self.context)
parse("ifeq(a, 2, k(a),)", self.context) # no error
parse("ifeq(a, 2, , k(a))", self.context) # no error
self.assertRaises(MacroParsingError, parse, "ifeq(a, 2, 1,)", self.context)
self.assertRaises(MacroParsingError, parse, "ifeq(a, 2, , 2)", self.context)
parse("if_eq(2, $a, k(a),)", self.context) # no error
parse("if_eq(2, $a, , else=k(a))", self.context) # no error
self.assertRaises(MacroParsingError, parse, "if_eq(2, $a, 1,)", self.context)
self.assertRaises(MacroParsingError, parse, "if_eq(2, $a, , 2)", self.context)
with self.assertRaises(MacroParsingError) as cm:
parse("foo(a)", self.context)
error = str(cm.exception)
self.assertIn("unknown", error.lower())
self.assertIn("foo", error)
error = parse("set($a, 1)", self.context, True)
self.assertIsNotNone(error)
error = parse("set(1, 2)", self.context, True)
self.assertIsNotNone(error)
error = parse("set(+, 2)", self.context, True)
self.assertIsNotNone(error)
error = parse("set(a(), 2)", self.context, True)
self.assertIsNotNone(error)
error = parse("set('b,c', 2)", self.context, True)
self.assertIsNotNone(error)
error = parse('set("b,c", 2)', self.context, True)
self.assertIsNotNone(error)
error = parse("set(A, 2)", self.context, True)
self.assertIsNone(error)
self.assertRaises(MacroParsingError, parse, "set($a, 1)", self.context)
self.assertRaises(MacroParsingError, parse, "set(1, 2)", self.context)
self.assertRaises(MacroParsingError, parse, "set(+, 2)", self.context)
self.assertRaises(MacroParsingError, parse, "set(a(), 2)", self.context)
self.assertRaises(MacroParsingError, parse, "set('b,c', 2)", self.context)
self.assertRaises(MacroParsingError, parse, 'set("b,c", 2)', self.context)
parse("set(A, 2)", self.context) # no error
async def test_key(self):
code_a = system_mapping.get("a")
code_b = system_mapping.get("b")
macro = parse("set(foo, b).key($foo).key(a)", self.context)
macro = parse("set(foo, b).key($foo).key(a)", self.context, DummyMapping)
await macro.run(self.handler)
self.assertListEqual(
self.result,
@ -550,7 +544,9 @@ class TestMacros(MacroTestBase):
code_a = system_mapping.get("a")
code_b = system_mapping.get("b")
code_c = system_mapping.get("c")
macro = parse("set(foo, b).modify($foo, modify(a, key(c)))", self.context)
macro = parse(
"set(foo, b).modify($foo, modify(a, key(c)))", self.context, DummyMapping
)
await macro.run(self.handler)
self.assertListEqual(
self.result,
@ -566,7 +562,7 @@ class TestMacros(MacroTestBase):
async def test_hold_variable(self):
code_a = system_mapping.get("a")
macro = parse("set(foo, a).hold($foo)", self.context)
macro = parse("set(foo, a).hold($foo)", self.context, DummyMapping)
await macro.run(self.handler)
self.assertListEqual(
self.result,
@ -577,7 +573,7 @@ class TestMacros(MacroTestBase):
)
async def test_hold_keys(self):
macro = parse("set(foo, b).hold_keys(a, $foo, c)", self.context)
macro = parse("set(foo, b).hold_keys(a, $foo, c)", self.context, DummyMapping)
# press first
macro.press_trigger()
# then run, just like how it is going to happen during runtime
@ -614,7 +610,7 @@ class TestMacros(MacroTestBase):
async def test_hold(self):
# repeats key(a) as long as the key is held down
macro = parse("key(1).hold(key(a)).key(3)", self.context)
macro = parse("key(1).hold(key(a)).key(3)", self.context, DummyMapping)
"""down"""
@ -643,7 +639,7 @@ class TestMacros(MacroTestBase):
self.assertEqual(len(macro.child_macros), 1)
async def test_dont_hold(self):
macro = parse("key(1).hold(key(a)).key(3)", self.context)
macro = parse("key(1).hold(key(a)).key(3)", self.context, DummyMapping)
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.2)
@ -658,7 +654,7 @@ class TestMacros(MacroTestBase):
self.assertEqual(len(macro.child_macros), 1)
async def test_just_hold(self):
macro = parse("key(1).hold().key(3)", self.context)
macro = parse("key(1).hold().key(3)", self.context, DummyMapping)
"""down"""
@ -684,7 +680,7 @@ class TestMacros(MacroTestBase):
self.assertEqual(len(macro.child_macros), 0)
async def test_dont_just_hold(self):
macro = parse("key(1).hold().key(3)", self.context)
macro = parse("key(1).hold().key(3)", self.context, DummyMapping)
asyncio.ensure_future(macro.run(self.handler))
await (asyncio.sleep(0.1))
@ -700,7 +696,7 @@ class TestMacros(MacroTestBase):
async def test_hold_down(self):
# writes down and waits for the up event until the key is released
macro = parse("hold(a)", self.context)
macro = parse("hold(a)", self.context, DummyMapping)
self.assertEqual(len(macro.child_macros), 0)
"""down"""
@ -730,11 +726,13 @@ class TestMacros(MacroTestBase):
start = time.time()
repeats = 20
macro = parse(f"repeat({repeats}, key(k)).repeat(1, key(k))", self.context)
macro = parse(
f"repeat({repeats}, key(k)).repeat(1, key(k))", self.context, DummyMapping
)
k_code = system_mapping.get("k")
await macro.run(self.handler)
keystroke_sleep = self.context.preset.get("macros.keystroke_sleep_ms")
keystroke_sleep = DummyMapping.macro_key_sleep_ms
sleep_time = 2 * repeats * keystroke_sleep / 1000
self.assertGreater(time.time() - start, sleep_time * 0.9)
self.assertLess(time.time() - start, sleep_time * 1.2)
@ -748,11 +746,11 @@ class TestMacros(MacroTestBase):
async def test_3(self):
start = time.time()
macro = parse("repeat(3, key(m).w(100))", self.context)
macro = parse("repeat(3, key(m).w(100))", self.context, DummyMapping)
m_code = system_mapping.get("m")
await macro.run(self.handler)
keystroke_time = 6 * self.context.preset.get("macros.keystroke_sleep_ms")
keystroke_time = 6 * DummyMapping.macro_key_sleep_ms
total_time = keystroke_time + 300
total_time /= 1000
@ -773,7 +771,9 @@ class TestMacros(MacroTestBase):
self.assertEqual(len(macro.child_macros[0].child_macros), 0)
async def test_4(self):
macro = parse(" repeat(2,\nkey(\nr ).key(minus\n )).key(m) ", self.context)
macro = parse(
" repeat(2,\nkey(\nr ).key(minus\n )).key(m) ", self.context, DummyMapping
)
r = system_mapping.get("r")
minus = system_mapping.get("minus")
@ -803,6 +803,7 @@ class TestMacros(MacroTestBase):
macro = parse(
"w(200).repeat(2,modify(w,\nrepeat(2,\tkey(BtN_LeFt))).w(10).key(k))",
self.context,
DummyMapping,
)
self.assertEqual(len(macro.child_macros), 1)
@ -815,9 +816,7 @@ class TestMacros(MacroTestBase):
await macro.run(self.handler)
num_pauses = 8 + 6 + 4
keystroke_time = num_pauses * self.context.preset.get(
"macros.keystroke_sleep_ms"
)
keystroke_time = num_pauses * DummyMapping.macro_key_sleep_ms
wait_time = 220
total_time = (keystroke_time + wait_time) / 1000
@ -836,26 +835,6 @@ class TestMacros(MacroTestBase):
self.assertIsInstance(macro, Macro)
self.assertListEqual(self.result, [])
async def test_keystroke_sleep_config(self):
# global_config as fallback
global_config.set("macros.keystroke_sleep_ms", 100)
start = time.time()
macro = parse("key(a).key(b)", self.context)
await macro.run(self.handler)
delta = time.time() - start
# is currently over 400, key(b) adds another sleep afterwards
# that doesn't do anything
self.assertGreater(delta, 0.300)
# now set the value in the preset, which is prioritized
self.context.preset.set("macros.keystroke_sleep_ms", 50)
start = time.time()
macro = parse("key(a).key(b)", self.context)
await macro.run(self.handler)
delta = time.time() - start
self.assertGreater(delta, 0.150)
self.assertLess(delta, 0.300)
async def test_duplicate_run(self):
# it won't restart the macro, because that may screw up the
# internal state (in particular the _trigger_release_event).
@ -865,7 +844,7 @@ class TestMacros(MacroTestBase):
b = system_mapping.get("b")
c = system_mapping.get("c")
macro = parse("key(a).modify(b, hold()).key(c)", self.context)
macro = parse("key(a).modify(b, hold()).key(c)", self.context, DummyMapping)
asyncio.ensure_future(macro.run(self.handler))
self.assertFalse(macro.is_holding())
@ -914,9 +893,9 @@ class TestMacros(MacroTestBase):
self.assertListEqual(self.result, expected)
async def test_mouse(self):
wheel_speed = 100
macro_1 = parse("mouse(up, 4)", self.context)
macro_2 = parse(f"wheel(left, {wheel_speed})", self.context)
wheel_speed = 60
macro_1 = parse("mouse(up, 4)", self.context, DummyMapping)
macro_2 = parse(f"wheel(left, {wheel_speed})", self.context, DummyMapping)
macro_1.press_trigger()
macro_2.press_trigger()
asyncio.ensure_future(macro_1.run(self.handler))
@ -930,15 +909,27 @@ class TestMacros(MacroTestBase):
macro_2.release_trigger()
self.assertIn((EV_REL, REL_Y, -4), self.result)
expected_wheel_event_count = sleep / (1 / wheel_speed)
expected_wheel_hi_res_event_count = sleep * DummyMapping.rate
expected_wheel_event_count = int(
expected_wheel_hi_res_event_count / 120 * wheel_speed
)
actual_wheel_event_count = self.result.count((EV_REL, REL_HWHEEL, 1))
actual_wheel_hi_res_event_count = self.result.count(
(EV_REL, REL_HWHEEL_HI_RES, wheel_speed)
)
# this seems to have a tendency of injecting less wheel events,
# especially if the sleep is short
self.assertGreater(actual_wheel_event_count, expected_wheel_event_count * 0.8)
self.assertLess(actual_wheel_event_count, expected_wheel_event_count * 1.1)
self.assertGreater(
actual_wheel_hi_res_event_count, expected_wheel_hi_res_event_count * 0.8
)
self.assertLess(
actual_wheel_hi_res_event_count, expected_wheel_hi_res_event_count * 1.1
)
async def test_event_1(self):
macro = parse("e(EV_KEY, KEY_A, 1)", self.context)
macro = parse("e(EV_KEY, KEY_A, 1)", self.context, DummyMapping)
a_code = system_mapping.get("a")
await macro.run(self.handler)
@ -946,51 +937,25 @@ class TestMacros(MacroTestBase):
self.assertEqual(len(macro.child_macros), 0)
async def test_event_2(self):
macro = parse("repeat(1, event(type=5421, code=324, value=154))", self.context)
macro = parse(
"repeat(1, event(type=5421, code=324, value=154))",
self.context,
DummyMapping,
)
code = 324
await macro.run(self.handler)
self.assertListEqual(self.result, [(5421, code, 154)])
self.assertEqual(len(macro.child_macros), 1)
async def test__wait_for_event(self):
macro = parse("hold(a)", self.context)
try:
# should timeout, no event known
await asyncio.wait_for(macro._wait_for_event(), 0.1)
raise AssertionError("Expected asyncio.TimeoutError")
except asyncio.TimeoutError:
pass
# should not timeout because a new event arrived
macro.notify(new_event(EV_KEY, 1, 1), PRESS)
await asyncio.wait_for(macro._wait_for_event(), 0.1)
try:
# should timeout, because the previous event doesn't match the filter
await asyncio.wait_for(
macro._wait_for_event(lambda e, a: e.value == 3), 0.1
)
raise AssertionError("Expected asyncio.TimeoutError")
except asyncio.TimeoutError:
pass
# should not timeout because a new event arrived
macro.notify(new_event(EV_KEY, 1, 3), RELEASE)
await asyncio.wait_for(macro._wait_for_event(), 0.1)
try:
# should timeout, because the previous event doesn't match the filter
await asyncio.wait_for(macro._wait_for_event(lambda _, a: a == PRESS), 0.1)
raise AssertionError("Expected asyncio.TimeoutError")
except asyncio.TimeoutError:
pass
async def test_macro_breaks(self):
# the first parameter for `repeat` requires an integer, not "foo",
# which makes `repeat` throw
macro = parse('set(a, "foo").repeat($a, key(KEY_A)).key(KEY_B)', self.context)
macro = parse(
'set(a, "foo").repeat($a, key(KEY_A)).key(KEY_B)',
self.context,
DummyMapping,
)
await macro.run(self.handler)
# .run() it will not throw because repeat() breaks, and it will properly set
@ -1001,16 +966,16 @@ class TestMacros(MacroTestBase):
self.assertListEqual(self.result, [])
async def test_set(self):
await parse('set(a, "foo")', self.context).run(self.handler)
await parse('set(a, "foo")', self.context, DummyMapping).run(self.handler)
self.assertEqual(macro_variables.get("a"), "foo")
await parse('set( \t"b" \n, "1")', self.context).run(self.handler)
await parse('set( \t"b" \n, "1")', self.context, DummyMapping).run(self.handler)
self.assertEqual(macro_variables.get("b"), "1")
await parse("set(a, 1)", self.context).run(self.handler)
await parse("set(a, 1)", self.context, DummyMapping).run(self.handler)
self.assertEqual(macro_variables.get("a"), 1)
await parse("set(a, )", self.context).run(self.handler)
await parse("set(a, )", self.context, DummyMapping).run(self.handler)
self.assertEqual(macro_variables.get("a"), None)
async def test_multiline_macro_and_comments(self):
@ -1032,6 +997,7 @@ class TestMacros(MacroTestBase):
{comment}
""",
self.context,
DummyMapping,
)
await macro.run(self.handler)
self.assertListEqual(
@ -1052,7 +1018,9 @@ class TestMacros(MacroTestBase):
class TestIfEq(MacroTestBase):
async def test_ifeq_runs(self):
# deprecated ifeq function, but kept for compatibility reasons
macro = parse("set(foo, 2).ifeq(foo, 2, key(a), key(b))", self.context)
macro = parse(
"set(foo, 2).ifeq(foo, 2, key(a), key(b))", self.context, DummyMapping
)
code_a = system_mapping.get("a")
code_b = system_mapping.get("b")
@ -1062,21 +1030,21 @@ class TestIfEq(MacroTestBase):
async def test_ifeq_none(self):
# first param none
macro = parse("set(foo, 2).ifeq(foo, 2, , key(b))", self.context)
macro = parse("set(foo, 2).ifeq(foo, 2, , key(b))", self.context, DummyMapping)
self.assertEqual(len(macro.child_macros), 1)
code_b = system_mapping.get("b")
await macro.run(self.handler)
self.assertListEqual(self.result, [])
# second param none
macro = parse("set(foo, 2).ifeq(foo, 2, key(a),)", self.context)
macro = parse("set(foo, 2).ifeq(foo, 2, key(a),)", self.context, DummyMapping)
self.assertEqual(len(macro.child_macros), 1)
code_a = system_mapping.get("a")
await macro.run(self.handler)
self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)])
async def test_ifeq_unknown_key(self):
macro = parse("ifeq(qux, 2, key(a), key(b))", self.context)
macro = parse("ifeq(qux, 2, key(a), key(b))", self.context, DummyMapping)
code_a = system_mapping.get("a")
code_b = system_mapping.get("b")
@ -1098,7 +1066,7 @@ class TestIfEq(MacroTestBase):
self.result.clear()
# test
macro = parse(macro, self.context)
macro = parse(macro, self.context, DummyMapping)
await macro.run(self.handler)
self.assertListEqual(self.result, expected)
@ -1134,7 +1102,7 @@ class TestIfEq(MacroTestBase):
async def test_if_eq_runs_multiprocessed(self):
"""ifeq on variables that have been set in other processes works."""
macro = parse("if_eq($foo, 3, key(a), key(b))", self.context)
macro = parse("if_eq($foo, 3, key(a), key(b))", self.context, DummyMapping)
code_a = system_mapping.get("a")
code_b = system_mapping.get("b")
@ -1142,7 +1110,7 @@ class TestIfEq(MacroTestBase):
def set_foo(value):
# will write foo = 2 into the shared dictionary of macros
macro_2 = parse(f"set(foo, {value})", self.context)
macro_2 = parse(f"set(foo, {value})", self.context, DummyMapping)
loop = asyncio.new_event_loop()
loop.run_until_complete(macro_2.run(lambda: None))
@ -1173,7 +1141,7 @@ class TestIfEq(MacroTestBase):
class TestIfSingle(MacroTestBase):
async def test_if_single(self):
macro = parse("if_single(key(x), key(y))", self.context)
macro = parse("if_single(key(x), key(y))", self.context, DummyMapping)
self.assertEqual(len(macro.child_macros), 2)
a = system_mapping.get("a")
@ -1181,10 +1149,9 @@ class TestIfSingle(MacroTestBase):
x = system_mapping.get("x")
y = system_mapping.get("y")
macro.notify(new_event(EV_KEY, a, 1), PRESS)
asyncio.ensure_future(macro.run(self.handler))
await self.trigger_sequence(macro, new_event(EV_KEY, a, 1))
await asyncio.sleep(0.1)
macro.notify(new_event(EV_KEY, a, 0), RELEASE)
await self.release_sequence(macro, new_event(EV_KEY, a, 0))
# the key that triggered the macro is released
await asyncio.sleep(0.1)
@ -1194,7 +1161,9 @@ class TestIfSingle(MacroTestBase):
async def test_if_single_ignores_releases(self):
# the timeout won't break the macro, everything happens well within that
# timeframe.
macro = parse("if_single(key(x), else=key(y), timeout=100000)", self.context)
macro = parse(
"if_single(key(x), else=key(y), timeout=100000)", self.context, DummyMapping
)
self.assertEqual(len(macro.child_macros), 2)
a = system_mapping.get("a")
@ -1203,21 +1172,22 @@ class TestIfSingle(MacroTestBase):
x = system_mapping.get("x")
y = system_mapping.get("y")
macro.notify(new_event(EV_KEY, a, 1), PRESS)
asyncio.ensure_future(macro.run(self.handler))
# pressing the macro key
await self.trigger_sequence(macro, new_event(EV_KEY, a, 1))
await asyncio.sleep(0.05)
# if_single only looks out for newly pressed keys,
# it doesn't care if keys were released that have been
# pressed before if_single. This was decided because it is a lot
# less tricky and more fluently to use if you type fast
macro.notify(new_event(EV_KEY, b, 0), RELEASE)
for listener in self.context.listeners:
asyncio.ensure_future(listener(new_event(EV_KEY, b, 0)))
await asyncio.sleep(0.05)
self.assertListEqual(self.result, [])
# pressing an actual key triggers if_single
# releasing the actual key triggers if_single
await asyncio.sleep(0.05)
macro.notify(new_event(EV_KEY, a, 1), PRESS)
await self.release_sequence(macro, new_event(EV_KEY, a, 0))
await asyncio.sleep(0.05)
self.assertListEqual(self.result, [(EV_KEY, x, 1), (EV_KEY, x, 0)])
self.assertFalse(macro.running)
@ -1226,7 +1196,9 @@ class TestIfSingle(MacroTestBase):
# Will run the `else` macro if another key is pressed.
# Also works if if_single is a child macro, i.e. the event is passed to it
# from the outside macro correctly.
macro = parse("repeat(1, if_single(then=key(x), else=key(y)))", self.context)
macro = parse(
"repeat(1, if_single(then=key(x), else=key(y)))", self.context, DummyMapping
)
self.assertEqual(len(macro.child_macros), 1)
self.assertEqual(len(macro.child_macros[0].child_macros), 2)
@ -1236,17 +1208,19 @@ class TestIfSingle(MacroTestBase):
x = system_mapping.get("x")
y = system_mapping.get("y")
macro.notify(new_event(EV_KEY, a, 1), PRESS)
asyncio.ensure_future(macro.run(self.handler))
# press the trigger key
await self.trigger_sequence(macro, new_event(EV_KEY, a, 1))
await asyncio.sleep(0.1)
macro.notify(new_event(EV_KEY, b, 1), PRESS)
# press another key
for listener in self.context.listeners:
asyncio.ensure_future(listener(new_event(EV_KEY, b, 1)))
await asyncio.sleep(0.1)
self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)])
self.assertFalse(macro.running)
async def test_if_not_single_none(self):
macro = parse("if_single(key(x),)", self.context)
macro = parse("if_single(key(x),)", self.context, DummyMapping)
self.assertEqual(len(macro.child_macros), 1)
a = system_mapping.get("a")
@ -1254,24 +1228,29 @@ class TestIfSingle(MacroTestBase):
x = system_mapping.get("x")
macro.notify(new_event(EV_KEY, a, 1), PRESS)
asyncio.ensure_future(macro.run(self.handler))
# press trigger key
await self.trigger_sequence(macro, new_event(EV_KEY, a, 1))
await asyncio.sleep(0.1)
macro.notify(new_event(EV_KEY, b, 1), PRESS)
# press another key
for listener in self.context.listeners:
asyncio.ensure_future(listener(new_event(EV_KEY, b, 1)))
await asyncio.sleep(0.1)
self.assertListEqual(self.result, [])
self.assertFalse(macro.running)
async def test_if_single_times_out(self):
macro = parse("set(t, 300).if_single(key(x), key(y), timeout=$t)", self.context)
macro = parse(
"set(t, 300).if_single(key(x), key(y), timeout=$t)",
self.context,
DummyMapping,
)
self.assertEqual(len(macro.child_macros), 2)
a = system_mapping.get("a")
y = system_mapping.get("y")
macro.notify(new_event(EV_KEY, a, 1), PRESS)
asyncio.ensure_future(macro.run(self.handler))
await self.trigger_sequence(macro, new_event(EV_KEY, a, 1))
# no timeout yet
await asyncio.sleep(0.2)
@ -1283,10 +1262,29 @@ class TestIfSingle(MacroTestBase):
self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)])
self.assertFalse(macro.running)
async def test_if_single_ignores_joystick(self):
"""triggers else + delayed_handle_keycode"""
# Integration test style for if_single.
# If a joystick that is mapped to a button is moved, if_single stops
macro = parse("if_single(k(a), k(KEY_LEFTSHIFT))", self.context, DummyMapping)
code_shift = system_mapping.get("KEY_LEFTSHIFT")
code_a = system_mapping.get("a")
trigger = 1
await self.trigger_sequence(macro, new_event(EV_KEY, trigger, 1))
await asyncio.sleep(0.1)
for listener in self.context.listeners:
asyncio.ensure_future(listener(new_event(EV_ABS, ABS_Y, 10)))
await asyncio.sleep(0.1)
await self.release_sequence(macro, new_event(EV_KEY, trigger, 0))
await asyncio.sleep(0.1)
self.assertFalse(macro.running)
self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)])
class TestIfTap(MacroTestBase):
async def test_if_tap(self):
macro = parse("if_tap(key(x), key(y), 100)", self.context)
macro = parse("if_tap(key(x), key(y), 100)", self.context, DummyMapping)
self.assertEqual(len(macro.child_macros), 2)
x = system_mapping.get("x")
@ -1307,7 +1305,7 @@ class TestIfTap(MacroTestBase):
# when the press arrives shortly after run.
# a tap will happen within the timeout even if the tigger is not pressed when
# it does into if_tap
macro = parse("if_tap(key(a), key(b), 100)", self.context)
macro = parse("if_tap(key(a), key(b), 100)", self.context, DummyMapping)
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.01)
@ -1320,7 +1318,11 @@ class TestIfTap(MacroTestBase):
self.result.clear()
async def test_if_double_tap(self):
macro = parse("if_tap(if_tap(key(a), key(b), 100), key(c), 100)", self.context)
macro = parse(
"if_tap(if_tap(key(a), key(b), 100), key(c), 100)",
self.context,
DummyMapping,
)
self.assertEqual(len(macro.child_macros), 2)
self.assertEqual(len(macro.child_macros[0].child_macros), 2)
@ -1364,7 +1366,7 @@ class TestIfTap(MacroTestBase):
async def test_if_tap_none(self):
# first param none
macro = parse("if_tap(, key(y), 100)", self.context)
macro = parse("if_tap(, key(y), 100)", self.context, DummyMapping)
self.assertEqual(len(macro.child_macros), 1)
y = system_mapping.get("y")
macro.press_trigger()
@ -1375,7 +1377,7 @@ class TestIfTap(MacroTestBase):
self.assertListEqual(self.result, [])
# second param none
macro = parse("if_tap(key(y), , 50)", self.context)
macro = parse("if_tap(key(y), , 50)", self.context, DummyMapping)
self.assertEqual(len(macro.child_macros), 1)
y = system_mapping.get("y")
macro.press_trigger()
@ -1388,7 +1390,7 @@ class TestIfTap(MacroTestBase):
self.assertFalse(macro.running)
async def test_if_not_tap(self):
macro = parse("if_tap(key(x), key(y), 50)", self.context)
macro = parse("if_tap(key(x), key(y), 50)", self.context, DummyMapping)
self.assertEqual(len(macro.child_macros), 2)
x = system_mapping.get("x")
@ -1404,7 +1406,7 @@ class TestIfTap(MacroTestBase):
self.assertFalse(macro.running)
async def test_if_not_tap_named(self):
macro = parse("if_tap(key(x), key(y), timeout=50)", self.context)
macro = parse("if_tap(key(x), key(y), timeout=50)", self.context, DummyMapping)
self.assertEqual(len(macro.child_macros), 2)
x = system_mapping.get("x")

@ -0,0 +1,335 @@
#!/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
from functools import partial
from evdev.ecodes import EV_KEY
from pydantic import ValidationError
from inputremapper.configs.mapping import Mapping
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.input_event import EventActions
from inputremapper.event_combination import EventCombination
class TestMapping(unittest.IsolatedAsyncioTestCase):
def test_init(self):
"""test init and that defaults are set"""
cfg = {
"event_combination": "1,2,1",
"target_uinput": "keyboard",
"output_symbol": "a",
}
m = Mapping(**cfg)
self.assertEqual(m.event_combination, EventCombination.validate("1,2,1"))
self.assertEqual(m.target_uinput, "keyboard")
self.assertEqual(m.output_symbol, "a")
self.assertIsNone(m.output_code)
self.assertIsNone(m.output_type)
self.assertEqual(m.macro_key_sleep_ms, 20)
self.assertEqual(m.deadzone, 0.1)
self.assertEqual(m.gain, 1)
self.assertEqual(m.expo, 0)
self.assertEqual(m.rate, 60)
self.assertEqual(m.rel_speed, 100)
self.assertEqual(m.rel_input_cutoff, 100)
self.assertEqual(m.release_timeout, 0.05)
def test_get_output_type_code(self):
cfg = {
"event_combination": "1,2,1",
"target_uinput": "keyboard",
"output_symbol": "a",
}
m = Mapping(**cfg)
a = system_mapping.get("a")
self.assertEqual(m.get_output_type_code(), (EV_KEY, a))
m.output_symbol = "key(a)"
self.assertIsNone(m.get_output_type_code())
cfg = {
"event_combination": "1,2,1+3,1,0",
"target_uinput": "keyboard",
"output_type": 2,
"output_code": 3,
}
m = Mapping(**cfg)
self.assertEqual(m.get_output_type_code(), (2, 3))
def test_init_sets_event_actions(self):
"""test that InputEvent.actions are set properly"""
cfg = {
"event_combination": "1,2,1+2,1,1+3,1,0",
"target_uinput": "keyboard",
"output_type": 2,
"output_code": 3,
}
m = Mapping(**cfg)
expected_actions = [EventActions.as_key, EventActions.as_key, EventActions.none]
actions = [event.action for event in m.event_combination]
self.assertEqual(expected_actions, actions)
# copy keeps the event action
m2 = m.copy()
actions = [event.action for event in m2.event_combination]
self.assertEqual(expected_actions, actions)
# changing the combination sets the action
m3 = m.copy()
m3.event_combination = "1,2,1+2,1,0+3,1,10"
expected_actions = [EventActions.as_key, EventActions.none, EventActions.as_key]
actions = [event.action for event in m3.event_combination]
self.assertEqual(expected_actions, actions)
def test_combination_changed_callback(self):
cfg = {
"event_combination": "1,1,1",
"target_uinput": "keyboard",
"output_symbol": "a",
}
m = Mapping(**cfg)
arguments = []
def callback(*args):
arguments.append(tuple(args))
m.set_combination_changed_callback(callback)
m.event_combination = "1,1,2"
m.event_combination = "1,1,3"
# make sure a copy works as expected and keeps the callback
m2 = m.copy()
m2.event_combination = "1,1,4"
m2.remove_combination_changed_callback()
m.remove_combination_changed_callback()
m.event_combination = "1,1,5"
m2.event_combination = "1,1,6"
self.assertEqual(
arguments,
[
(
EventCombination.from_string("1,1,2"),
EventCombination.from_string("1,1,1"),
),
(
EventCombination.from_string("1,1,3"),
EventCombination.from_string("1,1,2"),
),
(
EventCombination.from_string("1,1,4"),
EventCombination.from_string("1,1,3"),
),
],
)
m.remove_combination_changed_callback()
def test_init_fails(self):
"""test that the init fails with invalid data"""
test = partial(self.assertRaises, ValidationError, Mapping)
cfg = {
"event_combination": "1,2,3",
"target_uinput": "keyboard",
"output_symbol": "a",
}
Mapping(**cfg)
# missing output symbol
del cfg["output_symbol"]
test(**cfg)
cfg["output_code"] = 1
test(**cfg)
cfg["output_type"] = 1
Mapping(**cfg)
# matching type, code and symbol
a = system_mapping.get("a")
cfg["output_code"] = a
cfg["output_symbol"] = "a"
cfg["output_type"] = EV_KEY
Mapping(**cfg)
# macro + type and code
cfg["output_symbol"] = "key(a)"
test(**cfg)
cfg["output_symbol"] = "a"
Mapping(**cfg)
# mismatching type, code and symbol
cfg["output_symbol"] = "b"
test(**cfg)
del cfg["output_type"]
del cfg["output_code"]
Mapping(**cfg) # no error
# empty symbol string without type and code
cfg["output_symbol"] = ""
test(**cfg)
cfg["output_symbol"] = "a"
# missing target
del cfg["target_uinput"]
test(**cfg)
# unknown target
cfg["target_uinput"] = "foo"
test(**cfg)
cfg["target_uinput"] = "keyboard"
Mapping(**cfg)
# missing event_combination
del cfg["event_combination"]
test(**cfg)
cfg["event_combination"] = "1,2,3"
Mapping(**cfg)
# no macro and not a known symbol
cfg["output_symbol"] = "qux"
test(**cfg)
cfg["output_symbol"] = "key(a)"
Mapping(**cfg)
# invalid macro
cfg["output_symbol"] = "key('a')"
test(**cfg)
cfg["output_symbol"] = "a"
Mapping(**cfg)
# map axis but no output type and code given
cfg["event_combination"] = "3,0,0"
test(**cfg)
del cfg["output_symbol"]
cfg["output_code"] = 1
cfg["output_type"] = 3
Mapping(**cfg)
# empty symbol string is allowed when type and code is set
cfg["output_symbol"] = ""
Mapping(**cfg)
del cfg["output_symbol"]
# multiple axis as axis in event combination
cfg["event_combination"] = "3,0,0+3,1,0"
test(**cfg)
cfg["event_combination"] = "3,0,0"
Mapping(**cfg)
del cfg["output_type"]
del cfg["output_code"]
cfg["event_combination"] = "1,2,3"
cfg["output_symbol"] = "a"
Mapping(**cfg)
# map EV_ABS as key with trigger point out of range
cfg["event_combination"] = "3,0,100"
test(**cfg)
cfg["event_combination"] = "3,0,99"
Mapping(**cfg)
cfg["event_combination"] = "3,0,-100"
test(**cfg)
cfg["event_combination"] = "3,0,-99"
Mapping(**cfg)
cfg["event_combination"] = "1,2,3"
Mapping(**cfg)
# deadzone out of range
test(**cfg, deadzone=1.01)
test(**cfg, deadzone=-0.01)
Mapping(**cfg, deadzone=1)
Mapping(**cfg, deadzone=0)
# expo out of range
test(**cfg, expo=1.01)
test(**cfg, expo=-1.01)
Mapping(**cfg, expo=1)
Mapping(**cfg, expo=-1)
# negative rate
test(**cfg, rate=-1)
test(**cfg, rate=0)
Mapping(**cfg, rate=1)
Mapping(**cfg, rate=200)
# negative rel_speed
test(**cfg, rel_speed=-1)
test(**cfg, rel_speed=0)
Mapping(**cfg, rel_speed=1)
Mapping(**cfg, rel_speed=200)
# negative rel_input_cutoff
test(**cfg, rel_input_cutoff=-1)
test(**cfg, rel_input_cutoff=0)
Mapping(**cfg, rel_input_cutoff=1)
Mapping(**cfg, rel_input_cutoff=200)
# negative release timeout
test(**cfg, release_timeout=-0.1)
test(**cfg, release_timeout=0)
Mapping(**cfg, release_timeout=0.05)
Mapping(**cfg, release_timeout=0.3)
def test_revalidate_at_assignment(self):
cfg = {
"event_combination": "1,1,1",
"target_uinput": "keyboard",
"output_symbol": "a",
}
m = Mapping(**cfg)
test = partial(self.assertRaises, ValidationError, m.__setattr__)
# invalid input event
test("event_combination", "1,2,3,4")
# unknown target
test("target_uinput", "foo")
# invalid macro
test("output_symbol", "key()")
# we could do a lot more tests here but since pydantic uses the same validation
# code as for the initialization we only need to make sure that the
# assignment validation is active
def test_set_invalid_combination_with_callback(self):
cfg = {
"event_combination": "1,1,1",
"target_uinput": "keyboard",
"output_symbol": "a",
}
m = Mapping(**cfg)
m.set_combination_changed_callback(lambda *args: None)
self.assertRaises(ValidationError, m.__setattr__, "event_combination", "1,2")
m.event_combination = "1,2,3"
m.event_combination = "1,2,3"
def test_is_valid(self):
cfg = {
"event_combination": "1,1,1",
"target_uinput": "keyboard",
"output_symbol": "a",
}
m = Mapping(**cfg)
self.assertTrue(m.is_valid())
if __name__ == "__main__":
unittest.main()

@ -11,8 +11,7 @@
#
# 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 inputremapper.configs.mapping import UIMapping, Mapping
from tests.test import quick_cleanup, tmp
import os
@ -20,12 +19,26 @@ import unittest
import shutil
import json
from evdev.ecodes import EV_KEY, EV_ABS, ABS_HAT0X
from evdev.ecodes import (
EV_KEY,
EV_ABS,
ABS_HAT0X,
ABS_X,
ABS_Y,
ABS_RX,
ABS_RY,
EV_REL,
REL_X,
REL_Y,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
)
from inputremapper.configs.migrations import migrate, config_version
from inputremapper.configs.preset import Preset
from inputremapper.configs.global_config import global_config
from inputremapper.configs.paths import touch, CONFIG_PATH, mkdir, get_preset_path
from inputremapper.logger import IS_BETA
from inputremapper.event_combination import EventCombination
from inputremapper.user import HOME
@ -56,7 +69,10 @@ class TestMigrations(unittest.TestCase):
def test_rename_config(self):
old = os.path.join(HOME, ".config", "key-mapper")
new = CONFIG_PATH
if IS_BETA:
new = os.path.join(*os.path.split(CONFIG_PATH)[:-1])
else:
new = CONFIG_PATH
# we are not destroying our actual config files with this test
self.assertTrue(new.startswith(tmp))
@ -80,7 +96,6 @@ class TestMigrations(unittest.TestCase):
with open(new_config_json, "r") as f:
moved_config = json.loads(f.read())
self.assertEqual(moved_config["foo"], "bar")
self.assertIn("version", moved_config)
def test_wont_migrate_suffix(self):
old = os.path.join(CONFIG_PATH, "config")
@ -99,11 +114,11 @@ class TestMigrations(unittest.TestCase):
self.assertTrue(os.path.exists(old))
def test_migrate_preset(self):
if os.path.exists(tmp):
shutil.rmtree(tmp)
if os.path.exists(CONFIG_PATH):
shutil.rmtree(CONFIG_PATH)
p1 = os.path.join(tmp, "foo1", "bar1.json")
p2 = os.path.join(tmp, "foo2", "bar2.json")
p1 = os.path.join(CONFIG_PATH, "foo1", "bar1.json")
p2 = os.path.join(CONFIG_PATH, "foo2", "bar2.json")
touch(p1)
touch(p2)
@ -115,22 +130,22 @@ class TestMigrations(unittest.TestCase):
migrate()
self.assertFalse(os.path.exists(os.path.join(tmp, "foo1", "bar1.json")))
self.assertFalse(os.path.exists(os.path.join(tmp, "foo2", "bar2.json")))
self.assertFalse(os.path.exists(os.path.join(CONFIG_PATH, "foo1", "bar1.json")))
self.assertFalse(os.path.exists(os.path.join(CONFIG_PATH, "foo2", "bar2.json")))
self.assertTrue(
os.path.exists(os.path.join(tmp, "presets", "foo1", "bar1.json"))
os.path.exists(os.path.join(CONFIG_PATH, "presets", "foo1", "bar1.json"))
)
self.assertTrue(
os.path.exists(os.path.join(tmp, "presets", "foo2", "bar2.json"))
os.path.exists(os.path.join(CONFIG_PATH, "presets", "foo2", "bar2.json"))
)
def test_wont_migrate_preset(self):
if os.path.exists(tmp):
shutil.rmtree(tmp)
if os.path.exists(CONFIG_PATH):
shutil.rmtree(CONFIG_PATH)
p1 = os.path.join(tmp, "foo1", "bar1.json")
p2 = os.path.join(tmp, "foo2", "bar2.json")
p1 = os.path.join(CONFIG_PATH, "foo1", "bar1.json")
p2 = os.path.join(CONFIG_PATH, "foo2", "bar2.json")
touch(p1)
touch(p2)
@ -141,28 +156,28 @@ class TestMigrations(unittest.TestCase):
f.write("{}")
# already migrated
mkdir(os.path.join(tmp, "presets"))
mkdir(os.path.join(CONFIG_PATH, "presets"))
migrate()
self.assertTrue(os.path.exists(os.path.join(tmp, "foo1", "bar1.json")))
self.assertTrue(os.path.exists(os.path.join(tmp, "foo2", "bar2.json")))
self.assertTrue(os.path.exists(os.path.join(CONFIG_PATH, "foo1", "bar1.json")))
self.assertTrue(os.path.exists(os.path.join(CONFIG_PATH, "foo2", "bar2.json")))
self.assertFalse(
os.path.exists(os.path.join(tmp, "presets", "foo1", "bar1.json"))
os.path.exists(os.path.join(CONFIG_PATH, "presets", "foo1", "bar1.json"))
)
self.assertFalse(
os.path.exists(os.path.join(tmp, "presets", "foo2", "bar2.json"))
os.path.exists(os.path.join(CONFIG_PATH, "presets", "foo2", "bar2.json"))
)
def test_migrate_mappings(self):
"""test if mappings are migrated correctly
mappings like
{(type, code): symbol} or {(type, code, value): symbol} should migrate to
{(type, code, value): (symbol, "keyboard")}
{(type, code): symbol} or {(type, code, value): symbol} should migrate
to {EventCombination: {target: target, symbol: symbol, ...}}
"""
path = os.path.join(tmp, "presets", "Foo Device", "test.json")
path = os.path.join(CONFIG_PATH, "presets", "Foo Device", "test.json")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as file:
json.dump(
@ -171,9 +186,11 @@ class TestMigrations(unittest.TestCase):
f"{EV_KEY},1": "a",
f"{EV_KEY}, 2, 1": "BTN_B", # can be mapped to "gamepad"
f"{EV_KEY}, 3, 1": "BTN_1", # can not be mapped
f"{EV_KEY}, 4, 1": ("a", "foo"),
f"{EV_ABS},{ABS_HAT0X},-1": "b",
f"{EV_ABS},1,1+{EV_ABS},2,-1+{EV_ABS},3,1": "c",
f"{EV_KEY}, 4, 1": ("d", "keyboard"),
f"{EV_KEY}, 5, 1": ("e", "foo"), # unknown target
f"{EV_KEY}, 6, 1": ("key(a, b)", "keyboard"), # broken macro
# ignored because broken
f"3,1,1,2": "e",
f"3": "e",
@ -184,46 +201,83 @@ class TestMigrations(unittest.TestCase):
file,
)
migrate()
loaded = Preset()
self.assertEqual(loaded.num_saved_keys, 0)
loaded.load(get_preset_path("Foo Device", "test"))
# use UIMapping to also load invalid mappings
preset = Preset(get_preset_path("Foo Device", "test"), UIMapping)
preset.load()
self.assertEqual(
loaded.get_mapping(EventCombination([EV_KEY, 1, 1])),
("a", "keyboard"),
preset.get_mapping(EventCombination([EV_KEY, 1, 1])),
UIMapping(
event_combination=EventCombination([EV_KEY, 1, 1]),
target_uinput="keyboard",
output_symbol="a",
),
)
self.assertEqual(
loaded.get_mapping(EventCombination([EV_KEY, 2, 1])),
("BTN_B", "gamepad"),
preset.get_mapping(EventCombination([EV_KEY, 2, 1])),
UIMapping(
event_combination=EventCombination([EV_KEY, 2, 1]),
target_uinput="gamepad",
output_symbol="BTN_B",
),
)
self.assertEqual(
loaded.get_mapping(EventCombination([EV_KEY, 3, 1])),
(
"BTN_1\n# Broken mapping:\n# No target can handle all specified keycodes",
"keyboard",
preset.get_mapping(EventCombination([EV_KEY, 3, 1])),
UIMapping(
event_combination=EventCombination([EV_KEY, 3, 1]),
target_uinput="keyboard",
output_symbol="BTN_1\n# Broken mapping:\n# No target can handle all specified keycodes",
),
)
self.assertEqual(
loaded.get_mapping(EventCombination([EV_KEY, 4, 1])),
("a", "foo"),
preset.get_mapping(EventCombination([EV_KEY, 4, 1])),
UIMapping(
event_combination=EventCombination([EV_KEY, 4, 1]),
target_uinput="keyboard",
output_symbol="d",
),
)
self.assertEqual(
loaded.get_mapping(EventCombination([EV_ABS, ABS_HAT0X, -1])),
("b", "keyboard"),
preset.get_mapping(EventCombination([EV_ABS, ABS_HAT0X, -1])),
UIMapping(
event_combination=EventCombination([EV_ABS, ABS_HAT0X, -1]),
target_uinput="keyboard",
output_symbol="b",
),
)
self.assertEqual(
loaded.get_mapping(
EventCombination((EV_ABS, 1, 1), (EV_ABS, 2, -1), (EV_ABS, 3, 1))
preset.get_mapping(
EventCombination(((EV_ABS, 1, 1), (EV_ABS, 2, -1), (EV_ABS, 3, 1)))
),
UIMapping(
event_combination=EventCombination(
((EV_ABS, 1, 1), (EV_ABS, 2, -1), (EV_ABS, 3, 1))
),
target_uinput="keyboard",
output_symbol="c",
),
)
self.assertEqual(
preset.get_mapping(EventCombination([EV_KEY, 5, 1])),
UIMapping(
event_combination=EventCombination([EV_KEY, 5, 1]),
target_uinput="foo",
output_symbol="e",
),
)
self.assertEqual(
preset.get_mapping(EventCombination([EV_KEY, 6, 1])),
UIMapping(
event_combination=EventCombination([EV_KEY, 6, 1]),
target_uinput="keyboard",
output_symbol="key(a, b)",
),
("c", "keyboard"),
)
print(loaded._mapping)
self.assertEqual(len(loaded), 6)
self.assertEqual(loaded.num_saved_keys, 6)
self.assertEqual(8, len(preset))
def test_migrate_otherwise(self):
path = os.path.join(tmp, "presets", "Foo Device", "test.json")
path = os.path.join(CONFIG_PATH, "presets", "Foo Device", "test.json")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as file:
json.dump(
@ -241,28 +295,48 @@ class TestMigrations(unittest.TestCase):
migrate()
loaded = Preset()
loaded.load(get_preset_path("Foo Device", "test"))
preset = Preset(get_preset_path("Foo Device", "test"), UIMapping)
preset.load()
self.assertEqual(
loaded.get_mapping(EventCombination([EV_KEY, 1, 1])),
("otherwise + otherwise", "keyboard"),
preset.get_mapping(EventCombination([EV_KEY, 1, 1])),
UIMapping(
event_combination=EventCombination([EV_KEY, 1, 1]),
target_uinput="keyboard",
output_symbol="otherwise + otherwise",
),
)
self.assertEqual(
loaded.get_mapping(EventCombination([EV_KEY, 2, 1])),
("bar($otherwise)", "keyboard"),
preset.get_mapping(EventCombination([EV_KEY, 2, 1])),
UIMapping(
event_combination=EventCombination([EV_KEY, 2, 1]),
target_uinput="keyboard",
output_symbol="bar($otherwise)",
),
)
self.assertEqual(
loaded.get_mapping(EventCombination([EV_KEY, 3, 1])),
("foo(else=qux)", "keyboard"),
preset.get_mapping(EventCombination([EV_KEY, 3, 1])),
UIMapping(
event_combination=EventCombination([EV_KEY, 3, 1]),
target_uinput="keyboard",
output_symbol="foo(else=qux)",
),
)
self.assertEqual(
loaded.get_mapping(EventCombination([EV_KEY, 4, 1])),
("qux(otherwise).bar(else=1)", "foo"),
preset.get_mapping(EventCombination([EV_KEY, 4, 1])),
UIMapping(
event_combination=EventCombination([EV_KEY, 4, 1]),
target_uinput="foo",
output_symbol="qux(otherwise).bar(else=1)",
),
)
self.assertEqual(
loaded.get_mapping(EventCombination([EV_KEY, 5, 1])),
("foo(otherwise1=2qux)", "keyboard"),
preset.get_mapping(EventCombination([EV_KEY, 5, 1])),
UIMapping(
event_combination=EventCombination([EV_KEY, 5, 1]),
target_uinput="keyboard",
output_symbol="foo(otherwise1=2qux)",
),
)
def test_add_version(self):
@ -297,6 +371,140 @@ class TestMigrations(unittest.TestCase):
self.assertEqual("0.0.0", config_version().public)
def test_migrate_left_and_right_purpose(self):
path = os.path.join(CONFIG_PATH, "presets", "Foo Device", "test.json")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as file:
json.dump(
{
"gamepad": {
"joystick": {
"left_purpose": "mouse",
"right_purpose": "wheel",
"pointer_speed": 50,
"x_scroll_speed": 10,
"y_scroll_speed": 20,
}
}
},
file,
)
migrate()
preset = Preset(get_preset_path("Foo Device", "test"), UIMapping)
preset.load()
# 2 mappings for mouse
# 2 mappings for wheel
self.assertEqual(len(preset), 4)
self.assertEqual(
preset.get_mapping(EventCombination((EV_ABS, ABS_X, 0))),
UIMapping(
event_combination=EventCombination((EV_ABS, ABS_X, 0)),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_X,
gain=50 / 100,
),
)
self.assertEqual(
preset.get_mapping(EventCombination((EV_ABS, ABS_Y, 0))),
UIMapping(
event_combination=EventCombination((EV_ABS, ABS_Y, 0)),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_Y,
gain=50 / 100,
),
)
self.assertEqual(
preset.get_mapping(EventCombination((EV_ABS, ABS_RX, 0))),
UIMapping(
event_combination=EventCombination((EV_ABS, ABS_RX, 0)),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_HWHEEL_HI_RES,
gain=10,
),
)
self.assertEqual(
preset.get_mapping(EventCombination((EV_ABS, ABS_RY, 0))),
UIMapping(
event_combination=EventCombination((EV_ABS, ABS_RY, 0)),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_WHEEL_HI_RES,
gain=20,
),
)
def test_migrate_left_and_right_purpose2(self):
# same as above, but left and right is swapped
path = os.path.join(CONFIG_PATH, "presets", "Foo Device", "test.json")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as file:
json.dump(
{
"gamepad": {
"joystick": {
"right_purpose": "mouse",
"left_purpose": "wheel",
"pointer_speed": 50,
"x_scroll_speed": 10,
"y_scroll_speed": 20,
}
}
},
file,
)
migrate()
preset = Preset(get_preset_path("Foo Device", "test"), UIMapping)
preset.load()
# 2 mappings for mouse
# 2 mappings for wheel
self.assertEqual(len(preset), 4)
self.assertEqual(
preset.get_mapping(EventCombination((EV_ABS, ABS_RX, 0))),
UIMapping(
event_combination=EventCombination((EV_ABS, ABS_RX, 0)),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_X,
gain=50 / 100,
),
)
self.assertEqual(
preset.get_mapping(EventCombination((EV_ABS, ABS_RY, 0))),
UIMapping(
event_combination=EventCombination((EV_ABS, ABS_RY, 0)),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_Y,
gain=50 / 100,
),
)
self.assertEqual(
preset.get_mapping(EventCombination((EV_ABS, ABS_X, 0))),
UIMapping(
event_combination=EventCombination((EV_ABS, ABS_X, 0)),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_HWHEEL_HI_RES,
gain=10,
),
)
self.assertEqual(
preset.get_mapping(EventCombination((EV_ABS, ABS_Y, 0))),
UIMapping(
event_combination=EventCombination((EV_ABS, ABS_Y, 0)),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_WHEEL_HI_RES,
gain=20,
),
)
if __name__ == "__main__":
unittest.main()

@ -54,12 +54,12 @@ class TestPaths(unittest.TestCase):
self.assertTrue(os.path.isdir(path_bcde))
def test_get_preset_path(self):
self.assertEqual(get_preset_path(), os.path.join(tmp, "presets"))
self.assertEqual(get_preset_path("a"), os.path.join(tmp, "presets/a"))
self.assertEqual(
get_preset_path("a", "b"), os.path.join(tmp, "presets/a/b.json")
)
self.assertTrue(get_preset_path().startswith(get_config_path()))
self.assertTrue(get_preset_path().endswith("presets"))
self.assertTrue(get_preset_path("a").endswith("presets/a"))
self.assertTrue(get_preset_path("a", "b").endswith("presets/a/b.json"))
def test_get_config_path(self):
self.assertEqual(get_config_path(), tmp)
self.assertEqual(get_config_path("a", "b"), os.path.join(tmp, "a/b"))
# might end with /beta_XXX
self.assertTrue(get_config_path().startswith(f"{tmp}/.config/input-remapper"))
self.assertTrue(get_config_path("a", "b").endswith("a/b"))

@ -17,9 +17,8 @@
#
# 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 tests.test import tmp, quick_cleanup
from inputremapper.configs.mapping import UIMapping
from tests.test import tmp, quick_cleanup, get_key_mapping
import os
import unittest
@ -31,8 +30,7 @@ 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.configs.paths import get_preset_path, get_config_path, CONFIG_PATH
from inputremapper.event_combination import EventCombination
@ -49,7 +47,7 @@ class TestSystemMapping(unittest.TestCase):
def test_xmodmap_file(self):
system_mapping = SystemMapping()
path = os.path.join(tmp, XMODMAP_FILENAME)
path = os.path.join(CONFIG_PATH, XMODMAP_FILENAME)
os.remove(path)
system_mapping.populate()
@ -121,241 +119,367 @@ class TestSystemMapping(unittest.TestCase):
self.assertEqual(system_mapping.get("disable"), -1)
class TestMapping(unittest.TestCase):
class TestPreset(unittest.TestCase):
def setUp(self):
self.preset = Preset()
self.preset = Preset(get_preset_path("foo", "bar2"))
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)
def test_has_unsaved_changes(self):
self.preset.path = get_preset_path("foo", "bar2")
self.preset.add(get_key_mapping())
self.assertTrue(self.preset.has_unsaved_changes())
self.preset.save()
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.empty()
self.assertEqual(len(self.preset), 0)
self.assertTrue(
self.preset.has_unsaved_changes()
) # empty preset but non-empty file
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"))
# load again from the disc
self.preset.load()
self.assertEqual(
self.preset.get_mapping(EventCombination([EV_KEY, 81, 1])),
("a", "keyboard"),
self.preset.get_mapping(EventCombination([99, 99, 99])),
get_key_mapping(),
)
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")
# change the path to a non exiting file
self.preset.path = get_preset_path("bar", "foo")
self.assertTrue(
self.preset.has_unsaved_changes()
) # the preset has a mapping, the file has not
# change back to the original path
self.preset.path = get_preset_path("foo", "bar2")
self.assertFalse(
self.preset.has_unsaved_changes()
) # no difference between file and memory
# modify the mapping
mapping = self.preset.get_mapping(EventCombination([99, 99, 99]))
mapping.gain = 0.5
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"))
self.preset.load()
self.preset.path = get_preset_path("bar", "foo")
self.preset.remove(get_key_mapping().event_combination)
self.assertFalse(
self.preset.has_unsaved_changes()
) # empty preset and empty file
self.preset.path = get_preset_path("foo", "bar2")
self.assertTrue(
self.preset.has_unsaved_changes()
) # empty preset, but non-empty file
self.preset.load()
self.assertEqual(len(self.preset), 1)
self.assertFalse(self.preset.has_unsaved_changes())
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)
# delete the preset from the system:
self.preset.empty()
self.preset.save()
self.preset.load()
self.assertFalse(self.preset.has_unsaved_changes())
self.assertEqual(len(self.preset), 0)
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"))
self.preset.add(get_key_mapping(EventCombination(one), "keyboard", "1"))
self.preset.add(get_key_mapping(EventCombination(two), "keyboard", "2"))
self.preset.add(
get_key_mapping(EventCombination((two, three)), "keyboard", "3")
)
self.preset.path = get_preset_path("Foo Device", "test")
self.preset.save()
path = os.path.join(tmp, "presets", "Foo Device", "test.json")
path = os.path.join(CONFIG_PATH, "presets", "Foo Device", "test.json")
self.assertTrue(os.path.exists(path))
loaded = Preset()
loaded = Preset(get_preset_path("Foo Device", "test"))
self.assertEqual(len(loaded), 0)
loaded.load(get_preset_path("Foo Device", "test"))
loaded.load()
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")
loaded.get_mapping(EventCombination(one)),
get_key_mapping(EventCombination(one), "keyboard", "1"),
)
self.assertEqual(
loaded.get_mapping(EventCombination(two)),
get_key_mapping(EventCombination(two), "keyboard", "2"),
)
self.assertEqual(
loaded.get_mapping(EventCombination((two, three))),
get_key_mapping(EventCombination((two, three)), "keyboard", "3"),
)
self.assertEqual(loaded._config["foo"], "bar")
def test_change(self):
# load missing file
preset = Preset(get_config_path("missing_file.json"))
self.assertRaises(FileNotFoundError, preset.load)
def test_modify_mapping(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)]
)
# only values between -99 and 99 are allowed as mapping for EV_ABS or EV_REL
ev_4 = EventCombination((EV_ABS, 1, 99))
# 1 is not assigned yet, ignore it
self.preset.change(ev_1, "keyboard", "a", ev_2)
# add the first mapping
self.preset.add(get_key_mapping(ev_1, "keyboard", "a"))
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)
mapping = self.preset.get_mapping(ev_1)
mapping.event_combination = ev_3
mapping.output_symbol = "b"
self.assertIsNone(self.preset.get_mapping(ev_1))
self.assertEqual(self.preset.get_mapping(ev_3), ("b", "keyboard"))
self.assertEqual(
self.preset.get_mapping(ev_3), get_key_mapping(ev_3, "keyboard", "b")
)
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.preset.add(get_key_mapping(ev_4, "keyboard", "c"))
self.assertEqual(
self.preset.get_mapping(ev_3), get_key_mapping(ev_3, "keyboard", "b")
)
self.assertEqual(
self.preset.get_mapping(ev_4), get_key_mapping(ev_4, "keyboard", "c")
)
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"))
mapping = self.preset.get_mapping(ev_4)
mapping.output_symbol = "d"
self.assertEqual(
self.preset.get_mapping(ev_4), get_key_mapping(ev_4, "keyboard", "d")
)
self.assertEqual(len(self.preset), 2)
self.assertEqual(self.preset.num_saved_keys, 0)
# try to change combination of 4 to 3
mapping = self.preset.get_mapping(ev_4)
with self.assertRaises(KeyError):
mapping.event_combination = ev_3
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.assertEqual(
self.preset.get_mapping(ev_3), get_key_mapping(ev_3, "keyboard", "b")
)
self.assertRaises(ValueError, lambda: self.preset.change(key, " \n ", "b"))
self.assertEqual(len(self.preset), 0)
self.assertEqual(
self.preset.get_mapping(ev_4), get_key_mapping(ev_4, "keyboard", "d")
)
self.assertEqual(len(self.preset), 2)
def test_avoids_redundant_changes(self):
# to avoid logs that don't add any value
def clear(*_):
# should not be called
raise AssertionError
def test_avoids_redundant_saves(self):
with patch.object(self.preset, "has_unsaved_changes", lambda: False):
self.preset.path = get_preset_path("foo", "bar2")
self.preset.add(get_key_mapping())
self.preset.save()
key = EventCombination([EV_KEY, 987, 1])
target = "keyboard"
symbol = "foo"
with open(get_preset_path("foo", "bar2"), "r") as f:
content = f.read()
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)
self.assertFalse(content)
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"))
ev_4 = InputEvent.from_tuple((EV_ABS, 1, 99))
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))
def test_clear(self):
self.preset.add(get_key_mapping(combi_1, "keyboard", "a"))
self.assertEqual(
self.preset.get_mapping(combi_1), get_key_mapping(combi_1, "keyboard", "a")
)
self.assertEqual(
self.preset.get_mapping(combi_2), get_key_mapping(combi_1, "keyboard", "a")
)
# since combi_1 and combi_2 are equivalent, this raises an KeyError
self.assertRaises(
KeyError, self.preset.add, get_key_mapping(combi_2, "keyboard", "b")
)
self.assertEqual(
self.preset.get_mapping(combi_1), get_key_mapping(combi_1, "keyboard", "a")
)
self.assertEqual(
self.preset.get_mapping(combi_2), get_key_mapping(combi_1, "keyboard", "a")
)
self.preset.add(get_key_mapping(combi_3, "keyboard", "c"))
self.assertEqual(
self.preset.get_mapping(combi_1), get_key_mapping(combi_1, "keyboard", "a")
)
self.assertEqual(
self.preset.get_mapping(combi_2), get_key_mapping(combi_1, "keyboard", "a")
)
self.assertEqual(
self.preset.get_mapping(combi_3), get_key_mapping(combi_3, "keyboard", "c")
)
mapping = self.preset.get_mapping(combi_1)
mapping.output_symbol = "c"
with self.assertRaises(KeyError):
mapping.event_combination = combi_3
self.assertEqual(
self.preset.get_mapping(combi_1), get_key_mapping(combi_1, "keyboard", "c")
)
self.assertEqual(
self.preset.get_mapping(combi_2), get_key_mapping(combi_1, "keyboard", "c")
)
self.assertEqual(
self.preset.get_mapping(combi_3), get_key_mapping(combi_3, "keyboard", "c")
)
def test_remove(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.assertRaises(TypeError, self.preset.remove, (EV_KEY, 10, 1))
self.preset.remove(ev_1)
self.assertFalse(self.preset.has_unsaved_changes())
self.assertEqual(len(self.preset), 0)
self.preset._mapping[ev_1] = "b"
self.preset.add(get_key_mapping(combination=ev_1))
self.assertEqual(len(self.preset), 1)
self.preset.clear(ev_1)
self.preset.remove(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.preset.add(get_key_mapping(ev_4, "keyboard", "KEY_KP1"))
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.preset.add(get_key_mapping(ev_3, "keyboard", "KEY_KP2"))
self.preset.add(get_key_mapping(ev_2, "keyboard", "KEY_KP3"))
self.assertEqual(len(self.preset), 3)
self.preset.clear(ev_3)
self.preset.remove(ev_3)
self.assertEqual(len(self.preset), 2)
self.assertEqual(self.preset.get_mapping(ev_4), ("KEY_KP1", "keyboard"))
self.assertEqual(
self.preset.get_mapping(ev_4), get_key_mapping(ev_4, "keyboard", "KEY_KP1")
)
self.assertIsNone(self.preset.get_mapping(ev_3))
self.assertEqual(self.preset.get_mapping(ev_2), ("KEY_KP3", "keyboard"))
self.assertEqual(
self.preset.get_mapping(ev_2), get_key_mapping(ev_2, "keyboard", "KEY_KP3")
)
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.preset.add(
get_key_mapping(EventCombination([EV_KEY, 10, 1]), "keyboard", "1")
)
self.preset.add(
get_key_mapping(EventCombination([EV_KEY, 11, 1]), "keyboard", "2")
)
self.preset.add(
get_key_mapping(EventCombination([EV_KEY, 12, 1]), "keyboard", "3")
)
self.assertEqual(len(self.preset), 3)
self.preset.path = get_config_path("test.json")
self.preset.save()
self.assertFalse(self.preset.has_unsaved_changes())
self.preset.empty()
self.assertEqual(self.preset.path, get_config_path("test.json"))
self.assertTrue(self.preset.has_unsaved_changes())
self.assertEqual(len(self.preset), 0)
def test_clear(self):
self.preset.add(
get_key_mapping(EventCombination([EV_KEY, 10, 1]), "keyboard", "1")
)
self.preset.add(
get_key_mapping(EventCombination([EV_KEY, 11, 1]), "keyboard", "2")
)
self.preset.add(
get_key_mapping(EventCombination([EV_KEY, 12, 1]), "keyboard", "3")
)
self.assertEqual(len(self.preset), 3)
self.preset.path = get_config_path("test.json")
self.preset.save()
self.assertFalse(self.preset.has_unsaved_changes())
self.preset.clear()
self.assertFalse(self.preset.has_unsaved_changes())
self.assertIsNone(self.preset.path)
self.assertEqual(len(self.preset), 0)
def test_dangerously_mapped_btn_left(self):
self.preset.change(EventCombination(InputEvent.btn_left()), "keyboard", "1")
# btn left is mapped
self.preset.add(
get_key_mapping(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.preset.add(
get_key_mapping(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")
# another mapping maps to btn_left
self.preset.add(
get_key_mapping(EventCombination([EV_KEY, 42, 1]), "mouse", "btn_left")
)
self.assertFalse(self.preset.dangerously_mapped_btn_left())
self.preset.change(EventCombination([EV_KEY, 42, 1]), "gamepad", "BTN_Left")
mapping = self.preset.get_mapping(EventCombination([EV_KEY, 42, 1]))
mapping.output_symbol = "BTN_Left"
self.assertFalse(self.preset.dangerously_mapped_btn_left())
self.preset.change(EventCombination([EV_KEY, 42, 1]), "keyboard", "3")
mapping.target_uinput = "keyboard"
mapping.output_symbol = "3"
self.assertTrue(self.preset.dangerously_mapped_btn_left())
# btn_left is not mapped
self.preset.remove(EventCombination(InputEvent.btn_left()))
self.assertFalse(self.preset.dangerously_mapped_btn_left())
def test_save_load_with_invalid_mappings(self):
ui_preset = Preset(get_config_path("test.json"), mapping_factory=UIMapping)
# cannot add a mapping without a valid combination
self.assertRaises(Exception, ui_preset.add, UIMapping())
ui_preset.add(UIMapping(event_combination="1,1,1"))
self.assertFalse(ui_preset.is_valid())
# make the mapping valid
m = ui_preset.get_mapping(EventCombination.from_string("1,1,1"))
m.output_symbol = "a"
m.target_uinput = "keyboard"
self.assertTrue(ui_preset.is_valid())
m2 = UIMapping(event_combination="1,2,1")
ui_preset.add(m2)
self.assertFalse(ui_preset.is_valid())
ui_preset.save()
# only the valid preset is loaded
preset = Preset(get_config_path("test.json"))
preset.load()
self.assertEqual(len(preset), 1)
self.assertEqual(preset.get_mapping(m.event_combination), m)
# both presets load
ui_preset.clear()
ui_preset.path = get_config_path("test.json")
ui_preset.load()
self.assertEqual(len(ui_preset), 2)
self.assertEqual(ui_preset.get_mapping(m.event_combination), m)
self.assertEqual(ui_preset.get_mapping(m2.event_combination), m2)
if __name__ == "__main__":
unittest.main()

@ -40,8 +40,9 @@ from inputremapper.gui.active_preset import active_preset
def create_preset(group_name, name="new preset"):
name = get_available_preset_name(group_name, name)
active_preset.empty()
active_preset.save(get_preset_path(group_name, name))
active_preset.clear()
active_preset.path = get_preset_path(group_name, name)
active_preset.save()
PRESETS = os.path.join(CONFIG_PATH, "presets")

@ -127,7 +127,9 @@ class TestReader(unittest.TestCase):
self.assertIsInstance(result, tuple)
self.assertEqual(result, EventCombination((EV_REL, REL_WHEEL, 1)))
self.assertEqual(result, ((EV_REL, REL_WHEEL, 1),))
self.assertNotEqual(result, EventCombination((EV_REL, REL_WHEEL, 1), (1, 1, 1)))
self.assertNotEqual(
result, EventCombination(((EV_REL, REL_WHEEL, 1), (1, 1, 1)))
)
# it won't return the same event twice
self.assertEqual(reader.read(), None)
@ -155,8 +157,8 @@ class TestReader(unittest.TestCase):
send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1, 1000))
send_event_to_reader(new_event(EV_KEY, KEY_COMMA, 1, 1001))
combi_1 = EventCombination((EV_REL, REL_WHEEL, 1), (EV_KEY, KEY_COMMA, 1))
combi_2 = EventCombination((EV_KEY, KEY_COMMA, 1), (EV_KEY, KEY_A, 1))
combi_1 = EventCombination(((EV_REL, REL_WHEEL, 1), (EV_KEY, KEY_COMMA, 1)))
combi_2 = EventCombination(((EV_KEY, KEY_COMMA, 1), (EV_KEY, KEY_A, 1)))
read = reader.read()
self.assertEqual(read, combi_1)
self.assertEqual(reader.read(), None)
@ -293,17 +295,17 @@ class TestReader(unittest.TestCase):
send_event_to_reader(new_event(EV_KEY, CODE_1, 1, 1001))
self.assertEqual(reader.read(), EventCombination((EV_KEY, CODE_1, 1)))
active_preset.set("gamepad.joystick.left_purpose", BUTTONS)
# active_preset.set("gamepad.joystick.left_purpose", BUTTONS)
send_event_to_reader(new_event(EV_ABS, ABS_Y, 1, 1002))
self.assertEqual(
reader.read(), EventCombination((EV_KEY, CODE_1, 1), (EV_ABS, ABS_Y, 1))
reader.read(), EventCombination(((EV_KEY, CODE_1, 1), (EV_ABS, ABS_Y, 1)))
)
send_event_to_reader(new_event(EV_ABS, ABS_HAT0X, -1, 1003))
self.assertEqual(
reader.read(),
EventCombination(
(EV_KEY, CODE_1, 1), (EV_ABS, ABS_Y, 1), (EV_ABS, ABS_HAT0X, -1)
((EV_KEY, CODE_1, 1), (EV_ABS, ABS_Y, 1), (EV_ABS, ABS_HAT0X, -1))
),
)
@ -327,7 +329,7 @@ class TestReader(unittest.TestCase):
def test_reads_joysticks(self):
# if their purpose is "buttons"
active_preset.set("gamepad.joystick.left_purpose", BUTTONS)
# active_preset.set("gamepad.joystick.left_purpose", BUTTONS)
push_events(
"gamepad",
[
@ -346,7 +348,7 @@ class TestReader(unittest.TestCase):
self.assertEqual(len(reader._unreleased), 1)
reader._unreleased = {}
active_preset.set("gamepad.joystick.left_purpose", MOUSE)
# active_preset.set("gamepad.joystick.left_purpose", MOUSE)
push_events("gamepad", [new_event(EV_ABS, ABS_Y, MAX_ABS)])
self.create_helper()
@ -373,7 +375,7 @@ class TestReader(unittest.TestCase):
send_event_to_reader(new_event(3, 0, 0, next_timestamp()))
send_event_to_reader(new_event(3, 5, 1, next_timestamp()))
self.assertEqual(
reader.read(), EventCombination((EV_ABS, ABS_Z, 1), (EV_ABS, ABS_RZ, 1))
reader.read(), EventCombination(((EV_ABS, ABS_Z, 1), (EV_ABS, ABS_RZ, 1)))
)
send_event_to_reader(new_event(3, 5, 0, next_timestamp()))
send_event_to_reader(new_event(3, 0, 0, next_timestamp()))

Loading…
Cancel
Save