diff --git a/.github/workflows/reviewdog.yml b/.github/workflows/reviewdog.yml index 6e1369c9..a936b421 100644 --- a/.github/workflows/reviewdog.yml +++ b/.github/workflows/reviewdog.yml @@ -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 diff --git a/.gitignore b/.gitignore index b204709c..a55ae860 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 00000000..34d9e9e7 --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,7 @@ + +[mypy] +plugins = pydantic.mypy + +# ignore the missing evdev stubs +[mypy-evdev.*] +ignore_missing_imports = True diff --git a/.pylintrc b/.pylintrc index 36b74745..e9b01fec 100644 --- a/.pylintrc +++ b/.pylintrc @@ -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 diff --git a/.run/Only Integration Tests.run.xml b/.run/Only Integration Tests.run.xml new file mode 100644 index 00000000..d9a84753 --- /dev/null +++ b/.run/Only Integration Tests.run.xml @@ -0,0 +1,16 @@ + + + + + \ No newline at end of file diff --git a/.run/Only Unit tests.run.xml b/.run/Only Unit tests.run.xml new file mode 100644 index 00000000..e550a716 --- /dev/null +++ b/.run/Only Unit tests.run.xml @@ -0,0 +1,16 @@ + + + + + \ No newline at end of file diff --git a/DEBIAN/control b/DEBIAN/control index 5d0192bc..60f0338f 100644 --- a/DEBIAN/control +++ b/DEBIAN/control @@ -1,5 +1,5 @@ Package: input-remapper -Version: 1.4.2 +Version: 1.5b1 Architecture: all Maintainer: Sezanzeb 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 diff --git a/README.md b/README.md index 33dfdba9..8a8946ab 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

-

Input Remapper

+

Input Remapper (Beta)

Formerly Key Mapper

@@ -14,6 +14,13 @@

+#### 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) diff --git a/data/input-remapper.glade b/data/input-remapper.glade index 49de9a94..ae7868d4 100644 --- a/data/input-remapper.glade +++ b/data/input-remapper.glade @@ -11,18 +11,21 @@ True False + 2 2 dialog-ok True False + 2 2 edit-copy True False + 2 2 edit-delete @@ -117,14 +120,19 @@ True False + 2 2 window-close True False - 2 - object-rotate-right + gtk-add + + + True + False + media-record 2 @@ -135,6 +143,7 @@ True False + 2 2 document-new @@ -157,11 +166,16 @@ True False + 10 + 10 vertical + 1 True False + 4 + 4 vertical @@ -180,14 +194,12 @@ True False - 18 - 18 18 0 6 - 50 + 160 True False Device @@ -238,6 +250,7 @@ Gives your keys back their original function True Help end + center about-icon True @@ -255,20 +268,6 @@ Gives your keys back their original function 1 - - - True - False - - - - False - True - 2 - - @@ -280,105 +279,42 @@ Gives your keys back their original function - + + True + False + + + False + True + 1 + + + + True False + 4 + 4 + 18 + 18 + 4 - + + True False - vertical + 4 + 4 + True - + True False - 18 - vertical - 6 - + True - False - 12 - 6 - True - - - Apply - 80 - True - True - True - Start injecting. Don't hold down any keys while the injection starts - check-icon - none - True - - - - True - True - 0 - - - - - Copy - 80 - True - True - True - Duplicate this preset - copy-icon - none - True - - - - True - True - 1 - - - - - New - 80 - True - True - True - Create a new preset - new-icon - none - True - - - - - True - True - 2 - - - - - Delete - 80 - True - True - True - Delete this preset - delete-icon - none - True - - - - True - True - 4 - - + True + True @@ -387,38 +323,16 @@ Gives your keys back their original function - + True - False - 6 - - - 50 - True - False - Preset - 13 - 0 - - - False - True - 0 - - - - - 200 - True - False - - - - True - True - 1 - - + True + True + Save the entered name + 6 + 6 + save-icon + none + False @@ -426,350 +340,220 @@ Gives your keys back their original function 1 - - - True - False - 6 - - - True - False - Rename - 13 - 0 - - - False - True - 0 - - - - - True - False - - - True - True - - - - True - True - 0 - - - - - True - True - True - Save the entered name - 6 - save-icon - none - - - - False - True - 1 - - - - - True - True - 1 - - - - - False - True - 2 - - - - - True - False - To automatically apply the preset after your login or when it connects. - - - True - False - Autoload - 0 - - - True - True - 0 - - - - - True - True - - - - False - True - 1 - - - - - False - True - 3 - - - False - True - 0 + 1 + 1 - + + 200 True False + True + - False - True - 1 + 1 + 0 - + + 160 True False - 18 - vertical - 6 - - - True - False - 6 - - - True - False - Left joystick - 0 - - - True - True - 0 - - - - - 100 - True - False - - Mouse - Wheel - Buttons - Joystick - - - - - False - True - 1 - - - - - False - True - 0 - - - - - True - False - 6 - - - True - False - Right joystick - 0 - - - True - True - 0 - - - - - 100 - True - False - - Mouse - Wheel - Buttons - Joystick - - - - - False - True - 1 - - - - - False - True - 1 - - - - - True - False - 6 - - - True - False - Mouse speed - 13 - 0 - - - False - True - 0 - - - - - 0 - True - True - mouse_speed_adjustment - 1 - False - - - - - True - True - 1 - - - - - False - True - 2 - - + Rename + 13 + 0 - False - True - 2 + 0 + 1 - + + 50 True False - vertical + Preset + 13 + 0 + + + 0 + 0 + + + + + True + True + 0 + + + + + + True + False + 18 + 4 + 27 + + + Delete + True + True + True + Delete this preset + delete-icon + none + True + + + + 0 + 0 + + + + + Copy + True + True + True + Duplicate this preset + copy-icon + none + True + + + + 1 + 0 + + + + + New + True + True + True + Create a new preset + new-icon + none + True + + + + + 2 + 0 + + + + + Apply + True + True + True + Start injecting. Don't hold down any keys while the injection starts + check-icon + none + True + + + + 2 + 1 + + + + + True + True + start + center + + + + 1 + 1 + + + + + True + False + end + center + Autoload: + 0 + + + 0 + 1 + + + + + False + True + 1 + + + + + False + True + 4 + 3 + + + + + True + False + + + False + True + 4 + + + + + True + False + 4 + 4 + 18 + 4 + 18 + + + 160 + True + True + + + True + False + none - + True False + browse + - - False - True - 0 - - - - - True - False - - - False - 6 - 6 - dialog-warning - - - False - True - 0 - - - - - False - 6 - dialog-error - - - False - True - 1 - - - - - True - False - 7 - 7 - 6 - 6 - vertical - - - - True - True - 2 - - - - - False - True - 1 - - - False - True - end - 4 - @@ -790,122 +574,126 @@ Gives your keys back their original function - - 250 + + True False - vertical + True + True + 4 + 18 - + True False + True + True - - 200 + True True - + True - False - none - - - True - False - browse - - - + True + start + immediate + word + 10 + 10 + 10 + 10 + True + 2 + True + - False - True - 0 + Key or Macro + Key or Macro + + + + + + True + False + immediate + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Analog Axis + Analog Axis + 1 + + + 1 + 0 + + + + + 260 + True + False + vertical + 4 - + True False + center + editor-stack False True - 1 + 0 True False - 18 - 18 - 18 - 18 - vertical - 18 + 4 - + True False - 12 - True - - - Change Key - True - True - True - Record a button of your device that should be remapped - image1 - none - True - - - False - True - 0 - - - - - True - False - The type of device this mapping is emulating. - - - True - True - 1 - - - - - Delete - 80 - True - True - True - Delete this entry - icon-delete-row - none - True - - - - False - True - end - 2 - - + Target + 0 False @@ -914,28 +702,10 @@ Gives your keys back their original function - + True - True - - - True - True - start - immediate - word - 10 - 10 - 10 - 10 - True - 2 - True - - - + False + The type of device this mapping is emulating. True @@ -945,19 +715,163 @@ Gives your keys back their original function - True + False True 2 + + + Change Key + True + True + True + Record a button of your device that should be remapped + image2 + none + True + + + False + True + 2 + + + + + True + True + + + True + False + none + + + True + False + none + False + + + + + + + True + True + 4 + + - True - True - 0 + 0 + 0 + + + + + Delete Mapping + True + True + True + Delete this entry + icon-delete-row + none + True + + + + 1 + 1 + + + + + Add axis as button + True + True + True + image1 + none + + + 0 + 1 + + False + True + 5 + + + + + True + True + 4 + + + + + True + False + + + False + True + 6 + + + + + True + False + + + False + 6 + 6 + dialog-warning + + + False + True + 0 + + + + + False + 6 + 6 + dialog-error + + + False + True + 1 + + + + + True + False + 7 + 7 + 7 + 7 + 6 + 6 + vertical + + True True @@ -966,9 +880,9 @@ Gives your keys back their original function - True + False True - 2 + 7 diff --git a/data/style.css b/data/style.css index e38f5e5d..e3050b1f 100644 --- a/data/style.css +++ b/data/style.css @@ -78,4 +78,9 @@ list button { padding-left: 18px; } +.no-v-padding{ + padding-top: 0; + padding-bottom: 0; +} + /* @theme_bg_color, @theme_fg_color */ diff --git a/inputremapper/configs/base_config.py b/inputremapper/configs/base_config.py index abe1d5c1..1a651e2c 100644 --- a/inputremapper/configs/base_config.py +++ b/inputremapper/configs/base_config.py @@ -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, - }, - }, } diff --git a/inputremapper/configs/mapping.py b/inputremapper/configs/mapping.py new file mode 100644 index 00000000..f697bc24 --- /dev/null +++ b/inputremapper/configs/mapping.py @@ -0,0 +1,408 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . +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"] + ) diff --git a/inputremapper/configs/migrations.py b/inputremapper/configs/migrations.py index 39312ee9..1b1aeedf 100644 --- a/inputremapper/configs/migrations.py +++ b/inputremapper/configs/migrations.py @@ -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): diff --git a/inputremapper/configs/paths.py b/inputremapper/configs/paths.py index 9a9a69e4..efc5607a 100644 --- a/inputremapper/configs/paths.py +++ b/inputremapper/configs/paths.py @@ -18,6 +18,7 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . +# 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): diff --git a/inputremapper/configs/preset.py b/inputremapper/configs/preset.py index dff9a8ec..a5041e18 100644 --- a/inputremapper/configs/preset.py +++ b/inputremapper/configs/preset.py @@ -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) diff --git a/inputremapper/configs/system_mapping.py b/inputremapper/configs/system_mapping.py index 819f741a..759734e8 100644 --- a/inputremapper/configs/system_mapping.py +++ b/inputremapper/configs/system_mapping.py @@ -17,11 +17,8 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - - """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. diff --git a/inputremapper/daemon.py b/inputremapper/daemon.py index 5e090f0b..f22a0f25 100644 --- a/inputremapper/daemon.py +++ b/inputremapper/daemon.py @@ -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() diff --git a/inputremapper/event_combination.py b/inputremapper/event_combination.py index 23336051..9d21b8b8 100644 --- a/inputremapper/event_combination.py +++ b/inputremapper/event_combination.py @@ -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 diff --git a/inputremapper/exceptions.py b/inputremapper/exceptions.py index 179c9203..ef895e3c 100644 --- a/inputremapper/exceptions.py +++ b/inputremapper/exceptions.py @@ -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) diff --git a/inputremapper/groups.py b/inputremapper/groups.py index b3b432a5..356340e2 100644 --- a/inputremapper/groups.py +++ b/inputremapper/groups.py @@ -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 diff --git a/inputremapper/gui/active_preset.py b/inputremapper/gui/active_preset.py index d48d0f6e..6f2adc0a 100644 --- a/inputremapper/gui/active_preset.py +++ b/inputremapper/gui/active_preset.py @@ -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) diff --git a/inputremapper/gui/editor/editor.py b/inputremapper/gui/editor/editor.py index 40c5679d..a33b82a3 100644 --- a/inputremapper/gui/editor/editor.py +++ b/inputremapper/gui/editor/editor.py @@ -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 diff --git a/inputremapper/gui/reader.py b/inputremapper/gui/reader.py index 4bd29bc0..3a41a305 100644 --- a/inputremapper/gui/reader.py +++ b/inputremapper/gui/reader.py @@ -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.""" diff --git a/inputremapper/gui/user_interface.py b/inputremapper/gui/user_interface.py index 75e3bcc1..01a3d303 100644 --- a/inputremapper/gui/user_interface.py +++ b/inputremapper/gui/user_interface.py @@ -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.""" diff --git a/inputremapper/injection/consumer_control.py b/inputremapper/injection/consumer_control.py deleted file mode 100644 index 73a6c21d..00000000 --- a/inputremapper/injection/consumer_control.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2022 sezanzeb -# -# 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 . - - -"""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) diff --git a/inputremapper/injection/consumers/consumer.py b/inputremapper/injection/consumers/consumer.py deleted file mode 100644 index f8132e63..00000000 --- a/inputremapper/injection/consumers/consumer.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2022 sezanzeb -# -# 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 . - - -"""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 diff --git a/inputremapper/injection/consumers/joystick_to_mouse.py b/inputremapper/injection/consumers/joystick_to_mouse.py deleted file mode 100644 index a9521f71..00000000 --- a/inputremapper/injection/consumers/joystick_to_mouse.py +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2022 sezanzeb -# -# 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 . - - -"""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) diff --git a/inputremapper/injection/consumers/keycode_mapper.py b/inputremapper/injection/consumers/keycode_mapper.py deleted file mode 100644 index 201a2d9c..00000000 --- a/inputremapper/injection/consumers/keycode_mapper.py +++ /dev/null @@ -1,554 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2022 sezanzeb -# -# 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 . - - -"""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) diff --git a/inputremapper/injection/context.py b/inputremapper/injection/context.py index 39ba6379..e8a4d831 100644 --- a/inputremapper/injection/context.py +++ b/inputremapper/injection/context.py @@ -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) diff --git a/inputremapper/injection/event_reader.py b/inputremapper/injection/event_reader.py new file mode 100644 index 00000000..93ef99c1 --- /dev/null +++ b/inputremapper/injection/event_reader.py @@ -0,0 +1,169 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . + + +"""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) diff --git a/inputremapper/injection/global_uinputs.py b/inputremapper/injection/global_uinputs.py index 1c174a85..a9ee712c 100644 --- a/inputremapper/injection/global_uinputs.py +++ b/inputremapper/injection/global_uinputs.py @@ -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 }, } diff --git a/inputremapper/injection/injector.py b/inputremapper/injection/injector.py index 1486791e..97b9f87a 100644 --- a/inputremapper/injection/injector.py +++ b/inputremapper/injection/injector.py @@ -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()) diff --git a/inputremapper/injection/macros/macro.py b/inputremapper/injection/macros/macro.py index 148f1dba..b6df2dee 100644 --- a/inputremapper/injection/macros/macro.py +++ b/inputremapper/injection/macros/macro.py @@ -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) diff --git a/inputremapper/injection/macros/parse.py b/inputremapper/injection/macros/parse.py index 890549d6..0bd9f85d 100644 --- a/inputremapper/injection/macros/parse.py +++ b/inputremapper/injection/macros/parse.py @@ -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 diff --git a/inputremapper/injection/consumers/__init__.py b/inputremapper/injection/mapping_handlers/__init__.py similarity index 100% rename from inputremapper/injection/consumers/__init__.py rename to inputremapper/injection/mapping_handlers/__init__.py diff --git a/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py b/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py new file mode 100644 index 00000000..6fe95a2b --- /dev/null +++ b/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py @@ -0,0 +1,129 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . + + +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() diff --git a/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py b/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py new file mode 100644 index 00000000..cc3bb3f6 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py @@ -0,0 +1,220 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . +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 {} diff --git a/inputremapper/injection/mapping_handlers/axis_switch_handler.py b/inputremapper/injection/mapping_handlers/axis_switch_handler.py new file mode 100644 index 00000000..f8119c34 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/axis_switch_handler.py @@ -0,0 +1,141 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . +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} diff --git a/inputremapper/injection/mapping_handlers/axis_transform.py b/inputremapper/injection/mapping_handlers/axis_transform.py new file mode 100644 index 00000000..fd35a145 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/axis_transform.py @@ -0,0 +1,130 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . +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") diff --git a/inputremapper/injection/mapping_handlers/combination_handler.py b/inputremapper/injection/mapping_handlers/combination_handler.py new file mode 100644 index 00000000..9ccbd40e --- /dev/null +++ b/inputremapper/injection/mapping_handlers/combination_handler.py @@ -0,0 +1,160 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . +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 diff --git a/inputremapper/injection/mapping_handlers/hierarchy_handler.py b/inputremapper/injection/mapping_handlers/hierarchy_handler.py new file mode 100644 index 00000000..fe658870 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/hierarchy_handler.py @@ -0,0 +1,94 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . +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 diff --git a/inputremapper/injection/mapping_handlers/key_handler.py b/inputremapper/injection/mapping_handlers/key_handler.py new file mode 100644 index 00000000..575189e2 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/key_handler.py @@ -0,0 +1,92 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . + +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} diff --git a/inputremapper/injection/mapping_handlers/macro_handler.py b/inputremapper/injection/mapping_handlers/macro_handler.py new file mode 100644 index 00000000..5db9e4e4 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/macro_handler.py @@ -0,0 +1,101 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . +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} diff --git a/inputremapper/injection/mapping_handlers/mapping_handler.py b/inputremapper/injection/mapping_handlers/mapping_handler.py new file mode 100644 index 00000000..85759fae --- /dev/null +++ b/inputremapper/injection/mapping_handlers/mapping_handler.py @@ -0,0 +1,198 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . +"""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) diff --git a/inputremapper/injection/mapping_handlers/mapping_parser.py b/inputremapper/injection/mapping_handlers/mapping_parser.py new file mode 100644 index 00000000..765b2a7e --- /dev/null +++ b/inputremapper/injection/mapping_handlers/mapping_parser.py @@ -0,0 +1,320 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . +"""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) diff --git a/inputremapper/injection/mapping_handlers/null_handler.py b/inputremapper/injection/mapping_handlers/null_handler.py new file mode 100644 index 00000000..5981e8e5 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/null_handler.py @@ -0,0 +1,60 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . + +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 diff --git a/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py b/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py new file mode 100644 index 00000000..b5da4649 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py @@ -0,0 +1,132 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . + +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() diff --git a/inputremapper/input_event.py b/inputremapper/input_event.py index 1d2f413c..5b56dcc8 100644 --- a/inputremapper/input_event.py +++ b/inputremapper/input_event.py @@ -19,17 +19,35 @@ # along with input-remapper. If not, see . 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: diff --git a/inputremapper/logger.py b/inputremapper/logger.py index 1f9496b4..b598b8be 100644 --- a/inputremapper/logger.py +++ b/inputremapper/logger.py @@ -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)) diff --git a/inputremapper/user.py b/inputremapper/user.py index 22e6cf31..1b4e0582 100644 --- a/inputremapper/user.py +++ b/inputremapper/user.py @@ -66,5 +66,3 @@ def get_home(user): USER = get_user() HOME = get_home(USER) - -CONFIG_PATH = os.path.join(HOME, ".config/input-remapper") diff --git a/inputremapper/utils.py b/inputremapper/utils.py index 8957c0bd..cf6ecfc4 100644 --- a/inputremapper/utils.py +++ b/inputremapper/utils.py @@ -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 diff --git a/readme/development.md b/readme/development.md index 16ced91f..7af6785c 100644 --- a/readme/development.md +++ b/readme/development.md @@ -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 diff --git a/readme/usage.md b/readme/usage.md index c46794e4..00f39dd0 100644 --- a/readme/usage.md +++ b/readme/usage.md @@ -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. + +``` +: { + : , + : +} +``` +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** diff --git a/scripts/build.sh b/scripts/build.sh index 2ade8cf5..25f9bacb 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -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 & diff --git a/scripts/ci-install-deps.sh b/scripts/ci-install-deps.sh index e89efa76..6bb2a311 100755 --- a/scripts/ci-install-deps.sh +++ b/scripts/ci-install-deps.sh @@ -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 diff --git a/setup.py b/setup.py index 8f4cba7b..85c04f85 100644 --- a/setup.py +++ b/setup.py @@ -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", diff --git a/tests/integration/test_gui.py b/tests/integration/test_gui.py index 8e64df38..7d6b4099 100644 --- a/tests/integration/test_gui.py +++ b/tests/integration/test_gui.py @@ -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_") diff --git a/tests/test.py b/tests/test.py index 7d7c0558..71b6dec2 100644 --- a/tests/test.py +++ b/tests/test.py @@ -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: diff --git a/tests/unit/test_axis_transformation.py b/tests/unit/test_axis_transformation.py new file mode 100644 index 00000000..cdb24c0d --- /dev/null +++ b/tests/unit/test_axis_transformation.py @@ -0,0 +1,188 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . +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}", + ) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 58c3e7af..6abb4f90 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -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) diff --git a/tests/unit/test_consumer_control.py b/tests/unit/test_consumer_control.py deleted file mode 100644 index 27517e6a..00000000 --- a/tests/unit/test_consumer_control.py +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2022 sezanzeb -# -# 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 . - - -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), - ], - ) diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py index e23c39ab..a753b819 100644 --- a/tests/unit/test_context.py +++ b/tests/unit/test_context.py @@ -17,17 +17,22 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - - -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__": diff --git a/tests/unit/test_control.py b/tests/unit/test_control.py index a0d60326..5abb86a1 100644 --- a/tests/unit/test_control.py +++ b/tests/unit/test_control.py @@ -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() diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py index f057f4ee..4706f688 100644 --- a/tests/unit/test_daemon.py +++ b/tests/unit/test_daemon.py @@ -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")) diff --git a/tests/unit/test_dev_utils.py b/tests/unit/test_dev_utils.py index 37514f69..4e890239 100644 --- a/tests/unit/test_dev_utils.py +++ b/tests/unit/test_dev_utils.py @@ -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))) diff --git a/tests/unit/test_event_combination.py b/tests/unit/test_event_combination.py index 661c929d..11deb157 100644 --- a/tests/unit/test_event_combination.py +++ b/tests/unit/test_event_combination.py @@ -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") diff --git a/tests/unit/test_event_pipeline.py b/tests/unit/test_event_pipeline.py new file mode 100644 index 00000000..cbc4acf5 --- /dev/null +++ b/tests/unit/test_event_pipeline.py @@ -0,0 +1,1146 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . +import asyncio +import unittest +import time +from typing import List, Iterable + +import evdev +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + ABS_X, + ABS_Y, + REL_X, + REL_Y, + BTN_A, + REL_HWHEEL, + REL_WHEEL, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, + ABS_HAT0X, + BTN_LEFT, + BTN_B, + KEY_A, + ABS_HAT0Y, + KEY_B, + KEY_C, + BTN_TL, +) +from pydantic import ValidationError + +from inputremapper.logger import logger +from inputremapper.configs.mapping import Mapping +from inputremapper.injection.context import Context +from inputremapper.injection.event_reader import EventReader +from tests.test import ( + get_key_mapping, + uinput_write_history_pipe, + new_event, + push_events, + read_write_history_pipe, + InputDevice, + cleanup, + fixtures, + convert_to_internal_events, + EVENT_READ_TIMEOUT, + MAX_ABS, + MIN_ABS, +) + +from inputremapper.input_event import InputEvent +from inputremapper.event_combination import EventCombination +from inputremapper.configs.system_mapping import system_mapping +from inputremapper.configs.preset import Preset +from inputremapper.injection.global_uinputs import global_uinputs + + +class TestEventPipeline(unittest.IsolatedAsyncioTestCase): + """test the event pipeline form event_reader to UInput""" + + def setUp(self): + # print("in setup") + # global_uinputs.prepare_all() + self.forward_uinput = evdev.UInput() + self.stop_event = asyncio.Event() + + def tearDown(self) -> None: + cleanup() + + @staticmethod + async def send_events(events: Iterable[InputEvent], event_reader: EventReader): + for event in events: + logger.info("sending into event_pipeline: %s", event.event_tuple) + await event_reader.handle(event) + + def get_event_reader( + self, preset: Preset, source: evdev.InputDevice + ) -> EventReader: + context = Context(preset) + return EventReader(context, source, self.forward_uinput, self.stop_event) + + async def test_any_event_as_button(self): + """as long as there is an event handler and a mapping we should be able + to map anything to a button""" + + w_down = ( + EV_ABS, + ABS_Y, + -12345, + ) # value needs to be higher than 10% below center of axis (absinfo) + w_up = (EV_ABS, ABS_Y, 0) + + s_down = (EV_ABS, ABS_Y, 12345) + s_up = (EV_ABS, ABS_Y, 0) + + d_down = (EV_REL, REL_X, 100) + d_up = (EV_REL, REL_X, 0) + + a_down = (EV_REL, REL_X, -100) + a_up = (EV_REL, REL_X, 0) + + b_down = (EV_ABS, ABS_HAT0X, 1) + b_up = (EV_ABS, ABS_HAT0X, 0) + + c_down = (EV_ABS, ABS_HAT0X, -1) + c_up = (EV_ABS, ABS_HAT0X, 0) + + # first change the system mapping because Mapping will validate against it + system_mapping.clear() + code_w = 71 + code_b = 72 + code_c = 73 + code_d = 74 + code_a = 75 + code_s = 76 + system_mapping._set("w", code_w) + system_mapping._set("d", code_d) + system_mapping._set("a", code_a) + system_mapping._set("s", code_s) + system_mapping._set("b", code_b) + system_mapping._set("c", code_c) + + preset = Preset() + preset.add(get_key_mapping(EventCombination(b_down), "keyboard", "b")) + preset.add(get_key_mapping(EventCombination(c_down), "keyboard", "c")) + preset.add( + get_key_mapping(EventCombination([*w_down[:2], -10]), "keyboard", "w") + ) + preset.add( + get_key_mapping(EventCombination([*d_down[:2], 10]), "keyboard", "k(d)") + ) + preset.add( + get_key_mapping(EventCombination([*s_down[:2], 10]), "keyboard", "s") + ) + preset.add( + get_key_mapping(EventCombination([*a_down[:2], -10]), "keyboard", "a") + ) + + event_reader = self.get_event_reader( + preset, InputDevice("/dev/input/event30") + ) # gamepad fixture + + await self.send_events( + [ + InputEvent.from_tuple(b_down), + InputEvent.from_tuple(c_down), + InputEvent.from_tuple(w_down), + InputEvent.from_tuple(d_down), + InputEvent.from_tuple(s_down), + InputEvent.from_tuple(a_down), + InputEvent.from_tuple(b_up), + InputEvent.from_tuple(c_up), + InputEvent.from_tuple(w_up), + InputEvent.from_tuple(d_up), + InputEvent.from_tuple(s_up), + InputEvent.from_tuple(a_up), + ], + event_reader, + ) + await asyncio.sleep( + 0.1 + ) # wait a bit for the rel_to_btn handler to send the key up + + history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + + self.assertEqual(history.count((EV_KEY, code_b, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_c, 1)), 1) + 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_a, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_s, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_b, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_c, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_w, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_d, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_a, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_s, 0)), 1) + + async def test_reset_releases_keys(self): + """make sure that macros and keys are releases when the stop event is set""" + preset = Preset() + preset.add(get_key_mapping(combination="1,1,1", output_symbol="hold(a)")) + preset.add(get_key_mapping(combination="1,2,1", output_symbol="b")) + preset.add( + get_key_mapping(combination="1,3,1", output_symbol="modify(c,hold(d))") + ) + event_reader = self.get_event_reader(preset, InputDevice("/dev/input/event10")) + + a = system_mapping.get("a") + b = system_mapping.get("b") + c = system_mapping.get("c") + d = system_mapping.get("d") + + await self.send_events( + [ + InputEvent.from_tuple((1, 1, 1)), + InputEvent.from_tuple((1, 2, 1)), + InputEvent.from_tuple((1, 3, 1)), + ], + event_reader, + ) + await asyncio.sleep(0.1) + + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + + self.assertEqual(len(fw_history), 0) + # a down, b down, c down, d down + self.assertEqual(len(kb_history), 4) + + event_reader.context.reset() + await asyncio.sleep(0.1) + + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + + self.assertEqual(len(fw_history), 0) + # all a, b, c, d down+up + self.assertEqual(len(kb_history), 8) + kb_history = kb_history[-4:] + self.assertIn((1, a, 0), kb_history) + self.assertIn((1, b, 0), kb_history) + self.assertIn((1, c, 0), kb_history) + self.assertIn((1, d, 0), kb_history) + + async def test_abs_to_rel(self): + """map gamepad EV_ABS events to EV_REL events""" + + rate = 60 # rate [Hz] at which events are produced + gain = 0.5 # halve the speed of the rel axis + speed = 1 + preset = Preset() + # 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, + "rate": rate, + "gain": gain, + "deadzone": 0, + "rel_speed": speed, + } + m1 = Mapping(**cfg) + preset.add(m1) + # left y to mouse y + cfg["event_combination"] = ",".join((str(EV_ABS), str(ABS_Y), "0")) + cfg["output_code"] = REL_Y + m2 = Mapping(**cfg) + preset.add(m2) + + # set input axis to 100% in order to move + # speed*gain*rate=1*0.5*60 pixel per second + x = MAX_ABS + y = MAX_ABS + + event_reader = self.get_event_reader( + preset, InputDevice("/dev/input/event30") + ) # gamepad Fixture + + await self.send_events( + [ + InputEvent.from_tuple((EV_ABS, ABS_X, -x)), + InputEvent.from_tuple((EV_ABS, ABS_Y, -y)), + ], + event_reader, + ) + # wait a bit more for it to sum up + sleep = 0.5 + await asyncio.sleep(sleep) + # stop it + await self.send_events( + [ + InputEvent.from_tuple((EV_ABS, ABS_X, 0)), + InputEvent.from_tuple((EV_ABS, ABS_Y, 0)), + ], + event_reader, + ) + + # convert the write history to some easier to manage list + history = convert_to_internal_events( + global_uinputs.get_uinput("mouse").write_history + ) + + if history[0].type == EV_ABS: + raise AssertionError( + "The injector probably just forwarded them unchanged" + # possibly in addition to writing mouse events + ) + + # each axis writes speed*gain*rate*sleep=1*0.5*60 events + self.assertGreater(len(history), speed * gain * rate * sleep * 0.9 * 2) + self.assertLess(len(history), speed * gain * rate * sleep * 1.1 * 2) + + # 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) + + async def test_abs_to_wheel_hi_res_quirk(self): + """ + when mapping to wheel events we always expect to see both, + REL_WHEEL and REL_WHEEL_HI_RES events with a accumulative value ratio of 1/120 + """ + rate = 60 # rate [Hz] at which events are produced + gain = 1 + speed = 30 + preset = Preset() + # 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_WHEEL, + "rate": rate, + "gain": gain, + "deadzone": 0, + "rel_speed": speed, + } + m1 = Mapping(**cfg) + preset.add(m1) + # left y to mouse y + cfg["event_combination"] = ",".join((str(EV_ABS), str(ABS_Y), "0")) + cfg["output_code"] = REL_HWHEEL_HI_RES + m2 = Mapping(**cfg) + preset.add(m2) + + # set input axis to 100% in order to move + # speed*gain*rate=1*0.5*60 pixel per second + x = MAX_ABS + y = MAX_ABS + + event_reader = self.get_event_reader( + preset, InputDevice("/dev/input/event30") + ) # gamepad Fixture + + await self.send_events( + [ + InputEvent.from_tuple((EV_ABS, ABS_X, x)), + InputEvent.from_tuple((EV_ABS, ABS_Y, -y)), + ], + event_reader, + ) + # wait a bit more for it to sum up + sleep = 0.5 + await asyncio.sleep(sleep) + # stop it + await self.send_events( + [ + InputEvent.from_tuple((EV_ABS, ABS_X, 0)), + InputEvent.from_tuple((EV_ABS, ABS_Y, 0)), + ], + event_reader, + ) + m_history = convert_to_internal_events( + global_uinputs.get_uinput("mouse").write_history + ) + + rel_wheel = sum([event.value for event in m_history if event.code == REL_WHEEL]) + rel_wheel_hi_res = sum( + [event.value for event in m_history if event.code == REL_WHEEL_HI_RES] + ) + rel_hwheel = sum( + [event.value for event in m_history if event.code == REL_HWHEEL] + ) + rel_hwheel_hi_res = sum( + [event.value for event in m_history if event.code == REL_HWHEEL_HI_RES] + ) + + self.assertAlmostEqual(rel_wheel, rel_wheel_hi_res / 120, places=0) + self.assertAlmostEqual(rel_hwheel, rel_hwheel_hi_res / 120, places=0) + + async def test_forward_abs(self): + """test if EV_ABS events are forwarded when other events of the same input are not""" + preset = Preset() + # BTN_A -> 77 + system_mapping._set("b", 77) + preset.add(get_key_mapping(EventCombination([1, BTN_A, 1]), "keyboard", "b")) + event_reader = self.get_event_reader( + preset, InputDevice("/dev/input/event30") + ) # gamepad Fixture + + # should forward them unmodified + await self.send_events( + [ + InputEvent.from_tuple((EV_ABS, ABS_X, 10)), + InputEvent.from_tuple((EV_ABS, ABS_Y, 20)), + InputEvent.from_tuple((EV_ABS, ABS_X, -30)), + InputEvent.from_tuple((EV_ABS, ABS_Y, -40)), + # send them to keyboard 77 + InputEvent.from_tuple((EV_KEY, BTN_A, 1)), + InputEvent.from_tuple((EV_KEY, BTN_A, 0)), + ], + event_reader, + ) + + # convert the write history to some easier to manage list + history = convert_to_internal_events(self.forward_uinput.write_history) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + + self.assertEqual(history.count((EV_ABS, ABS_X, 10)), 1) + self.assertEqual(history.count((EV_ABS, ABS_Y, 20)), 1) + self.assertEqual(history.count((EV_ABS, ABS_X, -30)), 1) + self.assertEqual(history.count((EV_ABS, ABS_Y, -40)), 1) + self.assertEqual(kb_history.count((EV_KEY, 77, 1)), 1) + self.assertEqual(kb_history.count((EV_KEY, 77, 0)), 1) + + async def test_forward_rel(self): + """test if EV_REL events are forwarded when other events of the same input are not""" + preset = Preset() + # BTN_A -> 77 + system_mapping._set("b", 77) + preset.add(get_key_mapping(EventCombination([1, BTN_LEFT, 1]), "keyboard", "b")) + event_reader = self.get_event_reader( + preset, InputDevice("/dev/input/event11") + ) # gamepad Fixture + + # should forward them unmodified + await self.send_events( + [ + InputEvent.from_tuple((EV_REL, REL_X, 10)), + InputEvent.from_tuple((EV_REL, REL_Y, 20)), + InputEvent.from_tuple((EV_REL, REL_X, -30)), + InputEvent.from_tuple((EV_REL, REL_Y, -40)), + # send them to keyboard 77 + InputEvent.from_tuple((EV_KEY, BTN_LEFT, 1)), + InputEvent.from_tuple((EV_KEY, BTN_LEFT, 0)), + ], + event_reader, + ) + await asyncio.sleep(0.1) + + # convert the write history to some easier to manage list + history = convert_to_internal_events(self.forward_uinput.write_history) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + + self.assertEqual(history.count((EV_REL, REL_X, 10)), 1) + self.assertEqual(history.count((EV_REL, REL_Y, 20)), 1) + self.assertEqual(history.count((EV_REL, REL_X, -30)), 1) + self.assertEqual(history.count((EV_REL, REL_Y, -40)), 1) + self.assertEqual(kb_history.count((EV_KEY, 77, 1)), 1) + self.assertEqual(kb_history.count((EV_KEY, 77, 0)), 1) + + async def test_rel_to_btn(self): + """rel axis mapped to buttons are automatically released if no new rel event arrives""" + + # 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) + + system_mapping.clear() + code_b = 91 + code_c = 92 + system_mapping._set("b", code_b) + system_mapping._set("c", code_c) + + # set a high release timeout to make sure the tests pass + release_timeout = 0.2 + preset = Preset() + m1 = get_key_mapping(EventCombination(hw_right), "keyboard", "k(b)") + m2 = get_key_mapping(EventCombination(w_up), "keyboard", "c") + m1.release_timeout = release_timeout + m2.release_timeout = release_timeout + preset.add(m1) + preset.add(m2) + + device = InputDevice("/dev/input/event11") + event_reader = self.get_event_reader(preset, device) + + # 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]) + + await self.send_events( + [InputEvent.from_tuple(hw_right), InputEvent.from_tuple(w_up)] * 5, + event_reader, + ) + # wait less than the release timeout and send more events + await asyncio.sleep(release_timeout / 5) + await self.send_events( + [InputEvent.from_tuple(hw_right), InputEvent.from_tuple(w_up)] * 5 + + [InputEvent.from_tuple(hw_left)] + * 3, # one event will release hw_right, the others are forwarded + event_reader, + ) + # wait more than the release_timeout to make sure all handlers finish + await asyncio.sleep(release_timeout * 1.2) + + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + self.assertEqual(kb_history.count((EV_KEY, code_b, 1)), 1) + self.assertEqual(kb_history.count((EV_KEY, code_c, 1)), 1) + self.assertEqual(kb_history.count((EV_KEY, code_b, 0)), 1) + self.assertEqual(kb_history.count((EV_KEY, code_c, 0)), 1) + self.assertEqual(fw_history.count(hw_left), 2) # the unmapped wheel direction + + # the unmapped wheel won't get a debounced release command, it's + # forwarded as is + self.assertNotIn((EV_REL, REL_HWHEEL, 0), fw_history) + + async def test_abs_trigger_threshold(self): + """Test that different activation points for abs_to_btn work correctly""" + + m1 = get_key_mapping( + EventCombination((EV_ABS, ABS_X, 30)), output_symbol="a" + ) # at 30% map to a + m2 = get_key_mapping( + EventCombination((EV_ABS, ABS_X, 70)), output_symbol="b" + ) # at 70% map to b + preset = Preset() + preset.add(m1) + preset.add(m2) + + a = system_mapping.get("a") + b = system_mapping.get("b") + + event_reader = self.get_event_reader(preset, InputDevice("/dev/input/event30")) + + await self.send_events( + [ + InputEvent.from_tuple( + (EV_ABS, ABS_X, MIN_ABS // 10) + ), # -10%, do nothing + InputEvent.from_tuple((EV_ABS, ABS_X, 0)), # 0%, do noting + InputEvent.from_tuple( + (EV_ABS, ABS_X, MAX_ABS // 10) + ), # 10%, do nothing + InputEvent.from_tuple((EV_ABS, ABS_X, MAX_ABS // 2)), # 50%, trigger a + ], + event_reader, + ) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + + self.assertEqual(kb_history.count((EV_KEY, a, 1)), 1) + self.assertNotIn((EV_KEY, a, 0), kb_history) + self.assertNotIn((EV_KEY, b, 1), kb_history) + + await self.send_events( + [ + InputEvent.from_tuple( + (EV_ABS, ABS_X, int(MAX_ABS * 0.8)) + ), # 80%, trigger b + InputEvent.from_tuple((EV_ABS, ABS_X, MAX_ABS // 2)), # 50%, release b + ], + event_reader, + ) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + self.assertEqual(kb_history.count((EV_KEY, a, 1)), 1) + self.assertEqual(kb_history.count((EV_KEY, b, 1)), 1) + self.assertEqual(kb_history.count((EV_KEY, b, 0)), 1) + self.assertNotIn((EV_KEY, a, 0), kb_history) + + await event_reader.handle( + InputEvent.from_tuple((EV_ABS, ABS_X, 0)) + ) # 0% release a + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + self.assertEqual(kb_history.count((EV_KEY, a, 0)), 1) + self.assertEqual(len(fw_history), 0) + + async def test_rel_trigger_threshold(self): + """Test that different activation points for rel_to_btn work correctly""" + + m1 = get_key_mapping( + EventCombination((EV_REL, REL_X, 5)), output_symbol="a" + ) # at 30% map to a + m2 = get_key_mapping( + EventCombination((EV_REL, REL_X, 15)), output_symbol="b" + ) # at 70% map to b + release_timeout = 0.2 # give some time to do assertions before the release + m1.release_timeout = release_timeout + m2.release_timeout = release_timeout + preset = Preset() + preset.add(m1) + preset.add(m2) + + a = system_mapping.get("a") + b = system_mapping.get("b") + + event_reader = self.get_event_reader(preset, InputDevice("/dev/input/event11")) + + await self.send_events( + [ + InputEvent.from_tuple((EV_REL, REL_X, -5)), # forward + InputEvent.from_tuple((EV_REL, REL_X, 0)), # forward + InputEvent.from_tuple((EV_REL, REL_X, 3)), # forward + InputEvent.from_tuple((EV_REL, REL_X, 10)), # trigger a + ], + event_reader, + ) + await asyncio.sleep(release_timeout * 1.5) # release a + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + + self.assertEqual(kb_history.count((EV_KEY, a, 1)), 1) + self.assertEqual(kb_history.count((EV_KEY, a, 0)), 1) + self.assertNotIn((EV_KEY, b, 1), kb_history) + + await self.send_events( + [ + InputEvent.from_tuple((EV_REL, REL_X, 10)), # trigger a + InputEvent.from_tuple((EV_REL, REL_X, 20)), # trigger b + InputEvent.from_tuple((EV_REL, REL_X, 10)), # release b + ], + event_reader, + ) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + self.assertEqual(kb_history.count((EV_KEY, a, 1)), 2) + self.assertEqual(kb_history.count((EV_KEY, b, 1)), 1) + self.assertEqual(kb_history.count((EV_KEY, b, 0)), 1) + self.assertEqual(kb_history.count((EV_KEY, a, 0)), 1) + + await asyncio.sleep(release_timeout * 1.5) # release a + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + self.assertEqual(kb_history.count((EV_KEY, a, 0)), 2) + self.assertEqual( + fw_history, [(EV_REL, REL_X, -5), (EV_REL, REL_X, 0), (EV_REL, REL_X, 3)] + ) + + async def test_combination(self): + """test if combinations map to keys properly""" + + a = system_mapping.get("a") + b = system_mapping.get("b") + c = system_mapping.get("c") + + preset = Preset() + m1 = get_key_mapping(EventCombination((EV_ABS, ABS_X, 1)), output_symbol="a") + m2 = get_key_mapping( + EventCombination(((EV_ABS, ABS_X, 1), (EV_KEY, BTN_A, 1))), + output_symbol="b", + ) + m3 = get_key_mapping( + EventCombination( + ((EV_ABS, ABS_X, 1), (EV_KEY, BTN_A, 1), (EV_KEY, BTN_B, 1)) + ), + output_symbol="c", + ) + + preset.add(m1) + preset.add(m2) + preset.add(m3) + event_reader = self.get_event_reader(preset, InputDevice("/dev/input/event30")) + + await self.send_events( + [ + InputEvent.from_tuple((EV_KEY, BTN_A, 1)), # forwarded + InputEvent.from_tuple( + (EV_ABS, ABS_X, 1234) + ), # triggers b, releases BTN_A, ABS_X + InputEvent.from_tuple( + (EV_KEY, BTN_B, 1) + ), # triggers c, releases BTN_A, ABS_X, BTN_B + ], + event_reader, + ) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + + self.assertNotIn((1, a, 1), kb_history) + self.assertEqual(kb_history.count((1, c, 1)), 1) + self.assertEqual(kb_history.count((1, b, 1)), 1) + + self.assertEqual(fw_history.count((EV_KEY, BTN_A, 1)), 1) + self.assertIn((EV_KEY, BTN_A, 0), fw_history) + self.assertNotIn((EV_ABS, ABS_X, 1234), fw_history) + self.assertNotIn((EV_KEY, BTN_B, 1), fw_history) + + await self.send_events( + [InputEvent.from_tuple((EV_ABS, ABS_X, 0))], event_reader + ) # release b and c) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + self.assertNotIn((1, a, 1), kb_history) + self.assertNotIn((1, a, 0), kb_history) + self.assertEqual(kb_history.count((1, c, 0)), 1) + self.assertEqual(kb_history.count((1, b, 0)), 1) + + async def test_ignore_hold(self): + # hold as in event-value 2, not in macro-hold. + # linux will generate events with value 2 after input-remapper injected + # the key-press, so input-remapper doesn't need to forward them. That + # would cause duplicate events of those values otherwise. + key = (EV_KEY, KEY_A) + ev_1 = (*key, 1) + ev_2 = (*key, 2) + ev_3 = (*key, 0) + + preset = Preset() + preset.add(get_key_mapping(EventCombination(ev_1), output_symbol="a")) + a = system_mapping.get("a") + + event_reader = self.get_event_reader(preset, InputDevice("/dev/input/event30")) + await self.send_events( + [ + InputEvent.from_tuple(ev_1), + InputEvent.from_tuple(ev_2), + InputEvent.from_tuple(ev_3), + ], + event_reader, + ) + + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + self.assertEqual(len(kb_history), 2) + self.assertEqual(len(fw_history), 0) + self.assertNotIn((1, a, 2), kb_history) + + async def test_ignore_disabled(self): + ev_1 = (EV_ABS, ABS_HAT0Y, 1) + ev_2 = (EV_ABS, ABS_HAT0Y, 0) + + ev_3 = (EV_ABS, ABS_HAT0X, 1) # disabled + ev_4 = (EV_ABS, ABS_HAT0X, 0) + + ev_5 = (EV_KEY, KEY_A, 1) + ev_6 = (EV_KEY, KEY_A, 0) + + combi_1 = EventCombination((ev_5, ev_3)) + combi_2 = EventCombination((ev_3, ev_5)) + + preset = Preset() + preset.add(get_key_mapping(EventCombination(ev_1), output_symbol="a")) + preset.add(get_key_mapping(EventCombination(ev_3), output_symbol="disable")) + preset.add(get_key_mapping(combi_1, output_symbol="b")) + preset.add(get_key_mapping(combi_2, output_symbol="c")) + + a = system_mapping.get("a") + b = system_mapping.get("b") + c = system_mapping.get("c") + + event_reader = self.get_event_reader(preset, InputDevice("/dev/input/event30")) + + """single keys""" + await self.send_events( + [ + InputEvent.from_tuple(ev_1), # press a + InputEvent.from_tuple(ev_3), # disabled + InputEvent.from_tuple(ev_2), # release a + InputEvent.from_tuple(ev_4), # disabled + ], + event_reader, + ) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + self.assertIn((1, a, 1), kb_history) + self.assertIn((1, a, 0), kb_history) + self.assertEqual(len(kb_history), 2) + self.assertEqual(len(fw_history), 0) + + """a combination that ends in a disabled key""" + # ev_5 should be forwarded and the combination triggered + await self.send_events(combi_1, event_reader) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + self.assertIn((1, b, 1), kb_history) + self.assertEqual(len(kb_history), 3) + self.assertEqual(fw_history.count(ev_3), 0) + self.assertEqual(fw_history.count(ev_5), 1) + self.assertTrue(fw_history.count((*ev_5[0:2], 0)) >= 1) + + # release what the combination maps to + await self.send_events( + [ + InputEvent.from_tuple((*ev_3[0:2], 0)), + InputEvent.from_tuple((*ev_5[0:2], 0)), + ], + event_reader, + ) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + self.assertIn((1, b, 0), kb_history) + self.assertEqual(len(kb_history), 4) + self.assertEqual(fw_history.count(ev_3), 0) + self.assertTrue(fw_history.count((*ev_5[0:2], 0)) >= 1) + + """a combination that starts with a disabled key""" + # only the combination should get triggered + await self.send_events(combi_2, event_reader) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + self.assertIn((1, c, 1), kb_history) + self.assertEqual(len(kb_history), 5) + self.assertEqual(fw_history.count(ev_3), 0) + self.assertEqual(fw_history.count(ev_5), 1) + self.assertTrue(fw_history.count((*ev_5[0:2], 0)) >= 1) + + # release what the combination maps to + await self.send_events( + [ + InputEvent.from_tuple((*ev_3[0:2], 0)), + InputEvent.from_tuple((*ev_5[0:2], 0)), + ], + event_reader, + ) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + for event in kb_history: + print(event.event_tuple) + self.assertIn((1, c, 0), kb_history) + self.assertEqual(len(kb_history), 6) + self.assertEqual(fw_history.count(ev_3), 0) + self.assertTrue(fw_history.count((*ev_5[0:2], 0)) >= 1) + + async def test_combination_keycode_macro_mix(self): + """ev_1 triggers macro, ev_1 + ev_2 triggers key while the macro is + still running""" + + down_1 = (EV_ABS, ABS_HAT0X, 1) + down_2 = (EV_ABS, ABS_HAT0Y, -1) + up_1 = (EV_ABS, ABS_HAT0X, 0) + up_2 = (EV_ABS, ABS_HAT0Y, 0) + + a = system_mapping.get("a") + b = system_mapping.get("b") + + preset = Preset() + preset.add(get_key_mapping(EventCombination(down_1), output_symbol="h(k(a))")) + preset.add( + get_key_mapping(EventCombination((down_1, down_2)), output_symbol="b") + ) + + event_reader = self.get_event_reader(preset, InputDevice("/dev/input/event30")) + # macro starts + await self.send_events([InputEvent.from_tuple(down_1)], event_reader) + await asyncio.sleep(0.05) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + self.assertEqual(len(fw_history), 0) + self.assertGreater(len(kb_history), 1) + self.assertNotIn((1, b, 1), kb_history) + self.assertIn((1, a, 1), kb_history) + self.assertIn((1, a, 0), kb_history) + + # combination triggered + await self.send_events([InputEvent.from_tuple(down_2)], event_reader) + await asyncio.sleep(0) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + self.assertEqual(kb_history[-1], (EV_KEY, b, 1)) + + len_a = len(global_uinputs.get_uinput("keyboard").write_history) + await asyncio.sleep(0.05) + len_b = len(global_uinputs.get_uinput("keyboard").write_history) + # still running + self.assertGreater(len_b, len_a) + + # release + await self.send_events([InputEvent.from_tuple(up_1)], event_reader) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + self.assertEqual(kb_history[-1], (EV_KEY, b, 0)) + await asyncio.sleep(0.05) + len_c = len(global_uinputs.get_uinput("keyboard").write_history) + await asyncio.sleep(0.05) + len_d = len(global_uinputs.get_uinput("keyboard").write_history) + # not running anymore + self.assertEqual(len_c, len_d) + + await self.send_events([InputEvent.from_tuple(up_2)], event_reader) + await asyncio.sleep(0.05) + len_e = len(global_uinputs.get_uinput("keyboard").write_history) + self.assertEqual(len_e, len_d) + + async def test_wheel_combination_release_failure(self): + # test based on a bug that once occurred + # 1 | 22.6698, ((1, 276, 1)) -------------- forwarding + # 2 | 22.9904, ((1, 276, 1), (2, 8, -1)) -- maps to 30 + # 3 | 23.0103, ((1, 276, 1), (2, 8, -1)) -- duplicate key down + # 4 | ... 34 more duplicate key downs (scrolling) + # 5 | 23.7104, ((1, 276, 1), (2, 8, -1)) -- duplicate key down + # 6 | 23.7283, ((1, 276, 0)) -------------- forwarding release + # 7 | 23.7303, ((2, 8, -1)) --------------- forwarding + # 8 | 23.7865, ((2, 8, 0)) ---------------- not forwarding release + # line 7 should have been "duplicate key down" as well + # line 8 should have released 30, instead it was never released + # + # Note: the test was modified for the new Event pipeline: + # line 6 now releases the combination + # line 7 get forwarded + # line 8 get forwarded + + scroll = InputEvent.from_tuple((2, 8, -1)) + scroll_release = InputEvent.from_tuple((2, 8, 0)) + btn_down = InputEvent.from_tuple((1, 276, 1)) + btn_up = InputEvent.from_tuple((1, 276, 0)) + combination = EventCombination(((1, 276, 1), (2, 8, -1))) + + system_mapping.clear() + system_mapping._set("a", 30) + a = 30 + + preset = Preset() + m = get_key_mapping(combination, output_symbol="a") + m.release_timeout = 0.1 # a higher release timeout to give time for assertions + preset.add(m) + + event_reader = self.get_event_reader(preset, InputDevice("/dev/input/event11")) + + await self.send_events([btn_down], event_reader) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + self.assertEqual(fw_history[0], btn_down) + + await self.send_events([scroll], event_reader) + # "maps to 30" + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + self.assertEqual(kb_history[0], (1, a, 1)) + + await self.send_events([scroll] * 5, event_reader) + + # nothing new since all of them were duplicate key downs + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + self.assertEqual(len(kb_history), 1) + + await self.send_events([btn_up], event_reader) + # releasing the combination + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + self.assertEqual(kb_history[1], (1, a, 0)) + + # more scroll events + # it should be ignored as duplicate key-down + await self.send_events([scroll] * 5, event_reader) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + self.assertEqual(fw_history.count(scroll), 5) + + await self.send_events([scroll_release], event_reader) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + self.assertEqual(fw_history[-1], scroll_release) + + async def test_can_not_map(self): + """inject events to wrong or invalid uinput""" + ev_1 = (EV_KEY, KEY_A, 1) + ev_2 = (EV_KEY, KEY_B, 1) + ev_3 = (EV_KEY, KEY_C, 1) + + ev_4 = (EV_KEY, KEY_A, 0) + ev_5 = (EV_KEY, KEY_B, 0) + ev_6 = (EV_KEY, KEY_C, 0) + + preset = Preset() + m1 = Mapping( + event_combination=EventCombination(ev_2), + target_uinput="keyboard", + output_type=EV_KEY, + output_code=BTN_TL, + ) + m2 = Mapping( + event_combination=EventCombination(ev_3), + target_uinput="keyboard", + output_type=EV_KEY, + output_code=KEY_A, + ) + preset.add(m1) + preset.add(m2) + + event_reader = self.get_event_reader(preset, InputDevice("/dev/input/event11")) + # send key-down and up + await self.send_events( + [ + InputEvent.from_tuple(ev_1), + InputEvent.from_tuple(ev_2), + InputEvent.from_tuple(ev_3), + InputEvent.from_tuple(ev_4), + InputEvent.from_tuple(ev_5), + InputEvent.from_tuple(ev_6), + ], + event_reader, + ) + + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + + self.assertEqual(len(fw_history), 4) + self.assertEqual(len(kb_history), 2) + self.assertIn(ev_1, fw_history) + self.assertIn(ev_2, fw_history) + self.assertIn(ev_4, fw_history) + self.assertIn(ev_5, fw_history) + self.assertNotIn(ev_3, fw_history) + self.assertNotIn(ev_6, fw_history) + + self.assertIn((EV_KEY, KEY_A, 1), kb_history) + self.assertIn((EV_KEY, KEY_A, 0), kb_history) + + async def test_switch_axis(self): + """test a mapping for an axis that can be switched on or off""" + + rate = 60 # rate [Hz] at which events are produced + gain = 0.5 # halve the speed of the rel axis + speed = 1 + preset = Preset() + + # left x to mouse x if left y is above 10% + combination = EventCombination(((EV_ABS, ABS_X, 0), (EV_ABS, ABS_Y, 10))) + cfg = { + "event_combination": combination.json_str(), + "target_uinput": "mouse", + "output_type": EV_REL, + "output_code": REL_X, + "rate": rate, + "gain": gain, + "deadzone": 0, + "rel_speed": speed, + } + m1 = Mapping(**cfg) + preset.add(m1) + + # set input x-axis to 100% + x = MAX_ABS + event_reader = self.get_event_reader( + preset, InputDevice("/dev/input/event30") + ) # gamepad Fixture + + await event_reader.handle(InputEvent.from_tuple((EV_ABS, ABS_X, x))) + await asyncio.sleep(0.2) # wait a bit more for nothing to sum up + m_history = convert_to_internal_events( + global_uinputs.get_uinput("mouse").write_history + ) + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + self.assertEqual(len(m_history), 0) + self.assertEqual(len(fw_history), 1) + self.assertEqual(fw_history[0], (EV_ABS, ABS_X, x)) + + # move the y-Axis above 10% + await self.send_events( + ( + InputEvent.from_tuple((EV_ABS, ABS_Y, x * 0.05)), + InputEvent.from_tuple((EV_ABS, ABS_Y, x * 0.11)), + InputEvent.from_tuple((EV_ABS, ABS_Y, x * 0.5)), + ), + event_reader, + ) + # wait a bit more for it to sum up + sleep = 0.5 + await asyncio.sleep(sleep) + # send some more x events + await self.send_events( + ( + InputEvent.from_tuple((EV_ABS, ABS_X, x)), + InputEvent.from_tuple((EV_ABS, ABS_X, x * 0.9)), + ), + event_reader, + ) + # stop it + await event_reader.handle( + InputEvent.from_tuple((EV_ABS, ABS_Y, MAX_ABS * 0.05)) + ) + + await asyncio.sleep(0.2) # wait a bit more for nothing to sum up + history = convert_to_internal_events( + global_uinputs.get_uinput("mouse").write_history + ) + if history[0].type == EV_ABS: + raise AssertionError( + "The injector probably just forwarded them unchanged" + # possibly in addition to writing mouse events + ) + + # each axis writes speed*gain*rate*sleep=1*0.5*60 events + self.assertGreater(len(history), speed * gain * rate * sleep * 0.9) + self.assertLess(len(history), speed * gain * rate * sleep * 1.1) + + # does not contain anything else + count_x = history.count((EV_REL, REL_X, 1)) + self.assertEqual(len(history), count_x) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_producer.py b/tests/unit/test_event_producer.py deleted file mode 100644 index c04198ca..00000000 --- a/tests/unit/test_event_producer.py +++ /dev/null @@ -1,216 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2022 sezanzeb -# -# 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 . - - -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() diff --git a/tests/unit/test_event_reader.py b/tests/unit/test_event_reader.py new file mode 100644 index 00000000..a18ae58f --- /dev/null +++ b/tests/unit/test_event_reader.py @@ -0,0 +1,177 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . + +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), + ], + ) diff --git a/tests/unit/test_injector.py b/tests/unit/test_injector.py index 1f960cbe..495548d5 100644 --- a/tests/unit/test_injector.py +++ b/tests/unit/test_injector.py @@ -17,8 +17,9 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . +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], diff --git a/tests/unit/test_keycode_mapper.py b/tests/unit/test_keycode_mapper.py deleted file mode 100644 index 4b9ae577..00000000 --- a/tests/unit/test_keycode_mapper.py +++ /dev/null @@ -1,1343 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2022 sezanzeb -# -# 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 . - - -from tests.test import ( - new_event, - UInput, - uinput_write_history, - quick_cleanup, - InputDevice, - MAX_ABS, - MIN_ABS, -) - -import unittest -import asyncio -import time - -from evdev.ecodes import ( - EV_KEY, - EV_ABS, - KEY_A, - KEY_B, - KEY_C, - BTN_TL, - ABS_HAT0X, - ABS_HAT0Y, - ABS_HAT1X, - ABS_HAT1Y, - ABS_Y, -) -from inputremapper.event_combination import EventCombination - -from inputremapper.injection.consumers.keycode_mapper import ( - active_macros, - KeycodeMapper, - unreleased, - subsets, -) -from inputremapper.configs.system_mapping import system_mapping -from inputremapper.injection.macros.parse import parse -from inputremapper.injection.context import Context -from inputremapper.utils import RELEASE, PRESS -from inputremapper.configs.global_config import global_config, BUTTONS -from inputremapper.configs.preset import Preset -from inputremapper.configs.system_mapping import DISABLE_CODE -from inputremapper.injection.global_uinputs import global_uinputs - - -def wait(func, timeout=1.0): - """Wait for func to return True.""" - iterations = 0 - sleepytime = 0.1 - while not func(): - time.sleep(sleepytime) - iterations += 1 - if iterations * sleepytime > timeout: - raise Exception("Timeout") - - -def calculate_event_number(holdtime, before, after): - """ - Parameters - ---------- - holdtime : int - in ms, how long was the key held down - before : int - how many extra k() calls are executed before h() - after : int - how many extra k() calls are executed after h() - """ - keystroke_sleep = global_config.get("macros.keystroke_sleep_ms", 10) - # down and up: two sleeps per k - # one initial k(a): - events = before * 2 - holdtime -= keystroke_sleep * 2 - # hold events - events += (holdtime / (keystroke_sleep * 2)) * 2 - # one trailing k(c) - events += after * 2 - return events - - -class TestKeycodeMapper(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.mapping = Preset() - self.context = Context(self.mapping) - self.source = InputDevice("/dev/input/event11") - self.history = [] - - def tearDown(self): - # make sure all macros are stopped by tests - self.history = [] - - for macro in active_macros.values(): - if macro.is_holding(): - macro.release_trigger() - asyncio.get_event_loop().run_until_complete(asyncio.sleep(0.01)) - self.assertFalse(macro.is_holding()) - self.assertFalse(macro.running) - - quick_cleanup() - - def setup_keycode_mapper(self, keycodes, macro_mapping): - """Setup a default keycode mapper than can be used for most tests.""" - system_mapping.clear() - for key, code in keycodes.items(): - system_mapping._set(key, code) - - # parse requires an intact system_mapping! - self.context.macros = { - key: (parse(code, self.context), "keyboard") - for key, code in macro_mapping.items() - } - - uinput = UInput() - self.context.uinput = uinput - - keycode_mapper = KeycodeMapper(self.context, self.source, UInput()) - keycode_mapper.macro_write = self.create_handler - - return keycode_mapper - - def create_handler(self, _): - # to reduce the likelihood of race conditions of macros that for some reason - # are still running after the test, make sure they don't access the history - # of the next test. - history = self.history - return lambda *args: history.append(args) - - async def test_subsets(self): - a = subsets(((1,), (2,), (3,))) - self.assertIn(((1,), (2,)), a) - self.assertIn(((2,), (3,)), a) - self.assertIn(((1,), (3,)), a) - self.assertIn(((1,), (2,), (3,)), a) - self.assertEqual(len(a), 4) - - b = subsets(((1,), (2,))) - self.assertIn(((1,), (2,)), b) - self.assertEqual(len(b), 1) - - c = subsets(((1,),)) - self.assertEqual(len(c), 0) - - async def test_d_pad(self): - ev_1 = (EV_ABS, ABS_HAT0X, 1) - ev_2 = (EV_ABS, ABS_HAT0X, -1) - ev_3 = (EV_ABS, ABS_HAT0X, 0) - - ev_4 = (EV_ABS, ABS_HAT0Y, 1) - ev_5 = (EV_ABS, ABS_HAT0Y, -1) - ev_6 = (EV_ABS, ABS_HAT0Y, 0) - - uinput = UInput() - self.context.uinput = uinput - self.context.key_to_code = { - EventCombination(ev_1): (51, "keyboard"), - EventCombination(ev_2): (52, "keyboard"), - EventCombination(ev_4): (54, "keyboard"), - EventCombination(ev_5): (55, "keyboard"), - } - - keycode_mapper = KeycodeMapper(self.context, self.source, uinput) - - # a bunch of d-pad key down events at once - await keycode_mapper.notify(new_event(*ev_1)) - await keycode_mapper.notify(new_event(*ev_4)) - self.assertEqual(len(unreleased), 2) - - self.assertEqual( - unreleased.get(ev_1[:2]).target, - (EV_KEY, *self.context.key_to_code[(ev_1,)]), - ) - self.assertEqual(unreleased.get(ev_1[:2]).input_event_tuple, ev_1) - self.assertEqual( - unreleased.get(ev_1[:2]).triggered_key, (ev_1,) - ) # as seen in key_to_code - - self.assertEqual( - unreleased.get(ev_4[:2]).target, - (EV_KEY, *self.context.key_to_code[(ev_4,)]), - ev_4, - ) - self.assertEqual(unreleased.get(ev_4[:2]).input_event_tuple, ev_4) - self.assertEqual(unreleased.get(ev_4[:2]).triggered_key, (ev_4,)) - - # release all of them - await keycode_mapper.notify(new_event(*ev_3)) - await keycode_mapper.notify(new_event(*ev_6)) - self.assertEqual(len(unreleased), 0) - - # repeat with other values - await keycode_mapper.notify(new_event(*ev_2)) - await keycode_mapper.notify(new_event(*ev_5)) - self.assertEqual(len(unreleased), 2) - self.assertEqual( - unreleased.get(ev_2[:2]).target, - (EV_KEY, *self.context.key_to_code[(ev_2,)]), - ) - self.assertEqual(unreleased.get(ev_2[:2]).input_event_tuple, ev_2) - self.assertEqual( - unreleased.get(ev_5[:2]).target, - (EV_KEY, *self.context.key_to_code[(ev_5,)]), - ) - self.assertEqual(unreleased.get(ev_5[:2]).input_event_tuple, ev_5) - - # release all of them again - await keycode_mapper.notify(new_event(*ev_3)) - await keycode_mapper.notify(new_event(*ev_6)) - self.assertEqual(len(unreleased), 0) - - self.assertEqual(len(uinput_write_history), 8) - - self.assertEqual(uinput_write_history[0].t, (EV_KEY, 51, 1)) - self.assertEqual(uinput_write_history[1].t, (EV_KEY, 54, 1)) - - self.assertEqual(uinput_write_history[2].t, (EV_KEY, 51, 0)) - self.assertEqual(uinput_write_history[3].t, (EV_KEY, 54, 0)) - - self.assertEqual(uinput_write_history[4].t, (EV_KEY, 52, 1)) - self.assertEqual(uinput_write_history[5].t, (EV_KEY, 55, 1)) - - self.assertEqual(uinput_write_history[6].t, (EV_KEY, 52, 0)) - self.assertEqual(uinput_write_history[7].t, (EV_KEY, 55, 0)) - - async def test_not_forward(self): - down = (EV_KEY, 91, 1) - up = (EV_KEY, 91, 0) - uinput = global_uinputs.devices["keyboard"] - - keycode_mapper = KeycodeMapper(self.context, self.source, uinput) - - keycode_mapper.handle_keycode(new_event(*down), PRESS, forward=False) - self.assertEqual(unreleased[(EV_KEY, 91)].input_event_tuple, down) - self.assertEqual(unreleased[(EV_KEY, 91)].target, (*down[:2], None)) - self.assertEqual(len(unreleased), 1) - self.assertEqual(uinput.write_count, 0) - - keycode_mapper.handle_keycode(new_event(*up), RELEASE, forward=False) - self.assertEqual(len(unreleased), 0) - self.assertEqual(uinput.write_count, 0) - - async def test_release_joystick_button(self): - # with the left joystick mapped as button, it will release the mapped - # key when it goes back to close to its resting position - ev_1 = (3, 0, MAX_ABS // 10) # release - ev_3 = (3, 0, MIN_ABS) # press - - uinput = UInput() - - _key_to_code = {EventCombination((3, 0, -1)): (73, "keyboard")} - - self.mapping.set("gamepad.joystick.left_purpose", BUTTONS) - - # something with gamepad capabilities - source = InputDevice("/dev/input/event30") - - self.context.uinput = uinput - self.context.key_to_code = _key_to_code - keycode_mapper = KeycodeMapper(self.context, source, uinput) - - await keycode_mapper.notify(new_event(*ev_3)) - await keycode_mapper.notify(new_event(*ev_1)) - - # array of 3-tuples - self.history = [a.t for a in uinput_write_history] - - self.assertIn((EV_KEY, 73, 1), self.history) - self.assertEqual(self.history.count((EV_KEY, 73, 1)), 1) - - self.assertIn((EV_KEY, 73, 0), self.history) - self.assertEqual(self.history.count((EV_KEY, 73, 0)), 1) - - async def test_dont_filter_unmapped(self): - # if an event is not used at all, it should be written but not - # furthermore modified. For example wheel events - # keep reporting events of the same value without a release inbetween, - # they should be forwarded. - - down = (EV_KEY, 91, 1) - up = (EV_KEY, 91, 0) - uinput = global_uinputs.devices["keyboard"] - forward_to = UInput() - - keycode_mapper = KeycodeMapper(self.context, self.source, forward_to) - - for _ in range(10): - # don't filter duplicate events if not mapped - await keycode_mapper.notify(new_event(*down)) - - self.assertEqual(unreleased[(EV_KEY, 91)].input_event_tuple, down) - self.assertEqual(unreleased[(EV_KEY, 91)].target, (*down[:2], None)) - self.assertEqual(len(unreleased), 1) - self.assertEqual(forward_to.write_count, 10) - self.assertEqual(uinput.write_count, 0) - - await keycode_mapper.notify(new_event(*up)) - self.assertEqual(len(unreleased), 0) - self.assertEqual(forward_to.write_count, 11) - self.assertEqual(uinput.write_count, 0) - - async def test_filter_combi_mapped_duplicate_down(self): - # the opposite of the other test, but don't map the key directly - # but rather as the trigger for a combination - down_1 = (EV_KEY, 91, 1) - down_2 = (EV_KEY, 92, 1) - up_1 = (EV_KEY, 91, 0) - up_2 = (EV_KEY, 92, 0) - # forwarded and mapped event will end up at the same place - forward = global_uinputs.devices["keyboard"] - - output = 71 - - key_to_code = {EventCombination(down_1, down_2): (71, "keyboard")} - - self.context.key_to_code = key_to_code - keycode_mapper = KeycodeMapper(self.context, self.source, forward) - - await keycode_mapper.notify(new_event(*down_1)) - for _ in range(10): - await keycode_mapper.notify(new_event(*down_2)) - - # all duplicate down events should have been ignored - self.assertEqual(len(unreleased), 2) - self.assertEqual(forward.write_count, 2) - self.assertEqual(uinput_write_history[0].t, down_1) - self.assertEqual(uinput_write_history[1].t, (EV_KEY, output, 1)) - - await keycode_mapper.notify(new_event(*up_1)) - await keycode_mapper.notify(new_event(*up_2)) - self.assertEqual(len(unreleased), 0) - self.assertEqual(forward.write_count, 4) - self.assertEqual(uinput_write_history[2].t, up_1) - self.assertEqual(uinput_write_history[3].t, (EV_KEY, output, 0)) - - async def test_d_pad_combination(self): - ev_1 = (EV_ABS, ABS_HAT0X, 1) - ev_2 = (EV_ABS, ABS_HAT0Y, -1) - - ev_3 = (EV_ABS, ABS_HAT0X, 0) - ev_4 = (EV_ABS, ABS_HAT0Y, 0) - - _key_to_code = { - EventCombination(ev_1, ev_2): (51, "keyboard"), - EventCombination(ev_2): (52, "keyboard"), - } - - uinput = UInput() - - self.context.uinput = uinput - self.context.key_to_code = _key_to_code - keycode_mapper = KeycodeMapper(self.context, self.source, uinput) - - # a bunch of d-pad key down events at once - await keycode_mapper.notify(new_event(*ev_1)) - await keycode_mapper.notify(new_event(*ev_2)) - # (what_will_be_released, what_caused_the_key_down) - self.assertEqual(unreleased.get(ev_1[:2]).target, (EV_ABS, ABS_HAT0X, None)) - self.assertEqual(unreleased.get(ev_1[:2]).input_event_tuple, ev_1) - self.assertEqual(unreleased.get(ev_2[:2]).target, (EV_KEY, 51, "keyboard")) - self.assertEqual(unreleased.get(ev_2[:2]).input_event_tuple, ev_2) - self.assertEqual(len(unreleased), 2) - - # ev_1 is unmapped and the other is the triggered combination - self.assertEqual(len(uinput_write_history), 2) - self.assertEqual(uinput_write_history[0].t, ev_1) - self.assertEqual(uinput_write_history[1].t, (EV_KEY, 51, 1)) - - # release all of them - await keycode_mapper.notify(new_event(*ev_3)) - await keycode_mapper.notify(new_event(*ev_4)) - self.assertEqual(len(unreleased), 0) - - self.assertEqual(len(uinput_write_history), 4) - self.assertEqual(uinput_write_history[2].t, ev_3) - self.assertEqual(uinput_write_history[3].t, (EV_KEY, 51, 0)) - - async def test_notify(self): - code_2 = 2 - # this also makes sure that the keycode_mapper doesn't get confused - # when input and output codes are the same (because it at some point - # screwed it up because of that) - _key_to_code = { - EventCombination((EV_KEY, 1, 1)): (101, "keyboard"), - EventCombination((EV_KEY, code_2, 1)): (code_2, "keyboard"), - } - - uinput_mapped = global_uinputs.devices["keyboard"] - uinput_forwarded = UInput() - - self.context.key_to_code = _key_to_code - keycode_mapper = KeycodeMapper(self.context, self.source, uinput_forwarded) - - await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) - await keycode_mapper.notify(new_event(EV_KEY, 3, 1)) - await keycode_mapper.notify(new_event(EV_KEY, code_2, 1)) - await keycode_mapper.notify(new_event(EV_KEY, code_2, 0)) - - self.assertEqual(len(uinput_write_history), 4) - self.assertEqual(uinput_mapped.write_history[0].t, (EV_KEY, 101, 1)) - self.assertEqual(uinput_mapped.write_history[1].t, (EV_KEY, code_2, 1)) - self.assertEqual(uinput_mapped.write_history[2].t, (EV_KEY, code_2, 0)) - - self.assertEqual(uinput_forwarded.write_history[0].t, (EV_KEY, 3, 1)) - - async def test_combination_keycode(self): - combination = EventCombination((EV_KEY, 1, 1), (EV_KEY, 2, 1)) - _key_to_code = {combination: (101, "keyboard")} - - uinput = UInput() - - self.context.uinput = uinput - self.context.key_to_code = _key_to_code - keycode_mapper = KeycodeMapper(self.context, self.source, uinput) - - await keycode_mapper.notify(combination[0]) - await keycode_mapper.notify(combination[1]) - - self.assertEqual(len(uinput_write_history), 2) - # the first event is written and then the triggered combination - self.assertEqual(uinput_write_history[0].t, (EV_KEY, 1, 1)) - self.assertEqual(uinput_write_history[1].t, (EV_KEY, 101, 1)) - - # release them - await keycode_mapper.notify(combination[0].modify(value=0)) - await keycode_mapper.notify(combination[1].modify(value=0)) - # the first key writes its release event. The second key is hidden - # behind the executed combination. The result of the combination is - # also released, because it acts like a key. - self.assertEqual(len(uinput_write_history), 4) - self.assertEqual(uinput_write_history[2].t, (EV_KEY, 1, 0)) - self.assertEqual(uinput_write_history[3].t, (EV_KEY, 101, 0)) - - # press them in the wrong order (the wrong key at the end, the order - # of all other keys won't matter). no combination should be triggered - await keycode_mapper.notify(combination[1]) - await keycode_mapper.notify(combination[0]) - self.assertEqual(len(uinput_write_history), 6) - self.assertEqual(uinput_write_history[4].t, (EV_KEY, 2, 1)) - self.assertEqual(uinput_write_history[5].t, (EV_KEY, 1, 1)) - - async def test_combination_keycode_2(self): - combination_1 = EventCombination( - (EV_KEY, 1, 1), - (EV_ABS, ABS_Y, MIN_ABS), - (EV_KEY, 3, 1), - (EV_KEY, 4, 1), - ) - combination_2 = EventCombination( - # should not be triggered, combination_1 should be prioritized - # when all of its keys are down - (EV_KEY, 2, 1), - (EV_KEY, 3, 1), - (EV_KEY, 4, 1), - ) - - down_5 = (EV_KEY, 5, 1) - up_5 = (EV_KEY, 5, 0) - up_4 = (EV_KEY, 4, 0) - - def sign_value(event): - return event.modify(value=event.value / abs(event.value)) - - _key_to_code = { - # key_to_code is supposed to only contain values classified into PRESS, - # PRESS_NEGATIVE and RELEASE - EventCombination(*[sign_value(a) for a in combination_1]): ( - 101, - "keyboard", - ), - combination_2: (102, "keyboard"), - EventCombination(down_5): (103, "keyboard"), - } - - uinput = UInput() - - source = InputDevice("/dev/input/event30") - - # ABS_Y is part of the combination, which only works if the joystick - # is configured as D-Pad - self.mapping.set("gamepad.joystick.left_purpose", BUTTONS) - self.context.uinput = uinput - self.context.key_to_code = _key_to_code - keycode_mapper = KeycodeMapper(self.context, source, uinput) - self.assertIsNotNone(keycode_mapper._abs_range) - - # 10 and 11: insert some more arbitrary key-down events, - # they should not break the combinations - await keycode_mapper.notify(new_event(EV_KEY, 10, 1)) - await keycode_mapper.notify(combination_1[0]) - await keycode_mapper.notify(combination_1[1]) - await keycode_mapper.notify(combination_1[2]) - await keycode_mapper.notify(new_event(EV_KEY, 11, 1)) - await keycode_mapper.notify(combination_1[3]) - # combination_1 should have been triggered now - - self.assertEqual(len(uinput_write_history), 6) - # the first events are written and then the triggered combination, - # while the triggering event is the only one that is omitted - self.assertEqual(uinput_write_history[1].t, combination_1[0]) - self.assertEqual(uinput_write_history[2].t, combination_1[1]) - self.assertEqual(uinput_write_history[3].t, combination_1[2]) - self.assertEqual(uinput_write_history[5].t, (EV_KEY, 101, 1)) - - # while the combination is down, another unrelated key can be used - await keycode_mapper.notify(new_event(*down_5)) - # the keycode_mapper searches for subsets of the current held-down - # keys to activate combinations, down_5 should not trigger them - # again. - self.assertEqual(len(uinput_write_history), 7) - self.assertEqual(uinput_write_history[6].t, (EV_KEY, 103, 1)) - - # release the combination by releasing the last key, and release - # the unrelated key - await keycode_mapper.notify(new_event(*up_4)) - await keycode_mapper.notify(new_event(*up_5)) - self.assertEqual(len(uinput_write_history), 9) - - self.assertEqual(uinput_write_history[7].t, (EV_KEY, 101, 0)) - self.assertEqual(uinput_write_history[8].t, (EV_KEY, 103, 0)) - - async def test_macro_writes_to_global_uinput(self): - macro_mapping = { - ((EV_KEY, 1, 1),): (parse("k(a)", self.context), "keyboard"), - } - - self.context.macros = macro_mapping - forward_to = UInput() - keycode_mapper = KeycodeMapper(self.context, self.source, forward_to) - - await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) - - sleeptime = global_config.get("macros.keystroke_sleep_ms", 10) * 12 - await asyncio.sleep(sleeptime / 1000 + 0.1) - - self.assertEqual( - global_uinputs.devices["keyboard"].write_count, 2 - ) # down and up - self.assertEqual(forward_to.write_count, 0) - - await keycode_mapper.notify(new_event(EV_KEY, 2, 1)) - self.assertEqual(forward_to.write_count, 1) - - async def test_notify_macro(self): - code_a, code_b = 100, 101 - keycode_mapper = self.setup_keycode_mapper( - {"a": code_a, "b": code_b}, - { - ((EV_KEY, 1, 1),): "k(a)", - ((EV_KEY, 2, 1),): "r(5, k(b))", - }, - ) - - await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) - await keycode_mapper.notify(new_event(EV_KEY, 2, 1)) - - sleeptime = global_config.get("macros.keystroke_sleep_ms", 10) * 12 - - # let the mainloop run for some time so that the macro does its stuff - await asyncio.sleep(sleeptime / 1000 + 0.1) - - # 6 keycodes written, with down and up events - self.assertEqual(len(self.history), 12) - self.assertIn((EV_KEY, code_a, 1), self.history) - self.assertIn((EV_KEY, code_a, 0), self.history) - self.assertIn((EV_KEY, code_b, 1), self.history) - self.assertIn((EV_KEY, code_b, 0), self.history) - - # releasing stuff - self.assertIn((EV_KEY, 1), unreleased) - self.assertIn((EV_KEY, 2), unreleased) - await keycode_mapper.notify(new_event(EV_KEY, 1, 0)) - await keycode_mapper.notify(new_event(EV_KEY, 2, 0)) - self.assertNotIn((EV_KEY, 1), unreleased) - self.assertNotIn((EV_KEY, 2), unreleased) - await asyncio.sleep(0.1) - self.assertEqual(len(self.history), 12) - - async def test_if_single(self): - code_a, code_b = 100, 101 - keycode_mapper = self.setup_keycode_mapper( - {"a": code_a, "b": code_b}, {((EV_KEY, 1, 1),): "if_single(k(a), k(b))"} - ) - - """triggers then""" - - await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) # start the macro - await asyncio.sleep(0.05) - - self.assertTrue(active_macros[(EV_KEY, 1)].running) - - await keycode_mapper.notify(new_event(EV_KEY, 1, 0)) - await asyncio.sleep(0.05) - - self.assertFalse(active_macros[(EV_KEY, 1)].running) - - self.assertIn((EV_KEY, code_a, 1), self.history) - self.assertIn((EV_KEY, code_a, 0), self.history) - self.assertNotIn((EV_KEY, code_b, 1), self.history) - self.assertNotIn((EV_KEY, code_b, 0), self.history) - - """triggers else""" - - self.history.clear() - - await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) # start the macro - await asyncio.sleep(0.05) - - self.assertTrue(active_macros[(EV_KEY, 1)].running) - - await keycode_mapper.notify(new_event(EV_KEY, 2, 1)) - await asyncio.sleep(0.05) - - self.assertFalse(active_macros[(EV_KEY, 1)].running) - - self.assertNotIn((EV_KEY, code_a, 1), self.history) - self.assertNotIn((EV_KEY, code_a, 0), self.history) - self.assertIn((EV_KEY, code_b, 1), self.history) - self.assertIn((EV_KEY, code_b, 0), self.history) - - async def test_hold(self): - code_a, code_b, code_c = 100, 101, 102 - keycode_mapper = self.setup_keycode_mapper( - {"a": code_a, "b": code_b, "c": code_c}, - {((EV_KEY, 1, 1),): "k(a).h(k(b)).k(c)"}, - ) - - """start macro""" - - await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) - - # let the mainloop run for some time so that the macro does its stuff - sleeptime = 500 - keystroke_sleep = global_config.get("macros.keystroke_sleep_ms", 10) - await asyncio.sleep(sleeptime / 1000) - - self.assertTrue(active_macros[(EV_KEY, 1)].is_holding()) - self.assertTrue(active_macros[(EV_KEY, 1)].running) - - """stop macro""" - - await keycode_mapper.notify(new_event(EV_KEY, 1, 0)) - - await asyncio.sleep(keystroke_sleep * 10 / 1000) - - events = calculate_event_number(sleeptime, 1, 1) - - self.assertGreater(len(self.history), events * 0.9) - self.assertLess(len(self.history), events * 1.1) - - self.assertIn((EV_KEY, code_a, 1), self.history) - self.assertIn((EV_KEY, code_a, 0), self.history) - self.assertIn((EV_KEY, code_b, 1), self.history) - self.assertIn((EV_KEY, code_b, 0), self.history) - self.assertIn((EV_KEY, code_c, 1), self.history) - self.assertIn((EV_KEY, code_c, 0), self.history) - self.assertGreater(self.history.count((EV_KEY, code_b, 1)), 1) - self.assertGreater(self.history.count((EV_KEY, code_b, 0)), 1) - - # it's stopped and won't write stuff anymore - count_before = len(self.history) - await asyncio.sleep(0.2) - count_after = len(self.history) - self.assertEqual(count_before, count_after) - - self.assertFalse(active_macros[(EV_KEY, 1)].is_holding()) - self.assertFalse(active_macros[(EV_KEY, 1)].running) - - async def test_hold_2(self): - # test irregular input patterns - code_a, code_b, code_c, code_d = 100, 101, 102, 103 - keycode_mapper = self.setup_keycode_mapper( - {"a": code_a, "b": code_b, "c": code_c, "d": code_d}, - { - ((EV_KEY, 1, 1),): "h(k(b))", - ((EV_KEY, 2, 1),): "k(c).r(1, r(1, r(1, h(k(a))))).k(d)", - ((EV_KEY, 3, 1),): "h(k(b))", - }, - ) - - """start macro 2""" - - await keycode_mapper.notify(new_event(EV_KEY, 2, 1)) - - await asyncio.sleep(0.1) - # starting code_c written - self.assertEqual(self.history.count((EV_KEY, code_c, 1)), 1) - self.assertEqual(self.history.count((EV_KEY, code_c, 0)), 1) - - # spam garbage events - for _ in range(5): - await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) - await keycode_mapper.notify(new_event(EV_KEY, 3, 1)) - await asyncio.sleep(0.05) - self.assertTrue(active_macros[(EV_KEY, 1)].is_holding()) - self.assertTrue(active_macros[(EV_KEY, 1)].running) - self.assertTrue(active_macros[(EV_KEY, 2)].is_holding()) - self.assertTrue(active_macros[(EV_KEY, 2)].running) - self.assertTrue(active_macros[(EV_KEY, 3)].is_holding()) - self.assertTrue(active_macros[(EV_KEY, 3)].running) - - # there should only be one code_c in the events, because no key - # up event was ever done so the hold just continued - self.assertEqual(self.history.count((EV_KEY, code_c, 1)), 1) - self.assertEqual(self.history.count((EV_KEY, code_c, 0)), 1) - # without an key up event on 2, it won't write code_d - self.assertNotIn((code_d, 1), self.history) - self.assertNotIn((code_d, 0), self.history) - - # stop macro 2 - await keycode_mapper.notify(new_event(EV_KEY, 2, 0)) - await asyncio.sleep(0.1) - - # it stopped and didn't restart, so the count stays at 1 - self.assertEqual(self.history.count((EV_KEY, code_c, 1)), 1) - self.assertEqual(self.history.count((EV_KEY, code_c, 0)), 1) - # and the trailing d was written - self.assertEqual(self.history.count((EV_KEY, code_d, 1)), 1) - self.assertEqual(self.history.count((EV_KEY, code_d, 0)), 1) - - # it's stopped and won't write stuff anymore - count_before = self.history.count((EV_KEY, code_a, 1)) - self.assertGreater(count_before, 1) - await asyncio.sleep(0.1) - count_after = self.history.count((EV_KEY, code_a, 1)) - self.assertEqual(count_before, count_after) - - """restart macro 2""" - - self.history.clear() - - await keycode_mapper.notify(new_event(EV_KEY, 2, 1)) - await asyncio.sleep(0.1) - self.assertEqual(self.history.count((EV_KEY, code_c, 1)), 1) - self.assertEqual(self.history.count((EV_KEY, code_c, 0)), 1) - - # spam garbage events again, this time key-up events on all other - # macros - for _ in range(5): - await keycode_mapper.notify(new_event(EV_KEY, 1, 0)) - await keycode_mapper.notify(new_event(EV_KEY, 3, 0)) - await asyncio.sleep(0.05) - self.assertFalse(active_macros[(EV_KEY, 1)].is_holding()) - self.assertFalse(active_macros[(EV_KEY, 1)].running) - self.assertTrue(active_macros[(EV_KEY, 2)].is_holding()) - self.assertTrue(active_macros[(EV_KEY, 2)].running) - self.assertFalse(active_macros[(EV_KEY, 3)].is_holding()) - self.assertFalse(active_macros[(EV_KEY, 3)].running) - - # stop macro 2 - await keycode_mapper.notify(new_event(EV_KEY, 2, 0)) - await asyncio.sleep(0.1) - # was started only once - self.assertEqual(self.history.count((EV_KEY, code_c, 1)), 1) - self.assertEqual(self.history.count((EV_KEY, code_c, 0)), 1) - # and the trailing d was also written only once - self.assertEqual(self.history.count((EV_KEY, code_d, 1)), 1) - self.assertEqual(self.history.count((EV_KEY, code_d, 0)), 1) - - # stop all macros - await keycode_mapper.notify(new_event(EV_KEY, 1, 0)) - await keycode_mapper.notify(new_event(EV_KEY, 3, 0)) - await asyncio.sleep(0.1) - - # it's stopped and won't write stuff anymore - count_before = len(self.history) - await asyncio.sleep(0.1) - count_after = len(self.history) - self.assertEqual(count_before, count_after) - - self.assertFalse(active_macros[(EV_KEY, 1)].is_holding()) - self.assertFalse(active_macros[(EV_KEY, 1)].running) - self.assertFalse(active_macros[(EV_KEY, 2)].is_holding()) - self.assertFalse(active_macros[(EV_KEY, 2)].running) - self.assertFalse(active_macros[(EV_KEY, 3)].is_holding()) - self.assertFalse(active_macros[(EV_KEY, 3)].running) - - async def test_hold_3(self): - # test irregular input patterns - code_a, code_b, code_c = 100, 101, 102 - keycode_mapper = self.setup_keycode_mapper( - {"a": code_a, "b": code_b, "c": code_c}, - {((EV_KEY, 1, 1),): "k(a).h(k(b)).k(c)"}, - ) - - await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) - - await asyncio.sleep(0.1) - for _ in range(5): - self.assertTrue(active_macros[(EV_KEY, 1)].is_holding()) - self.assertTrue(active_macros[(EV_KEY, 1)].running) - await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) - await asyncio.sleep(0.05) - - # duplicate key down events don't do anything - self.assertEqual(self.history.count((EV_KEY, code_a, 1)), 1) - self.assertEqual(self.history.count((EV_KEY, code_a, 0)), 1) - self.assertEqual(self.history.count((EV_KEY, code_c, 1)), 0) - self.assertEqual(self.history.count((EV_KEY, code_c, 0)), 0) - - # stop - await keycode_mapper.notify(new_event(EV_KEY, 1, 0)) - await asyncio.sleep(0.1) - self.assertEqual(self.history.count((EV_KEY, code_a, 1)), 1) - self.assertEqual(self.history.count((EV_KEY, code_a, 0)), 1) - self.assertEqual(self.history.count((EV_KEY, code_c, 1)), 1) - self.assertEqual(self.history.count((EV_KEY, code_c, 0)), 1) - self.assertFalse(active_macros[(EV_KEY, 1)].is_holding()) - self.assertFalse(active_macros[(EV_KEY, 1)].running) - - # it's stopped and won't write stuff anymore - count_before = len(self.history) - await asyncio.sleep(0.1) - count_after = len(self.history) - self.assertEqual(count_before, count_after) - - async def test_hold_two(self): - # holding two macros at the same time, - # the first one is triggered by a combination - key_0 = (EV_KEY, 10) - key_1 = (EV_KEY, 11) - key_2 = (EV_ABS, ABS_HAT0X) - down_0 = (*key_0, 1) - down_1 = (*key_1, 1) - down_2 = (*key_2, -1) - up_0 = (*key_0, 0) - up_1 = (*key_1, 0) - up_2 = (*key_2, 0) - - code_1, code_2, code_3, code_a, code_b, code_c = 100, 101, 102, 103, 104, 105 - keycode_mapper = self.setup_keycode_mapper( - {1: code_1, 2: code_2, 3: code_3, "a": code_a, "b": code_b, "c": code_c}, - { - (down_0, down_1): "k(1).h(k(2)).k(3)", - (down_2,): "k(a).h(k(b)).k(c)", - }, - ) - - # key up won't do anything - await keycode_mapper.notify(new_event(*up_0)) - await keycode_mapper.notify(new_event(*up_1)) - await keycode_mapper.notify(new_event(*up_2)) - await asyncio.sleep(0.1) - self.assertEqual(len(active_macros), 0) - - """start macros""" - - uinput_2 = UInput() - self.context.uinput = uinput_2 - - keycode_mapper = KeycodeMapper(self.context, self.source, uinput_2) - - keycode_mapper.macro_write = self.create_handler - - await keycode_mapper.notify(new_event(*down_0)) - self.assertEqual(uinput_2.write_count, 1) - await keycode_mapper.notify(new_event(*down_1)) - await keycode_mapper.notify(new_event(*down_2)) - self.assertEqual(uinput_2.write_count, 1) - - # let the mainloop run for some time so that the macro does its stuff - sleeptime = 500 - keystroke_sleep = global_config.get("macros.keystroke_sleep_ms", 10) - await asyncio.sleep(sleeptime / 1000) - - # test that two macros are really running at the same time - self.assertEqual(len(active_macros), 2) - self.assertTrue(active_macros[key_1].is_holding()) - self.assertTrue(active_macros[key_1].running) - self.assertTrue(active_macros[key_2].is_holding()) - self.assertTrue(active_macros[key_2].running) - - self.assertIn(down_0[:2], unreleased) - self.assertIn(down_1[:2], unreleased) - self.assertIn(down_2[:2], unreleased) - - """stop macros""" - - keycode_mapper = KeycodeMapper(self.context, self.source, None) - - # releasing the last key of a combination releases the whole macro - await keycode_mapper.notify(new_event(*up_1)) - await keycode_mapper.notify(new_event(*up_2)) - - self.assertIn(down_0[:2], unreleased) - self.assertNotIn(down_1[:2], unreleased) - self.assertNotIn(down_2[:2], unreleased) - - await asyncio.sleep(keystroke_sleep * 10 / 1000) - - self.assertFalse(active_macros[key_1].is_holding()) - self.assertFalse(active_macros[key_1].running) - self.assertFalse(active_macros[key_2].is_holding()) - self.assertFalse(active_macros[key_2].running) - - events = calculate_event_number(sleeptime, 1, 1) * 2 - - self.assertGreater(len(self.history), events * 0.9) - self.assertLess(len(self.history), events * 1.1) - - self.assertIn((EV_KEY, code_a, 1), self.history) - self.assertIn((EV_KEY, code_a, 0), self.history) - self.assertIn((EV_KEY, code_b, 1), self.history) - self.assertIn((EV_KEY, code_b, 0), self.history) - self.assertIn((EV_KEY, code_c, 1), self.history) - self.assertIn((EV_KEY, code_c, 0), self.history) - self.assertIn((EV_KEY, code_1, 1), self.history) - self.assertIn((EV_KEY, code_1, 0), self.history) - self.assertIn((EV_KEY, code_2, 1), self.history) - self.assertIn((EV_KEY, code_2, 0), self.history) - self.assertIn((EV_KEY, code_3, 1), self.history) - self.assertIn((EV_KEY, code_3, 0), self.history) - self.assertGreater(self.history.count((EV_KEY, code_b, 1)), 1) - self.assertGreater(self.history.count((EV_KEY, code_b, 0)), 1) - self.assertGreater(self.history.count((EV_KEY, code_2, 1)), 1) - self.assertGreater(self.history.count((EV_KEY, code_2, 0)), 1) - - # it's stopped and won't write stuff anymore - count_before = len(self.history) - await asyncio.sleep(0.2) - count_after = len(self.history) - self.assertEqual(count_before, count_after) - - async def test_filter_trigger_spam(self): - # test_filter_duplicates - trigger = (EV_KEY, BTN_TL) - - _key_to_code = { - EventCombination((*trigger, 1)): (51, "keyboard"), - EventCombination((*trigger, -1)): (52, "keyboard"), - } - - uinput = UInput() - - self.context.uinput = uinput - self.context.key_to_code = _key_to_code - keycode_mapper = KeycodeMapper(self.context, self.source, uinput) - - """positive""" - - for _ in range(1, 20): - await keycode_mapper.notify(new_event(*trigger, 1)) - self.assertIn(trigger, unreleased) - - await keycode_mapper.notify(new_event(*trigger, 0)) - self.assertNotIn(trigger, unreleased) - - self.assertEqual(len(uinput_write_history), 2) - - """negative""" - - for _ in range(1, 20): - await keycode_mapper.notify(new_event(*trigger, -1)) - self.assertIn(trigger, unreleased) - - await keycode_mapper.notify(new_event(*trigger, 0)) - self.assertNotIn(trigger, unreleased) - - self.assertEqual(len(uinput_write_history), 4) - self.assertEqual(uinput_write_history[0].t, (EV_KEY, 51, 1)) - self.assertEqual(uinput_write_history[1].t, (EV_KEY, 51, 0)) - self.assertEqual(uinput_write_history[2].t, (EV_KEY, 52, 1)) - self.assertEqual(uinput_write_history[3].t, (EV_KEY, 52, 0)) - - async def test_ignore_hold(self): - # hold as in event-value 2, not in macro-hold. - # linux will generate events with value 2 after input-remapper injected - # the key-press, so input-remapper doesn't need to forward them. That - # would cause duplicate events of those values otherwise. - key = (EV_KEY, KEY_A) - ev_1 = (*key, 1) - ev_2 = (*key, 2) - ev_3 = (*key, 0) - - _key_to_code = { - EventCombination((*key, 1)): (21, "keyboard"), - } - - uinput = UInput() - - self.context.uinput = uinput - self.context.key_to_code = _key_to_code - keycode_mapper = KeycodeMapper(self.context, self.source, uinput) - - await keycode_mapper.notify(new_event(*ev_1)) - - for _ in range(10): - await keycode_mapper.notify(new_event(*ev_2)) - - self.assertIn(key, unreleased) - await keycode_mapper.notify(new_event(*ev_3)) - self.assertNotIn(key, unreleased) - - self.assertEqual(len(uinput_write_history), 2) - self.assertEqual(uinput_write_history[0].t, (EV_KEY, 21, 1)) - self.assertEqual(uinput_write_history[1].t, (EV_KEY, 21, 0)) - - async def test_ignore_disabled(self): - ev_1 = (EV_ABS, ABS_HAT0Y, 1) - ev_2 = (EV_ABS, ABS_HAT0Y, 0) - - ev_3 = (EV_ABS, ABS_HAT0X, 1) # disabled - ev_4 = (EV_ABS, ABS_HAT0X, 0) - - ev_5 = (EV_KEY, KEY_A, 1) - ev_6 = (EV_KEY, KEY_A, 0) - - combi_1 = EventCombination(ev_5, ev_3) - combi_2 = EventCombination(ev_3, ev_5) - - _key_to_code = { - EventCombination(ev_1): (61, "keyboard"), - EventCombination(ev_3): (DISABLE_CODE, "keyboard"), - combi_1: (62, "keyboard"), - combi_2: (63, "keyboard"), - } - - forward_to = UInput() - - self.context.key_to_code = _key_to_code - keycode_mapper = KeycodeMapper(self.context, self.source, forward_to) - - def expect_writecounts(uinput_count, forwarded_count): - self.assertEqual( - global_uinputs.devices["keyboard"].write_count, uinput_count - ) - self.assertEqual(forward_to.write_count, forwarded_count) - - """single keys""" - - # down - await keycode_mapper.notify(new_event(*ev_1)) - await keycode_mapper.notify(new_event(*ev_3)) - self.assertIn(ev_1[:2], unreleased) - self.assertIn(ev_3[:2], unreleased) - expect_writecounts(1, 0) - # up - await keycode_mapper.notify(new_event(*ev_2)) - await keycode_mapper.notify(new_event(*ev_4)) - expect_writecounts(2, 0) - self.assertNotIn(ev_1[:2], unreleased) - self.assertNotIn(ev_3[:2], unreleased) - - self.assertEqual(len(uinput_write_history), 2) - self.assertEqual(uinput_write_history[0].t, (EV_KEY, 61, 1)) - self.assertEqual(uinput_write_history[1].t, (EV_KEY, 61, 0)) - - """a combination that ends in a disabled key""" - - # ev_5 should be forwarded and the combination triggered - await keycode_mapper.notify(new_event(*combi_1[0].event_tuple)) # ev_5 - await keycode_mapper.notify(new_event(*combi_1[1].event_tuple)) # ev_3 - expect_writecounts(3, 1) - self.assertEqual(len(uinput_write_history), 4) - self.assertEqual(uinput_write_history[2].t, (EV_KEY, KEY_A, 1)) - self.assertEqual(uinput_write_history[3].t, (EV_KEY, 62, 1)) - self.assertIn(combi_1[0].type_and_code, unreleased) - self.assertIn(combi_1[1].type_and_code, unreleased) - # since this event did not trigger anything, key is None - self.assertEqual(unreleased[combi_1[0].type_and_code].triggered_key, None) - # that one triggered something from _key_to_code, so the key is that - self.assertEqual(unreleased[combi_1[1].type_and_code].triggered_key, combi_1) - - # release the last key of the combi first, it should - # release what the combination maps to - event = combi_1[1].modify(value=0) - await keycode_mapper.notify(event) - expect_writecounts(4, 1) - self.assertEqual(len(uinput_write_history), 5) - self.assertEqual(uinput_write_history[-1].t, (EV_KEY, 62, 0)) - self.assertIn(combi_1[0].type_and_code, unreleased) - self.assertNotIn(combi_1[1].type_and_code, unreleased) - - event = combi_1[0].modify(value=0) - await keycode_mapper.notify(event) - expect_writecounts(4, 2) - self.assertEqual(len(uinput_write_history), 6) - self.assertEqual(uinput_write_history[-1].t, (EV_KEY, KEY_A, 0)) - self.assertNotIn(combi_1[0].type_and_code, unreleased) - self.assertNotIn(combi_1[1].type_and_code, unreleased) - - """a combination that starts with a disabled key""" - - # only the combination should get triggered - await keycode_mapper.notify(combi_2[0]) - await keycode_mapper.notify(combi_2[1]) - expect_writecounts(5, 2) - self.assertEqual(len(uinput_write_history), 7) - self.assertEqual(uinput_write_history[-1].t, (EV_KEY, 63, 1)) - - # release the last key of the combi first, it should - # release what the combination maps to - event = combi_2[1].modify(value=0) - await keycode_mapper.notify(event) - self.assertEqual(len(uinput_write_history), 8) - self.assertEqual(uinput_write_history[-1].t, (EV_KEY, 63, 0)) - expect_writecounts(6, 2) - - # the first key of combi_2 is disabled, so it won't write another - # key-up event - event = combi_2[0].modify(value=0) - await keycode_mapper.notify(event) - self.assertEqual(len(uinput_write_history), 8) - expect_writecounts(6, 2) - - async def test_combination_keycode_macro_mix(self): - # ev_1 triggers macro, ev_1 + ev_2 triggers key while the macro is - # still running - down_1 = (EV_ABS, ABS_HAT1X, 1) - down_2 = (EV_ABS, ABS_HAT1Y, -1) - up_1 = (EV_ABS, ABS_HAT1X, 0) - up_2 = (EV_ABS, ABS_HAT1Y, 0) - - keycode_mapper = self.setup_keycode_mapper({"a": 92}, {(down_1,): "h(k(a))"}) - _key_to_code = {(down_1, down_2): (91, "keyboard")} - self.context.key_to_code = _key_to_code - - # macro starts - await keycode_mapper.notify(new_event(*down_1)) - await asyncio.sleep(0.05) - self.assertEqual(len(uinput_write_history), 0) - self.assertGreater(len(self.history), 1) - self.assertIn(down_1[:2], unreleased) - self.assertIn((EV_KEY, 92, 1), self.history) - - # combination triggered - await keycode_mapper.notify(new_event(*down_2)) - self.assertIn(down_1[:2], unreleased) - self.assertIn(down_2[:2], unreleased) - self.assertEqual(uinput_write_history[0].t, (EV_KEY, 91, 1)) - - len_a = len(self.history) - await asyncio.sleep(0.05) - len_b = len(self.history) - # still running - self.assertGreater(len_b, len_a) - - # release - await keycode_mapper.notify(new_event(*up_1)) - self.assertNotIn(down_1[:2], unreleased) - self.assertIn(down_2[:2], unreleased) - await asyncio.sleep(0.05) - len_c = len(self.history) - await asyncio.sleep(0.05) - len_d = len(self.history) - # not running anymore - self.assertEqual(len_c, len_d) - - await keycode_mapper.notify(new_event(*up_2)) - self.assertEqual(uinput_write_history[1].t, (EV_KEY, 91, 0)) - self.assertEqual(len(uinput_write_history), 2) - self.assertNotIn(down_1[:2], unreleased) - self.assertNotIn(down_2[:2], unreleased) - - async def test_wheel_combination_release_failure(self): - # test based on a bug that once occurred - # 1 | 22.6698, ((1, 276, 1)) -------------- forwarding - # 2 | 22.9904, ((1, 276, 1), (2, 8, -1)) -- maps to 30 - # 3 | 23.0103, ((1, 276, 1), (2, 8, -1)) -- duplicate key down - # 4 | ... 34 more duplicate key downs (scrolling) - # 5 | 23.7104, ((1, 276, 1), (2, 8, -1)) -- duplicate key down - # 6 | 23.7283, ((1, 276, 0)) -------------- forwarding release - # 7 | 23.7303, ((2, 8, -1)) --------------- forwarding - # 8 | 23.7865, ((2, 8, 0)) ---------------- not forwarding release - # line 7 should have been "duplicate key down" as well - # line 8 should have released 30, instead it was never released - - scroll = (2, 8, -1) - scroll_release = (2, 8, 0) - btn_down = (1, 276, 1) - btn_up = (1, 276, 0) - combination = EventCombination((1, 276, 1), (2, 8, -1)) - - system_mapping.clear() - system_mapping._set("a", 30) - k2c = {combination: (30, "keyboard")} - - uinput = UInput() - - self.context.uinput = uinput - self.context.key_to_code = k2c - keycode_mapper = KeycodeMapper(self.context, self.source, uinput) - - await keycode_mapper.notify(new_event(*btn_down)) - # "forwarding" - self.assertEqual(uinput_write_history[0].t, btn_down) - - await keycode_mapper.notify(new_event(*scroll)) - # "maps to 30" - self.assertEqual(uinput_write_history[1].t, (1, 30, 1)) - - for _ in range(5): - # keep scrolling - # "duplicate key down" - await keycode_mapper.notify(new_event(*scroll)) - - # nothing new since all of them were duplicate key downs - self.assertEqual(len(uinput_write_history), 2) - - await keycode_mapper.notify(new_event(*btn_up)) - # "forwarding release" - self.assertEqual(uinput_write_history[2].t, btn_up) - - # one more scroll event. since the combination is still not released, - # it should be ignored as duplicate key-down - self.assertEqual(len(uinput_write_history), 3) - # "forwarding" (should be "duplicate key down") - await keycode_mapper.notify(new_event(*scroll)) - self.assertEqual(len(uinput_write_history), 3) - - # the failure to release the mapped key - # forward=False is what the debouncer uses, because a - # "scroll release" doesn't actually exist so it is not actually - # written if it doesn't release any mapping - keycode_mapper.handle_keycode( - new_event(*scroll_release), RELEASE, forward=False - ) - - # 30 should be released - self.assertEqual(uinput_write_history[3].t, (1, 30, 0)) - self.assertEqual(len(uinput_write_history), 4) - - async def test_debounce_1(self): - tick_time = 1 / 60 - self.history = [] - - keycode_mapper = KeycodeMapper(self.context, self.source) - keycode_mapper.debounce(1234, self.history.append, (1,), 10) - asyncio.ensure_future(keycode_mapper.run()) # run alongside the test - await asyncio.sleep(6 * tick_time) - self.assertEqual(len(self.history), 0) - await asyncio.sleep(6 * tick_time) - self.assertEqual(len(self.history), 1) - # won't get called a second time - await asyncio.sleep(12 * tick_time) - self.assertEqual(len(self.history), 1) - self.assertEqual(self.history[0], 1) - - async def test_debounce_2(self): - tick_time = 1 / 60 - self.history = [] - - keycode_mapper = KeycodeMapper(self.context, self.source) - keycode_mapper.debounce(1234, self.history.append, ("first",), 10) - asyncio.ensure_future(keycode_mapper.run()) # run alongside the test - await asyncio.sleep(6 * tick_time) - self.assertEqual(len(self.history), 0) - - # replaces - keycode_mapper.debounce(1234, self.history.append, ("second",), 20) - await asyncio.sleep(5 * tick_time) - self.assertEqual(len(self.history), 0) - await asyncio.sleep(17 * tick_time) - self.assertEqual(len(self.history), 1) - self.assertEqual(self.history[0], "second") - # won't get called a second time - await asyncio.sleep(22 * tick_time) - self.assertEqual(len(self.history), 1) - self.assertEqual(self.history[0], "second") - - async def test_debounce_3(self): - tick_time = 1 / 60 - self.history = [] - - keycode_mapper = KeycodeMapper(self.context, self.source) - keycode_mapper.debounce(1234, self.history.append, (1,), 10) - keycode_mapper.debounce(5678, self.history.append, (2,), 20) - asyncio.ensure_future(keycode_mapper.run()) # run alongside the test - await asyncio.sleep(12 * tick_time) - self.assertEqual(len(self.history), 1) - await asyncio.sleep(12 * tick_time) - self.assertEqual(len(self.history), 2) - await asyncio.sleep(22 * tick_time) - self.assertEqual(len(self.history), 2) - self.assertEqual(self.history[0], 1) - self.assertEqual(self.history[1], 2) - - async def test_can_not_map(self): - """inject events to wrong or invalid uinput""" - ev_1 = (EV_KEY, KEY_A, 1) - ev_2 = (EV_KEY, KEY_B, 1) - ev_3 = (EV_KEY, KEY_C, 1) - - ev_4 = (EV_KEY, KEY_A, 0) - ev_5 = (EV_KEY, KEY_B, 0) - ev_6 = (EV_KEY, KEY_C, 0) - - self.context.key_to_code = { - EventCombination(ev_1): (51, "foo"), # invalid - EventCombination(ev_2): (BTN_TL, "keyboard"), # invalid - EventCombination(ev_3): (KEY_A, "keyboard"), # valid - } - - keyboard = global_uinputs.get_uinput("keyboard") - forward = UInput() - keycode_mapper = KeycodeMapper(self.context, self.source, forward) - - # send key-down - await keycode_mapper.notify(new_event(*ev_1)) - await keycode_mapper.notify(new_event(*ev_2)) - await keycode_mapper.notify(new_event(*ev_3)) - self.assertEqual(len(unreleased), 3) - # send key-up - await keycode_mapper.notify(new_event(*ev_4)) - await keycode_mapper.notify(new_event(*ev_5)) - await keycode_mapper.notify(new_event(*ev_6)) - - # all key down and key up events get forwarded - self.assertEqual(forward.write_count, 4) - self.assertEqual(keyboard.write_count, 2) - forward_history = [event.t for event in forward.write_history] - self.assertIn(ev_1, forward_history) - self.assertIn(ev_2, forward_history) - self.assertIn(ev_4, forward_history) - self.assertIn(ev_5, forward_history) - self.assertNotIn(ev_3, forward_history) - self.assertNotIn(ev_6, forward_history) - - keyboard_history = [event.t for event in keyboard.write_history] - self.assertIn((EV_KEY, KEY_A, 1), keyboard_history) - self.assertIn((EV_KEY, KEY_A, 0), keyboard_history) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_macros.py b/tests/unit/test_macros.py index 18af4acf..ba93f302 100644 --- a/tests/unit/test_macros.py +++ b/tests/unit/test_macros.py @@ -17,7 +17,7 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - +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") diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py new file mode 100644 index 00000000..0fe5a27d --- /dev/null +++ b/tests/unit/test_mapping.py @@ -0,0 +1,335 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . + +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() diff --git a/tests/unit/test_migrations.py b/tests/unit/test_migrations.py index b424f91c..47e707d9 100644 --- a/tests/unit/test_migrations.py +++ b/tests/unit/test_migrations.py @@ -11,8 +11,7 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - - +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() diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py index eb8596db..e1864c4a 100644 --- a/tests/unit/test_paths.py +++ b/tests/unit/test_paths.py @@ -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")) diff --git a/tests/unit/test_preset.py b/tests/unit/test_preset.py index 88f5a087..87d74194 100644 --- a/tests/unit/test_preset.py +++ b/tests/unit/test_preset.py @@ -17,9 +17,8 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - - -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() diff --git a/tests/unit/test_presets.py b/tests/unit/test_presets.py index 6d440c9b..090a5d55 100644 --- a/tests/unit/test_presets.py +++ b/tests/unit/test_presets.py @@ -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") diff --git a/tests/unit/test_reader.py b/tests/unit/test_reader.py index 3eec7186..71fb6c85 100644 --- a/tests/unit/test_reader.py +++ b/tests/unit/test_reader.py @@ -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()))