Frontend Refactor (#375)

* Tests for the GuiEventHandler

* Implement GuiEventHandler

* tests for data manager

* Implemented data_manager

* Remove Ellipsis from type hint

* workaround for old pydantic version

* workaround for old pydantic version

* some more tests for data_manager

* Updated Data Manager

* move DeviceSelection to its own class

* Data Manager no longer listens for events

* Moved PresetSelection to its own class

* MappingListBox and SelectionLable Listen to the EventHandler

* DataManager no longer creates its own data objects in the init

* removed global reader object

* Changed UI startup

* created backend Interface

* event_handler debug logs show function which emit a event

* some cleanup

* added target selector to components

* created code editor component

* adapted autocompletion & some cleanup

* black

* connected some buttons to the event_handler

* tests for data_manager newest_preset and group

* cleanup presets and test_presets

* migrated confirm delete dialog

* backend tests

* controller tests

* add python3-gi to ci

* more dependencies

* and more ...

* Github-Actions workaround

remove this commit

* not so many permission denyed errors in test.yml

* Fix #404 (hopefully)

* revert Github-Actions workaround

* More tests

* event_handler allows for event supression

* more tests

* WIP Implement Key recording

* Start and Stop Injection

* context no longer stores preset

* restructured the RelToBtnHandler

* Simplified read_loop

* Implement async iterator for ipc.pipe

* multiple event actions

* helper now implements mapping handlers to read inputs all with async

* updated and simplified reader 

the helper uses the mapping handlers, so the reader now can be much simpler

* Fixed race condition in tests

* implemented DataBus

* Fixed a UIMapping bug where the last_error would not be deleted

* added a immutable variant of the UIMapping

* updated data_manager to use data_bus

* Uptdated tests to use the DataBus

* Gui uses DataBus

* removed EventHandler

* Renamed controller methods

* Implemented recording toggle

* implemented StatusBar

* Sending validation errors to status bar

* sending injection status to status bar

* proper preset renaming

* implemented copy preset in the data manager

* implemented copy_preset in controller

* fixed a bug where a wron selection lable would update

* no longer send invalid data over the bus, if the preset or group changes

* Implement create and delete mapping

* Allow for frontend specific mapping defaults

* implemented autoload toggle

* cleanup user_interface

* removed editor

* Docstings renaming and ordering of methods

* more simplifications to user_interface

* integrated backend into data_manager

* removed active preset

* transformation tests

* controller tests

* fix missing uinputs in gui

* moved some tests and implemented basic tests for mapping handlers

* docstring reformatting

Co-authored-by: Tobi <proxima@sezanzeb.de>

* allow for empty groups

* docstring

* fixed TestGroupFromHelper

* some work on integration tests

* test for annoying import error in tests

* testing if test_user_interface works

* I feel lucky

* not so lucky

* some more tests

* fixed but where the group_key was used as folder name

* Fixed a bug where state=NO_GRAB would never be read from the injector

* allow to stop the recorder

* working on integration tests

* integration tests

* fixed more integration tests

* updated coveragerc

* no longer attempt to record keys when injecting

* event_reader cleans up not finished tasks

* More integration tests

* All tests pass

* renamed data_bus

* WIP fixing typing issues

* more typing fixes

* added keyboard+mouse device to tests

* cleanup imports

* new read loop because the evdev async read loop can not be cancelled

* Added field to modify mapping name

* created tests for components

* even more component tests

* do component tests need a screen?

* apparently they do :_(

* created release_input switch

* Don't record relative axis when movement is slow

* show delete dialog above main window

* wip basic dialog to edit combination

* some gui changes to the combination-editor

* Simple implementation of CombinationListbox

* renamed attach_to_events method and mark as private

* shorter str() for UInputsData

* moved logic to generate readable event string from combination to event

* new mapping parameter force release timeout

this helps with the helper when recording multiple relative axis at once

* make it possible to rearange the event_combination

* more work on the combination editor

* tests for DataManager.load_event

* simplyfied test_controller

* more controller tests

* Implement input threshold in gui

* greater range for time dependent unit test

* implemented a output-axis selector

* data_manager now provides injector state

* black

* mypy

* Updated confirm cancel dialog

* created release timeout input

* implemented transformation graph

* Added sliders for gain, expo and deadzone

* fix bug where the system_mapping was overridden in each injector thread

* updated slider settings

* removed debug statement

* explicitly checking output code against None (0 is a valid code)

* usage

* Allow for multiple axis to be activated by same button

* readme

* only warn about not implemented mapping-handler

don't fail to create event-pipelines

* More accurate event names

* Allow removal of single events from the input-combination

* rename callback to notify_callback

* rename event message to selected_event

* made read_continuisly private

* typing for autocompletion

* docstrings for message_broker messages

* make components methods and propreties private

* gui spacings

* removed eval

* make some controller functions private

* move status message generation from data_manager to controller

* parse mapping errors in controller for more helpful messages

* remove system_mapping from code editor

* More component tests

* more tests

* mypy

* make grab_devices less greedy (partial mitigation for #435)

only grab one device if there are multiple which can satisfy the same mapping

* accumulate more values in test

* docstrings

* Updated status messages

* comments, docstrings, imports

Co-authored-by: Tobi <proxima@sezanzeb.de>
pull/439/head
jonasBoss 2 years ago committed by GitHub
parent e067c4c50f
commit 3637204bff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -6,3 +6,12 @@ debug = multiproc
omit = omit =
# not used currently due to problems # not used currently due to problems
/usr/lib/python3.9/site-packages/inputremapper/ipc/socket.py /usr/lib/python3.9/site-packages/inputremapper/ipc/socket.py
[report]
exclude_lines =
pragma: no cover
# Don't complain about abstract methods, they aren't run:
@(abc\.)?abstractmethod
# Don't cover Protocol classes
class .*\(.*Protocol.*\):

@ -20,7 +20,7 @@ jobs:
run: | run: |
# Install deps as root since we will run tests as root # Install deps as root since we will run tests as root
sudo scripts/ci-install-deps.sh sudo scripts/ci-install-deps.sh
sudo pip install . sudo pip install --no-binary :all: .
- name: Run tests - name: Run tests
run: | run: |
# FIXME: Had some permissions issues, currently worked around by running tests as root # FIXME: Had some permissions issues, currently worked around by running tests as root

@ -16,10 +16,9 @@
#### Known Issues (Beta Branch) #### Known Issues (Beta Branch)
* The GUI is currently is not adapted to reflect all new features and might behave strange. * Mapping relative axis to relative axis (mouse to mouse) is not possible
Mapping a gamepad to mouse is currently not possible in the GUI. * Mapping relative axis to absolute axis (mouse to gamepad) is not possible
Also mapping joystick or mouse axis to buttons might not work. * Mapping absolute axis to absolute axis (gamepad to gamepad) is not possible
Those are only limitations of the GUI, when editing the preset manually all those features are still available.
## Installation ## Installation

@ -18,13 +18,15 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Starts the user interface.""" """Starts the user interface."""
from __future__ import annotations
import os
import sys import sys
import atexit import atexit
import logging
from argparse import ArgumentParser from argparse import ArgumentParser
from inputremapper.gui.gettext import _, LOCALE_DIR from inputremapper.gui.gettext import _, LOCALE_DIR
import gi import gi
@ -38,7 +40,24 @@ from gi.repository import Gtk
Gtk.init() Gtk.init()
from inputremapper.logger import logger, update_verbosity, log_info from inputremapper.logger import logger, update_verbosity, log_info
from inputremapper.configs.migrations import migrate
def start_processes() -> DaemonProxy:
"""Start helper and daemon via pkexec to run in the background."""
# this function is overwritten in tests
daemon = Daemon.connect()
debug = " -d" if logger.level <= logging.DEBUG else ""
cmd = f"pkexec input-remapper-control --command helper {debug}"
logger.debug("Running `%s`", cmd)
exit_code = os.system(cmd)
if exit_code != 0:
logger.error("Failed to pkexec the helper, code %d", exit_code)
sys.exit(11)
return daemon
if __name__ == '__main__': if __name__ == '__main__':
@ -55,21 +74,42 @@ if __name__ == '__main__':
logger.debug('Using locale directory: {}'.format(LOCALE_DIR)) logger.debug('Using locale directory: {}'.format(LOCALE_DIR))
# import input-remapper stuff after setting the log verbosity # import input-remapper stuff after setting the log verbosity
from inputremapper.gui.message_broker import MessageBroker, MessageType
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.data_manager import DataManager
from inputremapper.gui.user_interface import UserInterface from inputremapper.gui.user_interface import UserInterface
from inputremapper.daemon import Daemon from inputremapper.gui.controller import Controller
from inputremapper.configs.global_config import global_config from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.groups import _Groups
from inputremapper.gui.reader import Reader
from inputremapper.daemon import Daemon, DaemonProxy
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.configs.migrations import migrate
migrate() migrate()
global_config.load_config()
user_interface = UserInterface() message_broker = MessageBroker()
# create the reader before we start the helper (start_processes) otherwise it
# can come to race conditions with the creation of pipes
reader = Reader(message_broker, _Groups())
daemon = start_processes()
data_manager = DataManager(
message_broker, GlobalConfig(), reader, daemon, GlobalUInputs(), system_mapping
)
controller = Controller(message_broker, data_manager)
user_interface = UserInterface(message_broker, controller)
controller.set_gui(user_interface)
message_broker.signal(MessageType.init)
def stop(): def stop():
if isinstance(user_interface.dbus, Daemon): if isinstance(daemon, Daemon):
# have fun debugging completely unrelated tests if you remove this # have fun debugging completely unrelated tests if you remove this
user_interface.dbus.stop_all() daemon.stop_all()
user_interface.on_close() controller.close()
atexit.register(stop) atexit.register(stop)

@ -29,6 +29,7 @@ import signal
from argparse import ArgumentParser from argparse import ArgumentParser
from inputremapper.logger import update_verbosity from inputremapper.logger import update_verbosity
from inputremapper.groups import _Groups
if __name__ == '__main__': if __name__ == '__main__':
@ -53,6 +54,6 @@ if __name__ == '__main__':
os.kill(os.getpid(), signal.SIGKILL) os.kill(os.getpid(), signal.SIGKILL)
atexit.register(on_exit) atexit.register(on_exit)
groups = _Groups()
helper = RootHelper() helper = RootHelper(groups)
helper.run() helper.run()

File diff suppressed because it is too large Load Diff

@ -23,7 +23,6 @@ which is perfect. */
} }
list entry { list entry {
background-color: transparent;
border-radius: 4px; border-radius: 4px;
border: 0px; border: 0px;
box-shadow: none; box-shadow: none;

@ -22,14 +22,14 @@
"""Get stuff from /usr/share/input-remapper, depending on the prefix.""" """Get stuff from /usr/share/input-remapper, depending on the prefix."""
import sys
import os import os
import site import site
import sys
import pkg_resources import pkg_resources
from inputremapper.logger import logger from inputremapper.logger import logger
logged = False logged = False

@ -19,13 +19,13 @@
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Store which presets should be enabled for which device on login.""" """Store which presets should be enabled for which device on login."""
import os
import json
import copy import copy
import json
import os
from inputremapper.configs.base_config import ConfigBase, INITIAL_CONFIG
from inputremapper.configs.paths import CONFIG_PATH, USER, touch from inputremapper.configs.paths import CONFIG_PATH, USER, touch
from inputremapper.logger import logger from inputremapper.logger import logger
from inputremapper.configs.base_config import ConfigBase, INITIAL_CONFIG
MOUSE = "mouse" MOUSE = "mouse"
WHEEL = "wheel" WHEEL = "wheel"
@ -45,6 +45,10 @@ class GlobalConfig(ConfigBase):
self.path = os.path.join(CONFIG_PATH, "config.json") self.path = os.path.join(CONFIG_PATH, "config.json")
super().__init__() super().__init__()
def get_dir(self) -> str:
"""the folder containing this config"""
return os.path.split(self.path)[0]
def set_autoload_preset(self, group_key, preset): def set_autoload_preset(self, group_key, preset):
"""Set a preset to be automatically applied on start. """Set a preset to be automatically applied on start.
Parameters Parameters

@ -18,45 +18,62 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations from __future__ import annotations
import enum import enum
from typing import Optional, Callable, Tuple, Dict, Any, TypeVar
import pkg_resources
from evdev.ecodes import EV_KEY, EV_ABS, EV_REL from evdev.ecodes import EV_KEY, EV_ABS, EV_REL
from pydantic import ( from pydantic import (
BaseModel, BaseModel,
PositiveInt, PositiveInt,
confloat, confloat,
conint,
root_validator, root_validator,
validator, validator,
ValidationError, ValidationError,
PositiveFloat, PositiveFloat,
VERSION, VERSION,
BaseConfig,
) )
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.configs.system_mapping import system_mapping
from inputremapper.event_combination import EventCombination
from inputremapper.exceptions import MacroParsingError from inputremapper.exceptions import MacroParsingError
from inputremapper.gui.message_broker import MessageType
from inputremapper.injection.macros.parse import is_this_a_macro, parse from inputremapper.injection.macros.parse import is_this_a_macro, parse
from inputremapper.input_event import EventActions from inputremapper.input_event import EventActions
# TODO: remove pydantic VERSION check as soon as we no longer support # TODO: remove pydantic VERSION check as soon as we no longer support
# Ubuntu 20.04 and with it the ainchant pydantic 1.2 # Ubuntu 20.04 and with it the ainchant pydantic 1.2
needs_workaround = pkg_resources.parse_version( needs_workaround = pkg_resources.parse_version(
str(VERSION) str(VERSION)
) < pkg_resources.parse_version("1.7.1") ) < pkg_resources.parse_version("1.7.1")
# TODO: in python 3.11 inherit enum.StrEnum
class KnownUinput(str, enum.Enum): class KnownUinput(str, enum.Enum):
keyboard = "keyboard" keyboard = "keyboard"
mouse = "mouse" mouse = "mouse"
gamepad = "gamepad" gamepad = "gamepad"
keyboard_mouse = "keyboard + mouse"
CombinationChangedCallback = Optional[ CombinationChangedCallback = Optional[
Callable[[EventCombination, EventCombination], None] Callable[[EventCombination, EventCombination], None]
] ]
MappingModel = TypeVar("MappingModel", bound="Mapping")
class Cfg(BaseConfig):
validate_assignment = True
use_enum_values = True
underscore_attrs_are_private = True
json_encoders = {EventCombination: lambda v: v.json_str()}
class ImmutableCfg(Cfg):
allow_mutation = False
class Mapping(BaseModel): class Mapping(BaseModel):
@ -75,12 +92,14 @@ class Mapping(BaseModel):
output_type: Optional[int] = None # The event type of the mapped event output_type: Optional[int] = None # The event type of the mapped event
output_code: Optional[int] = None # The event code of the mapped event output_code: Optional[int] = None # The event code of the mapped event
name: Optional[str] = None
# if release events will be sent to the forwarded device as soon as a combination # if release events will be sent to the forwarded device as soon as a combination
# triggers see also #229 # triggers see also #229
release_combination_keys: bool = True release_combination_keys: bool = True
# macro settings # macro settings
macro_key_sleep_ms: PositiveInt = 0 macro_key_sleep_ms: conint(ge=0) = 0 # type: ignore
# Optional attributes for mapping Axis to Axis # Optional attributes for mapping Axis to Axis
# The deadzone of the input axis # The deadzone of the input axis
@ -99,6 +118,13 @@ class Mapping(BaseModel):
rel_input_cutoff: PositiveInt = 100 rel_input_cutoff: PositiveInt = 100
# the time until a relative axis is considered stationary if no new events arrive # the time until a relative axis is considered stationary if no new events arrive
release_timeout: PositiveFloat = 0.05 release_timeout: PositiveFloat = 0.05
# don't release immediately when a relative axis drops below the speed threshold
# instead wait until it dropped for loger than release_timeout below the threshold
force_release_timeout: bool = False
def is_axis_mapping(self) -> bool:
"""whether this mapping specifies an output axis"""
return self.output_type == EV_ABS or self.output_type == EV_REL
# callback which gets called if the event_combination is updated # callback which gets called if the event_combination is updated
if not needs_workaround: if not needs_workaround:
@ -142,8 +168,9 @@ class Mapping(BaseModel):
if needs_workaround: if needs_workaround:
# https://github.com/samuelcolvin/pydantic/issues/1383 # https://github.com/samuelcolvin/pydantic/issues/1383
def copy(self, *args, **kwargs) -> Mapping: def copy(self: MappingModel, *args, **kwargs) -> MappingModel:
copy = super(Mapping, self).copy(*args, deep=True, **kwargs) kwargs["deep"] = True
copy = super(Mapping, self).copy(*args, **kwargs)
object.__setattr__(copy, "_combination_changed", self._combination_changed) object.__setattr__(copy, "_combination_changed", self._combination_changed)
return copy return copy
@ -218,11 +245,11 @@ class Mapping(BaseModel):
@validator("event_combination") @validator("event_combination")
def set_event_actions(cls, combination): def set_event_actions(cls, combination):
"""Sets the correct action for each event.""" """Sets the correct actions for each event."""
new_combination = [] new_combination = []
for event in combination: for event in combination:
if event.value != 0: if event.value != 0:
event = event.modify(action=EventActions.as_key) event = event.modify(actions=(EventActions.as_key,))
new_combination.append(event) new_combination.append(event)
return EventCombination(new_combination) return EventCombination(new_combination)
@ -267,7 +294,7 @@ class Mapping(BaseModel):
@root_validator @root_validator
def output_axis_given(cls, values): def output_axis_given(cls, values):
"""Validate that an output type is an axis if we have an input axis.""" """Validate that an output type is an axis if we have an input axis."""
combination = values.get("event_combination") combination: EventCombination = values.get("event_combination")
output_type = values.get("output_type") output_type = values.get("output_type")
event_values = [event.value for event in combination] event_values = [event.value for event in combination]
if 0 not in event_values: if 0 not in event_values:
@ -275,18 +302,14 @@ class Mapping(BaseModel):
if output_type not in (EV_ABS, EV_REL): if output_type not in (EV_ABS, EV_REL):
raise ValueError( raise ValueError(
f"missing output axis: "
f"the {combination = } specifies a input axis, " f"the {combination = } specifies a input axis, "
f"but the {output_type = } is not an axis " f"but the {output_type = } is not an axis "
) )
return values return values
class Config: Config = Cfg
validate_assignment = True
use_enum_values = True
underscore_attrs_are_private = True
json_encoders = {EventCombination: lambda v: v.json_str()}
class UIMapping(Mapping): class UIMapping(Mapping):
@ -308,12 +331,11 @@ class UIMapping(Mapping):
def __init__(self, **data): # type: ignore def __init__(self, **data): # type: ignore
object.__setattr__(self, "_last_error", None) object.__setattr__(self, "_last_error", None)
super().__init__( super().__init__(
event_combination="99,99,99", event_combination=EventCombination.empty_combination(),
target_uinput="keyboard", target_uinput="keyboard",
output_symbol="KEY_A", output_symbol="KEY_A",
) )
cache = { cache = {
"event_combination": None,
"target_uinput": None, "target_uinput": None,
"output_symbol": None, "output_symbol": None,
} }
@ -330,6 +352,7 @@ class UIMapping(Mapping):
super(UIMapping, self).__setattr__(key, value) super(UIMapping, self).__setattr__(key, value)
if key in self._cache: if key in self._cache:
del self._cache[key] del self._cache[key]
self._last_error = None
except ValidationError as error: except ValidationError as error:
# cache the value # cache the value
@ -369,10 +392,23 @@ class UIMapping(Mapping):
return dict_ return dict_
def copy(self: MappingModel, *args, **kwargs) -> MappingModel:
# we always need a deep copy otherwise the _cache of the copy will
# point to the same address
kwargs["deep"] = True
# seems to be related: https://github.com/python/mypy/issues/9282
copy = super().copy(*args, **kwargs) # type: ignore
object.__setattr__(copy, "_combination_changed", self._combination_changed)
return copy
def get_error(self) -> Optional[ValidationError]: def get_error(self) -> Optional[ValidationError]:
"""The validation error or None.""" """The validation error or None."""
return self._last_error return self._last_error
def get_bus_message(self) -> MappingData:
"""return a immutable copy for use in the"""
return MappingData(**self.dict())
def _validate(self) -> None: def _validate(self) -> None:
"""Try to validate the mapping.""" """Try to validate the mapping."""
if self.is_valid(): if self.is_valid():
@ -397,3 +433,18 @@ class UIMapping(Mapping):
self._cache["event_combination"] = EventCombination.validate( self._cache["event_combination"] = EventCombination.validate(
self._cache["event_combination"] self._cache["event_combination"]
) )
class MappingData(UIMapping):
Config = ImmutableCfg
message_type = MessageType.mapping # allow this to be sent over the MessageBroker
def __str__(self):
return str(self.dict(exclude_defaults=True))
def dict(self, *args, **kwargs):
"""will not include the message_type"""
dict_ = super(MappingData, self).dict(*args, **kwargs)
if "message_type" in dict_:
del dict_["message_type"]
return dict_

@ -21,15 +21,15 @@
"""Migration functions""" """Migration functions"""
import copy
import json
import os import os
import re import re
import json
import copy
import shutil import shutil
import pkg_resources
from typing import List
from pathlib import Path from pathlib import Path
from typing import Iterator, Tuple, Dict
import pkg_resources
from evdev.ecodes import ( from evdev.ecodes import (
EV_KEY, EV_KEY,
EV_ABS, EV_ABS,
@ -44,24 +44,23 @@ from evdev.ecodes import (
REL_HWHEEL_HI_RES, REL_HWHEEL_HI_RES,
) )
from inputremapper.configs.preset import Preset
from inputremapper.configs.mapping import Mapping, UIMapping 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, remove from inputremapper.configs.paths import get_preset_path, mkdir, CONFIG_PATH, remove
from inputremapper.configs.preset import Preset
from inputremapper.configs.system_mapping import system_mapping from inputremapper.configs.system_mapping import system_mapping
from inputremapper.event_combination import EventCombination
from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.injection.global_uinputs import global_uinputs
from inputremapper.injection.macros.parse import is_this_a_macro from inputremapper.injection.macros.parse import is_this_a_macro
from inputremapper.logger import logger, VERSION, IS_BETA
from inputremapper.user import HOME
def all_presets() -> List[os.PathLike]: def all_presets() -> Iterator[Tuple[os.PathLike, Dict]]:
"""Get all presets for all groups as list.""" """Get all presets for all groups as list."""
if not os.path.exists(get_preset_path()): if not os.path.exists(get_preset_path()):
return [] return
preset_path = Path(get_preset_path()) preset_path = Path(get_preset_path())
presets = []
for folder in preset_path.iterdir(): for folder in preset_path.iterdir():
if not folder.is_dir(): if not folder.is_dir():
continue continue
@ -78,8 +77,6 @@ def all_presets() -> List[os.PathLike]:
logger.warning('Invalid json format in preset "%s"', preset) logger.warning('Invalid json format in preset "%s"', preset)
continue continue
return presets
def config_version(): def config_version():
"""Get the version string in config.json as packaging.Version object.""" """Get the version string in config.json as packaging.Version object."""

@ -25,6 +25,7 @@
import os import os
import shutil import shutil
from typing import List, Union
from inputremapper.logger import logger, VERSION, IS_BETA from inputremapper.logger import logger, VERSION, IS_BETA
from inputremapper.user import USER, HOME from inputremapper.user import USER, HOME
@ -44,9 +45,9 @@ def chown(path):
shutil.chown(path, user=USER) shutil.chown(path, user=USER)
def touch(path, log=True): def touch(path: os.PathLike, log=True):
"""Create an empty file and all its parent dirs, give it to the user.""" """Create an empty file and all its parent dirs, give it to the user."""
if path.endswith("/"): if str(path).endswith("/"):
raise ValueError(f"Expected path to not end with a slash: {path}") raise ValueError(f"Expected path to not end with a slash: {path}")
if os.path.exists(path): if os.path.exists(path):
@ -81,6 +82,23 @@ def mkdir(path, log=True):
chown(path) chown(path)
def split_all(path: Union[os.PathLike, str]) -> List[str]:
parts = []
while True:
path, tail = os.path.split(path)
parts.append(tail)
if path == os.path.sep:
# we arrived at the root '/'
parts.append(path)
break
if not path:
# arrived at start of relative path
break
parts.reverse()
return parts
def remove(path): def remove(path):
"""Remove whatever is at the path.""" """Remove whatever is at the path."""
if not os.path.exists(path): if not os.path.exists(path):
@ -103,8 +121,8 @@ def get_preset_path(group_name=None, preset=None):
# the extension of the preset should not be shown in the ui. # the extension of the preset should not be shown in the ui.
# if a .json extension arrives this place, it has not been # if a .json extension arrives this place, it has not been
# stripped away properly prior to this. # stripped away properly prior to this.
assert not preset.endswith(".json") if not preset.endswith(".json"):
preset = f"{preset}.json" preset = f"{preset}.json"
if preset is None: if preset is None:
return os.path.join(presets_base, group_name) return os.path.join(presets_base, group_name)

@ -22,21 +22,28 @@ from __future__ import annotations
"""Contains and manages mappings.""" """Contains and manages mappings."""
import os import os
import re
import json import json
import glob
import time
from typing import Tuple, Dict, List, Optional, Iterator, Type, Iterable, Any, Union from typing import (
Tuple,
Dict,
List,
Optional,
Iterator,
Type,
Iterable,
TypeVar,
Generic,
overload,
)
from pydantic import ValidationError from pydantic import ValidationError
from inputremapper.logger import logger from inputremapper.logger import logger
from inputremapper.configs.mapping import Mapping from inputremapper.configs.mapping import Mapping
from inputremapper.configs.paths import touch, get_preset_path, mkdir from inputremapper.configs.paths import touch
from inputremapper.input_event import InputEvent from inputremapper.input_event import InputEvent
from inputremapper.event_combination import EventCombination from inputremapper.event_combination import EventCombination
from inputremapper.groups import groups
def common_data(list1: Iterable, list2: Iterable) -> List: def common_data(list1: Iterable, list2: Iterable) -> List:
@ -52,32 +59,50 @@ def common_data(list1: Iterable, list2: Iterable) -> List:
return common return common
class Preset: MappingModel = TypeVar("MappingModel", bound=Mapping)
class Preset(Generic[MappingModel]):
"""Contains and manages mappings of a single preset.""" """Contains and manages mappings of a single preset."""
_mappings: Dict[EventCombination, Mapping] # workaround for typing: https://github.com/python/mypy/issues/4236
# a copy of mappings for keeping track of changes @overload
_saved_mappings: Dict[EventCombination, Mapping] def __init__(self: Preset[Mapping], path: Optional[os.PathLike] = None):
_path: Optional[os.PathLike] ...
_mapping_factpry: Type[Mapping] # the mapping class which is used by load()
@overload
def __init__( def __init__(
self, self,
path: Optional[os.PathLike] = None, path: Optional[os.PathLike] = None,
mapping_factory: Type[Mapping] = Mapping, mapping_factory: Type[MappingModel] = ...,
):
...
def __init__(
self,
path: Optional[os.PathLike] = None,
mapping_factory=Mapping,
) -> None: ) -> None:
self._mappings = {} self._mappings: Dict[EventCombination, MappingModel] = {}
self._saved_mappings = {} # a copy of mappings for keeping track of changes
self._path = path self._saved_mappings: Dict[EventCombination, MappingModel] = {}
self._mapping_factory = mapping_factory self._path: Optional[os.PathLike] = path
# the mapping class which is used by load()
self._mapping_factory: Type[MappingModel] = mapping_factory
def __iter__(self) -> Iterator[Mapping]: def __iter__(self) -> Iterator[MappingModel]:
"""Iterate over Mapping objects.""" """Iterate over Mapping objects."""
return iter(self._mappings.values()) return iter(self._mappings.values())
def __len__(self) -> int: def __len__(self) -> int:
return len(self._mappings) return len(self._mappings)
def __bool__(self):
# otherwise __len__ will be used which results in False for a preset
# without mappings
return True
def has_unsaved_changes(self) -> bool: def has_unsaved_changes(self) -> bool:
"""Check if there are unsaved changed.""" """Check if there are unsaved changed."""
return self._mappings != self._saved_mappings return self._mappings != self._saved_mappings
@ -101,7 +126,7 @@ class Preset:
logger.debug(f"unable to remove non-existing mapping with {combination = }") logger.debug(f"unable to remove non-existing mapping with {combination = }")
pass pass
def add(self, mapping: Mapping) -> None: def add(self, mapping: MappingModel) -> None:
"""Add a mapping to the preset.""" """Add a mapping to the preset."""
for permutation in mapping.event_combination.get_permutations(): for permutation in mapping.event_combination.get_permutations():
if permutation in self._mappings: if permutation in self._mappings:
@ -137,7 +162,7 @@ class Preset:
self._saved_mappings = self._get_mappings_from_disc() self._saved_mappings = self._get_mappings_from_disc()
self.empty() self.empty()
for mapping in self._saved_mappings.values(): for mapping in self._saved_mappings.values():
# use the external add method to make sure # use the public add method to make sure
# the _combination_changed_callback is attached # the _combination_changed_callback is attached
self.add(mapping.copy()) self.add(mapping.copy())
@ -148,8 +173,9 @@ class Preset:
logger.debug("unable to save preset without a path set Preset.path first") logger.debug("unable to save preset without a path set Preset.path first")
return return
touch(str(self.path)) # touch expects a string, not a Posix path touch(self.path)
if not self.has_unsaved_changes(): if not self.has_unsaved_changes():
logger.debug("Not saving unchanged preset")
return return
logger.info("Saving preset to %s", self.path) logger.info("Saving preset to %s", self.path)
@ -158,7 +184,10 @@ class Preset:
saved_mappings = {} saved_mappings = {}
for mapping in self: for mapping in self:
if not mapping.is_valid(): if not mapping.is_valid():
if not isinstance(mapping.event_combination, EventCombination): if (
not isinstance(mapping.event_combination, EventCombination)
or mapping.event_combination == EventCombination.empty_combination()
):
# we save invalid mapping except for those with # we save invalid mapping except for those with
# invalid event_combination # invalid event_combination
logger.debug("skipping invalid mapping %s", mapping) logger.debug("skipping invalid mapping %s", mapping)
@ -189,7 +218,9 @@ class Preset:
def is_valid(self) -> bool: def is_valid(self) -> bool:
return False not in [mapping.is_valid() for mapping in self] return False not in [mapping.is_valid() for mapping in self]
def get_mapping(self, combination: Optional[EventCombination]) -> Optional[Mapping]: def get_mapping(
self, combination: Optional[EventCombination]
) -> Optional[MappingModel]:
"""Return the Mapping that is mapped to this EventCombination. """Return the Mapping that is mapped to this EventCombination.
Parameters Parameters
---------- ----------
@ -246,12 +277,16 @@ class Preset:
return return
self._saved_mappings = self._get_mappings_from_disc() self._saved_mappings = self._get_mappings_from_disc()
def _get_mappings_from_disc(self) -> Dict[EventCombination, Mapping]: def _get_mappings_from_disc(self) -> Dict[EventCombination, MappingModel]:
mappings: Dict[EventCombination, Mapping] = {} mappings: Dict[EventCombination, MappingModel] = {}
if not self.path: if not self.path:
logger.debug("unable to read preset without a path set Preset.path first") logger.debug("unable to read preset without a path set Preset.path first")
return mappings return mappings
if os.stat(self.path).st_size == 0:
logger.debug("got empty file")
return mappings
with open(self.path, "r") as file: with open(self.path, "r") as file:
try: try:
preset_dict = json.load(file) preset_dict = json.load(file)
@ -285,150 +320,8 @@ class Preset:
self._path = path self._path = path
self._update_saved_mappings() self._update_saved_mappings()
@property
########################################################################### def name(self) -> Optional[str]:
# Method from previously presets.py if self.path:
# TODO: See what can be implemented as classmethod or return os.path.basename(self.path).split(".")[0]
# member function of Preset return None
###########################################################################
def get_available_preset_name(group_name, preset="new preset", copy=False):
"""Increment the preset name until it is available."""
if group_name is None:
# endless loop otherwise
raise ValueError("group_name may not be None")
preset = preset.strip()
if copy and not re.match(r"^.+\scopy( \d+)?$", preset):
preset = f"{preset} copy"
# find a name that is not already taken
if os.path.exists(get_preset_path(group_name, preset)):
# if there already is a trailing number, increment it instead of
# adding another one
match = re.match(r"^(.+) (\d+)$", preset)
if match:
preset = match[1]
i = int(match[2]) + 1
else:
i = 2
while os.path.exists(get_preset_path(group_name, f"{preset} {i}")):
i += 1
return f"{preset} {i}"
return preset
def get_presets(group_name: str) -> List[str]:
"""Get all preset filenames for the device and user, starting with the newest.
Parameters
----------
group_name : string
"""
device_folder = get_preset_path(group_name)
mkdir(device_folder)
paths = glob.glob(os.path.join(device_folder, "*.json"))
presets = [
os.path.splitext(os.path.basename(path))[0]
for path in sorted(paths, key=os.path.getmtime)
]
# the highest timestamp to the front
presets.reverse()
return presets
def get_any_preset() -> Tuple[str | None, str | None]:
"""Return the first found tuple of (device, preset)."""
group_names = groups.list_group_names()
if len(group_names) == 0:
return None, None
any_device = list(group_names)[0]
any_preset = get_presets(any_device)
return any_device, any_preset[0] if any_preset else None
def find_newest_preset(group_name=None):
"""Get a tuple of (device, preset) that was most recently modified
in the users home directory.
If no device has been configured yet, return an arbitrary device.
Parameters
----------
group_name : string
If set, will return the newest preset for the device or None
"""
# sort the oldest files to the front in order to use pop to get the newest
if group_name is None:
paths = sorted(
glob.glob(os.path.join(get_preset_path(), "*/*.json")),
key=os.path.getmtime,
)
else:
paths = sorted(
glob.glob(os.path.join(get_preset_path(group_name), "*.json")),
key=os.path.getmtime,
)
if len(paths) == 0:
logger.debug("No presets found")
return get_any_preset()
group_names = groups.list_group_names()
newest_path = None
while len(paths) > 0:
# take the newest path
path = paths.pop()
preset = os.path.split(path)[1]
group_name = os.path.split(os.path.split(path)[0])[1]
if group_name in group_names:
newest_path = path
break
if newest_path is None:
return get_any_preset()
preset = os.path.splitext(preset)[0]
logger.debug('The newest preset is "%s", "%s"', group_name, preset)
return group_name, preset
def delete_preset(group_name, preset):
"""Delete one of the users presets."""
preset_path = get_preset_path(group_name, preset)
if not os.path.exists(preset_path):
logger.debug('Cannot remove non existing path "%s"', preset_path)
return
logger.info('Removing "%s"', preset_path)
os.remove(preset_path)
device_path = get_preset_path(group_name)
if os.path.exists(device_path) and len(os.listdir(device_path)) == 0:
logger.debug('Removing empty dir "%s"', device_path)
os.rmdir(device_path)
def rename_preset(group_name, old_preset_name, new_preset_name):
"""Rename one of the users presets while avoiding name conflicts."""
if new_preset_name == old_preset_name:
return 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)
os.rename(
get_preset_path(group_name, old_preset_name),
get_preset_path(group_name, new_preset_name),
)
# set the modification date to now
now = time.time()
os.utime(get_preset_path(group_name, new_preset_name), (now, now))
return new_preset_name

@ -19,13 +19,15 @@
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Make the systems/environments mapping of keys and codes accessible.""" """Make the systems/environments mapping of keys and codes accessible."""
import re
import json import json
import re
import subprocess import subprocess
from typing import Optional, List, Iterable
import evdev import evdev
from inputremapper.logger import logger
from inputremapper.configs.paths import get_config_path, touch from inputremapper.configs.paths import get_config_path, touch
from inputremapper.logger import logger
from inputremapper.utils import is_service from inputremapper.utils import is_service
DISABLE_NAME = "disable" DISABLE_NAME = "disable"
@ -64,7 +66,7 @@ class SystemMapping:
return object.__getattribute__(self, wanted) return object.__getattribute__(self, wanted)
def list_names(self, codes=None): def list_names(self, codes: Optional[Iterable[int]] = None) -> List[str]:
"""Return a list of all possible names in the mapping, optionally filtered by codes. """Return a list of all possible names in the mapping, optionally filtered by codes.
Parameters Parameters

@ -25,14 +25,16 @@ https://github.com/LEW21/pydbus/tree/cc407c8b1d25b7e28a6d661a29f9e661b1c9b964/ex
""" """
import atexit
import json
import os import os
import sys import sys
import json
import time import time
import atexit from pathlib import PurePath
from typing import Protocol, Dict
from pydbus import SystemBus
import gi import gi
from pydbus import SystemBus
gi.require_version("GLib", "2.0") gi.require_version("GLib", "2.0")
from gi.repository import GLib from gi.repository import GLib
@ -116,6 +118,34 @@ def remove_timeout(func):
return wrapped return wrapped
class DaemonProxy(Protocol): # pragma: no cover
"""the interface provided over the dbus"""
def stop_injecting(self, group_key: str) -> None:
...
def get_state(self, group_key: str) -> int:
...
def start_injecting(self, group_key: str, preset: str) -> bool:
...
def stop_all(self) -> None:
...
def set_config_dir(self, config_dir: str) -> None:
...
def autoload(self) -> None:
...
def autoload_single(self, group_key: str) -> None:
...
def hello(self, out: str) -> str:
...
class Daemon: class Daemon:
"""Starts injecting keycodes based on the configuration. """Starts injecting keycodes based on the configuration.
@ -164,7 +194,7 @@ class Daemon:
def __init__(self): def __init__(self):
"""Constructs the daemon.""" """Constructs the daemon."""
logger.debug("Creating daemon") logger.debug("Creating daemon")
self.injectors = {} self.injectors: Dict[str, Injector] = {}
self.config_dir = None self.config_dir = None
@ -184,7 +214,7 @@ class Daemon:
macro_variables.start() macro_variables.start()
@classmethod @classmethod
def connect(cls, fallback=True): def connect(cls, fallback=True) -> DaemonProxy:
"""Get an interface to start and stop injecting keystrokes. """Get an interface to start and stop injecting keystrokes.
Parameters Parameters
@ -193,8 +223,8 @@ class Daemon:
If true, returns an instance of the daemon instead if it cannot If true, returns an instance of the daemon instead if it cannot
connect connect
""" """
bus = SystemBus()
try: try:
bus = SystemBus()
interface = bus.get(BUS_NAME, timeout=BUS_TIMEOUT) interface = bus.get(BUS_NAME, timeout=BUS_TIMEOUT)
logger.info("Connected to the service") logger.info("Connected to the service")
except GLib.GError as error: except GLib.GError as error:
@ -306,7 +336,7 @@ class Daemon:
This path contains config.json, xmodmap.json and the This path contains config.json, xmodmap.json and the
presets directory presets directory
""" """
config_path = os.path.join(config_dir, "config.json") config_path = PurePath(config_dir, "config.json")
if not os.path.exists(config_path): if not os.path.exists(config_path):
logger.error('"%s" does not exist', config_path) logger.error('"%s" does not exist', config_path)
return return
@ -405,7 +435,7 @@ class Daemon:
for group_key, _ in autoload_presets: for group_key, _ in autoload_presets:
self._autoload(group_key) self._autoload(group_key)
def start_injecting(self, group_key, preset): def start_injecting(self, group_key, preset) -> bool:
"""Start injecting the preset for the device. """Start injecting the preset for the device.
Returns True on success. If an injection is already ongoing for Returns True on success. If an injection is already ongoing for
@ -435,7 +465,7 @@ class Daemon:
logger.error('Could not find group "%s"', group_key) logger.error('Could not find group "%s"', group_key)
return False return False
preset_path = os.path.join( preset_path = PurePath(
self.config_dir, self.config_dir,
"presets", "presets",
group.name, group.name,
@ -453,6 +483,13 @@ class Daemon:
# date when the system layout changes. # date when the system layout changes.
xmodmap = json.load(file) xmodmap = json.load(file)
logger.debug('Using keycodes from "%s"', xmodmap_path) logger.debug('Using keycodes from "%s"', xmodmap_path)
# this creates the system_mapping._xmodmap, which we need to do now
# otherwise it might be created later which will override the changes
# we do here.
# Do we really need to lazyload in the system_mapping?
# this kind of bug is stupid to track down
system_mapping.get_name(0)
system_mapping.update(xmodmap) system_mapping.update(xmodmap)
# the service now has process wide knowledge of xmodmap # the service now has process wide knowledge of xmodmap
# keys of the users session # keys of the users session

@ -20,14 +20,11 @@
from __future__ import annotations from __future__ import annotations
import itertools import itertools
from typing import Tuple, Iterable, Union, List, Callable, Sequence from typing import Tuple, Iterable, Union, Callable, Sequence
from evdev import ecodes from evdev import ecodes
from inputremapper.logger import logger
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.input_event import InputEvent, InputEventValidationType from inputremapper.input_event import InputEvent, InputEventValidationType
# having shift in combinations modifies the configured output, # having shift in combinations modifies the configured output,
@ -64,12 +61,17 @@ class EventCombination(Tuple[InputEvent]):
for event in events: for event in events:
validated_events.append(InputEvent.validate(event)) validated_events.append(InputEvent.validate(event))
if len(validated_events) == 0:
raise ValueError(f"failed to create EventCombination with {events = }")
# mypy bug: https://github.com/python/mypy/issues/8957 # mypy bug: https://github.com/python/mypy/issues/8957
# https://github.com/python/mypy/issues/8541 # https://github.com/python/mypy/issues/8541
return super().__new__(cls, validated_events) # type: ignore return super().__new__(cls, validated_events) # type: ignore
def __str__(self): def __str__(self):
# only used in tests and logging return " + ".join(event.description(exclude_threshold=True) for event in self)
def __repr__(self):
return f"<EventCombination {', '.join([str(e.event_tuple) for e in self])}>" return f"<EventCombination {', '.join([str(e.event_tuple) for e in self])}>"
@classmethod @classmethod
@ -105,7 +107,13 @@ class EventCombination(Tuple[InputEvent]):
except AttributeError: except AttributeError:
raise ValueError(f"failed to create EventCombination from {init_string = }") raise ValueError(f"failed to create EventCombination from {init_string = }")
def is_problematic(self): @classmethod
def empty_combination(cls) -> EventCombination:
"""a combination that has default invalid (to evdev) values useful for the
UI to indicate that this combination is not set"""
return cls("99,99,99")
def is_problematic(self) -> bool:
"""Is this combination going to work properly on all systems?""" """Is this combination going to work properly on all systems?"""
if len(self) <= 1: if len(self) <= 1:
return False return False
@ -119,6 +127,9 @@ class EventCombination(Tuple[InputEvent]):
return False return False
def has_input_axis(self) -> bool:
return False in (event.is_key_event for event in self)
def get_permutations(self): def get_permutations(self):
"""Get a list of EventCombination objects representing all possible permutations. """Get a list of EventCombination objects representing all possible permutations.
@ -139,93 +150,6 @@ class EventCombination(Tuple[InputEvent]):
def beautify(self) -> str: def beautify(self) -> str:
"""Get a human readable string representation.""" """Get a human readable string representation."""
result = [] if self == EventCombination.empty_combination():
return "empty_combination"
for event in self: return " + ".join(event.description(exclude_threshold=True) for event in self)
if event.type not in ecodes.bytype:
logger.error("Unknown type for %s", event)
result.append(str(event.code))
continue
if event.code not in ecodes.bytype[event.type]:
logger.error("Unknown combination code for %s", event)
result.append(str(event.code))
continue
key_name = None
# first try to find the name in xmodmap to not display wrong
# names due to the keyboard layout
if event.type == ecodes.EV_KEY:
key_name = system_mapping.get_name(event.code)
if key_name is None:
# if no result, look in the linux combination constants. On a german
# keyboard for example z and y are switched, which will therefore
# cause the wrong letter to be displayed.
key_name = ecodes.bytype[event.type][event.code]
if isinstance(key_name, list):
key_name = key_name[0]
if event.type != ecodes.EV_KEY:
direction = {
# D-Pad
(ecodes.ABS_HAT0X, -1): "Left",
(ecodes.ABS_HAT0X, 1): "Right",
(ecodes.ABS_HAT0Y, -1): "Up",
(ecodes.ABS_HAT0Y, 1): "Down",
(ecodes.ABS_HAT1X, -1): "Left",
(ecodes.ABS_HAT1X, 1): "Right",
(ecodes.ABS_HAT1Y, -1): "Up",
(ecodes.ABS_HAT1Y, 1): "Down",
(ecodes.ABS_HAT2X, -1): "Left",
(ecodes.ABS_HAT2X, 1): "Right",
(ecodes.ABS_HAT2Y, -1): "Up",
(ecodes.ABS_HAT2Y, 1): "Down",
# joystick
(ecodes.ABS_X, 1): "Right",
(ecodes.ABS_X, -1): "Left",
(ecodes.ABS_Y, 1): "Down",
(ecodes.ABS_Y, -1): "Up",
(ecodes.ABS_RX, 1): "Right",
(ecodes.ABS_RX, -1): "Left",
(ecodes.ABS_RY, 1): "Down",
(ecodes.ABS_RY, -1): "Up",
# wheel
(ecodes.REL_WHEEL, -1): "Down",
(ecodes.REL_WHEEL, 1): "Up",
(ecodes.REL_HWHEEL, -1): "Left",
(ecodes.REL_HWHEEL, 1): "Right",
}.get((event.code, event.value))
if direction is not None:
key_name += f" {direction}"
key_name = key_name.replace("ABS_Z", "Trigger Left")
key_name = key_name.replace("ABS_RZ", "Trigger Right")
key_name = key_name.replace("ABS_HAT0X", "DPad")
key_name = key_name.replace("ABS_HAT0Y", "DPad")
key_name = key_name.replace("ABS_HAT1X", "DPad 2")
key_name = key_name.replace("ABS_HAT1Y", "DPad 2")
key_name = key_name.replace("ABS_HAT2X", "DPad 3")
key_name = key_name.replace("ABS_HAT2Y", "DPad 3")
key_name = key_name.replace("ABS_X", "Joystick")
key_name = key_name.replace("ABS_Y", "Joystick")
key_name = key_name.replace("ABS_RX", "Joystick 2")
key_name = key_name.replace("ABS_RY", "Joystick 2")
key_name = key_name.replace("BTN_", "Button ")
key_name = key_name.replace("KEY_", "")
key_name = key_name.replace("REL_", "")
key_name = key_name.replace("HWHEEL", "Wheel")
key_name = key_name.replace("WHEEL", "Wheel")
key_name = key_name.replace("_", " ")
key_name = key_name.replace(" ", " ")
result.append(key_name)
return " + ".join(result)

@ -57,3 +57,8 @@ class MappingParsingError(Error):
class InputEventCreationError(Error): class InputEventCreationError(Error):
def __init__(self, msg): def __init__(self, msg):
super().__init__(msg) super().__init__(msg)
class DataManagementError(Error):
def __init__(self, msg):
super().__init__(msg)

@ -29,14 +29,15 @@ Those groups are what is being displayed in the device dropdown, and
events are being read from all of the paths of an individual group in the gui events are being read from all of the paths of an individual group in the gui
and the injector. and the injector.
""" """
from __future__ import annotations
import re
import multiprocessing
import threading
import asyncio import asyncio
import enum
import json import json
from typing import List import multiprocessing
import os
import re
import threading
from typing import List, Optional
import evdev import evdev
from evdev.ecodes import ( from evdev.ecodes import (
@ -53,9 +54,8 @@ from evdev.ecodes import (
REL_WHEEL, REL_WHEEL,
) )
from inputremapper.logger import logger
from inputremapper.configs.paths import get_preset_path from inputremapper.configs.paths import get_preset_path
from inputremapper.logger import logger
TABLET_KEYS = [ TABLET_KEYS = [
evdev.ecodes.BTN_STYLUS, evdev.ecodes.BTN_STYLUS,
@ -64,13 +64,15 @@ TABLET_KEYS = [
evdev.ecodes.BTN_TOOL_RUBBER, evdev.ecodes.BTN_TOOL_RUBBER,
] ]
GAMEPAD = "gamepad"
KEYBOARD = "keyboard" class DeviceType(str, enum.Enum):
MOUSE = "mouse" GAMEPAD = "gamepad"
TOUCHPAD = "touchpad" KEYBOARD = "keyboard"
GRAPHICS_TABLET = "graphics-tablet" MOUSE = "mouse"
CAMERA = "camera" TOUCHPAD = "touchpad"
UNKNOWN = "unknown" GRAPHICS_TABLET = "graphics-tablet"
CAMERA = "camera"
UNKNOWN = "unknown"
if not hasattr(evdev.InputDevice, "path"): if not hasattr(evdev.InputDevice, "path"):
@ -156,7 +158,7 @@ def _is_camera(capabilities):
return key_capa and len(key_capa) == 1 and key_capa[0] == KEY_CAMERA return key_capa and len(key_capa) == 1 and key_capa[0] == KEY_CAMERA
def classify(device): def classify(device) -> DeviceType:
"""Figure out what kind of device this is. """Figure out what kind of device this is.
Use this instead of functions like _is_keyboard to avoid getting false Use this instead of functions like _is_keyboard to avoid getting false
@ -167,26 +169,26 @@ def classify(device):
if _is_graphics_tablet(capabilities): if _is_graphics_tablet(capabilities):
# check this before is_gamepad to avoid classifying abs_x # check this before is_gamepad to avoid classifying abs_x
# as joysticks when they are actually stylus positions # as joysticks when they are actually stylus positions
return GRAPHICS_TABLET return DeviceType.GRAPHICS_TABLET
if _is_touchpad(capabilities): if _is_touchpad(capabilities):
return TOUCHPAD return DeviceType.TOUCHPAD
if _is_gamepad(capabilities): if _is_gamepad(capabilities):
return GAMEPAD return DeviceType.GAMEPAD
if _is_mouse(capabilities): if _is_mouse(capabilities):
return MOUSE return DeviceType.MOUSE
if _is_camera(capabilities): if _is_camera(capabilities):
return CAMERA return DeviceType.CAMERA
if _is_keyboard(capabilities): if _is_keyboard(capabilities):
# very low in the chain to avoid classifying most devices # very low in the chain to avoid classifying most devices
# as keyboard, because there are many with ev_key capabilities # as keyboard, because there are many with ev_key capabilities
return KEYBOARD return DeviceType.KEYBOARD
return UNKNOWN return DeviceType.UNKNOWN
DENYLIST = [".*Yubico.*YubiKey.*", "Eee PC WMI hotkeys"] DENYLIST = [".*Yubico.*YubiKey.*", "Eee PC WMI hotkeys"]
@ -253,7 +255,13 @@ class _Group:
presets folder structure presets folder structure
""" """
def __init__(self, paths: List[str], names: List[str], types: List[str], key: str): def __init__(
self,
paths: List[os.PathLike],
names: List[str],
types: List[DeviceType | str],
key: str,
):
"""Specify a group """Specify a group
Parameters Parameters
@ -262,7 +270,7 @@ class _Group:
Paths in /dev/input of the grouped devices Paths in /dev/input of the grouped devices
names : str[] names : str[]
Names of the grouped devices Names of the grouped devices
types : str[] types : list[DeviceType]
Types of the grouped devices Types of the grouped devices
key : str key : str
Unique identifier of the group. Unique identifier of the group.
@ -283,7 +291,7 @@ class _Group:
self.paths = paths self.paths = paths
self.names = names self.names = names
self.types = types self.types = [DeviceType(type_) for type_ in types]
def get_preset_path(self, preset=None): def get_preset_path(self, preset=None):
"""Get a path to the stored preset, or to store a preset to. """Get a path to the stored preset, or to store a preset to.
@ -356,7 +364,7 @@ class _FindGroups(threading.Thread):
device_type = classify(device) device_type = classify(device)
if device_type == CAMERA: if device_type == DeviceType.CAMERA:
continue continue
# https://www.kernel.org/doc/html/latest/input/event-codes.html # https://www.kernel.org/doc/html/latest/input/event-codes.html
@ -364,7 +372,7 @@ class _FindGroups(threading.Thread):
key_capa = capabilities.get(EV_KEY) key_capa = capabilities.get(EV_KEY)
if key_capa is None and device_type != GAMEPAD: if key_capa is None and device_type != DeviceType.GAMEPAD:
# skip devices that don't provide buttons that can be mapped # skip devices that don't provide buttons that can be mapped
continue continue
@ -405,14 +413,17 @@ class _FindGroups(threading.Thread):
key=key, key=key,
paths=devs, paths=devs,
names=names, names=names,
types=sorted(list({item[2] for item in group if item[2] != UNKNOWN})), types=sorted(
list({item[2] for item in group if item[2] != DeviceType.UNKNOWN})
),
) )
result.append(group.dumps()) result.append(group.dumps())
self.pipe.send(json.dumps(result)) self.pipe.send(json.dumps(result))
loop.close() # avoid resource allocation warnings
# now that everything is sent via the pipe, the InputDevice # now that everything is sent via the pipe, the InputDevice
# destructors can go on an take ages to complete in the thread # destructors can go on and take ages to complete in the thread
# without blocking anything # without blocking anything
@ -429,7 +440,7 @@ class _Groups:
need it the information. need it the information.
""" """
if key == "_groups" and object.__getattribute__(self, "_groups") is None: if key == "_groups" and object.__getattribute__(self, "_groups") is None:
object.__setattr__(self, "_groups", {}) object.__setattr__(self, "_groups", [])
object.__getattribute__(self, "refresh")() object.__getattribute__(self, "refresh")()
return object.__getattribute__(self, key) return object.__getattribute__(self, key)
@ -452,7 +463,7 @@ class _Groups:
keys = [f'"{group.key}"' for group in self._groups] keys = [f'"{group.key}"' for group in self._groups]
logger.info("Found %s", ", ".join(keys)) logger.info("Found %s", ", ".join(keys))
def filter(self, include_inputremapper=False): def filter(self, include_inputremapper=False) -> List[_Group]:
"""Filter groups.""" """Filter groups."""
result = [] result = []
for group in self._groups: for group in self._groups:
@ -466,6 +477,7 @@ class _Groups:
def set_groups(self, new_groups): def set_groups(self, new_groups):
"""Overwrite all groups.""" """Overwrite all groups."""
logger.debug("overwriting groups with %s", new_groups)
self._groups = new_groups self._groups = new_groups
def list_group_names(self) -> List[str]: def list_group_names(self) -> List[str]:
@ -496,7 +508,7 @@ class _Groups:
key: str = None, key: str = None,
path: str = None, path: str = None,
include_inputremapper: bool = False, include_inputremapper: bool = False,
) -> _Group: ) -> Optional[_Group]:
"""Find a group that matches the provided parameters. """Find a group that matches the provided parameters.
Parameters Parameters

@ -1,29 +0,0 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""One preset object for the GUI application."""
from inputremapper.configs.preset import Preset
from inputremapper.configs.mapping import UIMapping
active_preset = Preset(mapping_factory=UIMapping)

@ -23,30 +23,34 @@
import re import re
from typing import Dict, Optional, List, Tuple
from gi.repository import Gdk, Gtk, GLib, GObject
from evdev.ecodes import EV_KEY from evdev.ecodes import EV_KEY
from gi.repository import Gdk, Gtk, GLib, GObject
from inputremapper.configs.mapping import MappingData
from inputremapper.configs.system_mapping import system_mapping from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.components import CodeEditor
from inputremapper.gui.message_broker import MessageBroker, MessageType, UInputsData
from inputremapper.gui.utils import debounce
from inputremapper.injection.macros.parse import ( from inputremapper.injection.macros.parse import (
FUNCTIONS, FUNCTIONS,
get_macro_argument_names, get_macro_argument_names,
remove_comments, remove_comments,
) )
from inputremapper.injection.global_uinputs import global_uinputs
from inputremapper.logger import logger from inputremapper.logger import logger
from inputremapper.gui.utils import debounce
# no deprecated shorthand function-names # no deprecated shorthand function-names
FUNCTION_NAMES = [name for name in FUNCTIONS.keys() if len(name) > 1] FUNCTION_NAMES = [name for name in FUNCTIONS.keys() if len(name) > 1]
# no deprecated functions # no deprecated functions
FUNCTION_NAMES.remove("ifeq") FUNCTION_NAMES.remove("ifeq")
Capabilities = Dict[int, List]
def _get_left_text(iter): def _get_left_text(iter_: Gtk.TextIter) -> str:
buffer = iter.get_buffer() buffer = iter_.get_buffer()
result = buffer.get_text(buffer.get_start_iter(), iter, True) result = buffer.get_text(buffer.get_start_iter(), iter_, True)
result = remove_comments(result) result = remove_comments(result)
result = result.replace("\n", " ") result = result.replace("\n", " ")
return result.lower() return result.lower()
@ -57,9 +61,9 @@ PARAMETER = r".*?[(,=+]\s*"
FUNCTION_CHAIN = r".*?\)\s*\.\s*" FUNCTION_CHAIN = r".*?\)\s*\.\s*"
def get_incomplete_function_name(iter): def get_incomplete_function_name(iter_: Gtk.TextIter) -> str:
"""Get the word that is written left to the TextIter.""" """Get the word that is written left to the TextIter."""
left_text = _get_left_text(iter) left_text = _get_left_text(iter_)
# match foo in: # match foo in:
# bar().foo # bar().foo
@ -77,9 +81,9 @@ def get_incomplete_function_name(iter):
return match[1] return match[1]
def get_incomplete_parameter(iter): def get_incomplete_parameter(iter_: Gtk.TextIter) -> Optional[str]:
"""Get the parameter that is written left to the TextIter.""" """Get the parameter that is written left to the TextIter."""
left_text = _get_left_text(iter) left_text = _get_left_text(iter_)
# match foo in: # match foo in:
# bar(foo # bar(foo
@ -96,7 +100,7 @@ def get_incomplete_parameter(iter):
return match[1] return match[1]
def propose_symbols(text_iter, codes): def propose_symbols(text_iter: Gtk.TextIter, codes: List[int]) -> List[Tuple[str, str]]:
"""Find key names that match the input at the cursor and are mapped to the codes.""" """Find key names that match the input at the cursor and are mapped to the codes."""
incomplete_name = get_incomplete_parameter(text_iter) incomplete_name = get_incomplete_parameter(text_iter)
@ -112,7 +116,7 @@ def propose_symbols(text_iter, codes):
] ]
def propose_function_names(text_iter): def propose_function_names(text_iter: Gtk.TextIter) -> List[Tuple[str, str]]:
"""Find function names that match the input at the cursor.""" """Find function names that match the input at the cursor."""
incomplete_name = get_incomplete_function_name(text_iter) incomplete_name = get_incomplete_function_name(text_iter)
@ -146,7 +150,7 @@ class Autocompletion(Gtk.Popover):
__gtype_name__ = "Autocompletion" __gtype_name__ = "Autocompletion"
def __init__(self, text_input, target_selector): def __init__(self, message_broker: MessageBroker, code_editor: CodeEditor):
"""Create an autocompletion popover. """Create an autocompletion popover.
It will remain hidden until there is something to autocomplete. It will remain hidden until there is something to autocomplete.
@ -164,10 +168,10 @@ class Autocompletion(Gtk.Popover):
constrain_to=Gtk.PopoverConstraint.NONE, constrain_to=Gtk.PopoverConstraint.NONE,
) )
self.text_input = text_input self.code_editor = code_editor
self.target_selector = target_selector self.message_broker = message_broker
self._target_key_capabilities = [] self._uinputs: Optional[Dict[str, Capabilities]] = None
target_selector.connect("changed", self._update_target_key_capabilities) self._target_key_capabilities: List[int] = []
self.scrolled_window = Gtk.ScrolledWindow( self.scrolled_window = Gtk.ScrolledWindow(
min_content_width=200, min_content_width=200,
@ -192,22 +196,27 @@ class Autocompletion(Gtk.Popover):
self.set_position(Gtk.PositionType.BOTTOM) self.set_position(Gtk.PositionType.BOTTOM)
text_input.connect("key-press-event", self.navigate) self.code_editor.gui.connect("key-press-event", self.navigate)
# add some delay, so that pressing the button in the completion works before # add some delay, so that pressing the button in the completion works before
# the popover is hidden due to focus-out-event # the popover is hidden due to focus-out-event
text_input.connect("focus-out-event", self.on_text_input_unfocus) self.code_editor.gui.connect("focus-out-event", self.on_gtk_text_input_unfocus)
text_input.get_buffer().connect("changed", self.update) self.code_editor.gui.get_buffer().connect("changed", self.update)
self.set_position(Gtk.PositionType.BOTTOM) self.set_position(Gtk.PositionType.BOTTOM)
self.visible = False self.visible = False
self.attach_to_events()
self.show_all() self.show_all()
self.popdown() # hidden by default. this needs to happen after show_all! self.popdown() # hidden by default. this needs to happen after show_all!
def on_text_input_unfocus(self, *_): def attach_to_events(self):
self.message_broker.subscribe(MessageType.mapping, self._on_mapping_loaded)
self.message_broker.subscribe(MessageType.uinputs, self._on_uinputs_changed)
def on_gtk_text_input_unfocus(self, *_):
"""The code editor was unfocused.""" """The code editor was unfocused."""
GLib.timeout_add(100, self.popdown) GLib.timeout_add(100, self.popdown)
# "(input-remapper-gtk:97611): Gtk-WARNING **: 16:33:56.464: GtkTextView - # "(input-remapper-gtk:97611): Gtk-WARNING **: 16:33:56.464: GtkTextView -
@ -300,8 +309,8 @@ class Autocompletion(Gtk.Popover):
def _get_text_iter_at_cursor(self): def _get_text_iter_at_cursor(self):
"""Get Gtk.TextIter at the current text cursor location.""" """Get Gtk.TextIter at the current text cursor location."""
cursor = self.text_input.get_cursor_locations()[0] cursor = self.code_editor.gui.get_cursor_locations()[0]
return self.text_input.get_iter_at_location(cursor.x, cursor.y)[1] return self.code_editor.gui.get_iter_at_location(cursor.x, cursor.y)[1]
def popup(self): def popup(self):
self.visible = True self.visible = True
@ -314,24 +323,24 @@ class Autocompletion(Gtk.Popover):
@debounce(100) @debounce(100)
def update(self, *_): def update(self, *_):
"""Find new autocompletion suggestions and display them. Hide if none.""" """Find new autocompletion suggestions and display them. Hide if none."""
if not self.text_input.is_focus(): if not self.code_editor.gui.is_focus():
self.popdown() self.popdown()
return return
self.list_box.forall(self.list_box.remove) self.list_box.forall(self.list_box.remove)
# move the autocompletion to the text cursor # move the autocompletion to the text cursor
cursor = self.text_input.get_cursor_locations()[0] cursor = self.code_editor.gui.get_cursor_locations()[0]
# convert it to window coords, because the cursor values will be very large # convert it to window coords, because the cursor values will be very large
# when the TextView is in a scrolled down ScrolledWindow. # when the TextView is in a scrolled down ScrolledWindow.
window_coords = self.text_input.buffer_to_window_coords( window_coords = self.code_editor.gui.buffer_to_window_coords(
Gtk.TextWindowType.TEXT, cursor.x, cursor.y Gtk.TextWindowType.TEXT, cursor.x, cursor.y
) )
cursor.x = window_coords.window_x cursor.x = window_coords.window_x
cursor.y = window_coords.window_y cursor.y = window_coords.window_y
cursor.y += 12 cursor.y += 12
if self.text_input.get_show_line_numbers(): if self.code_editor.gui.get_show_line_numbers():
cursor.x += 25 cursor.x += 25
self.set_pointing_to(cursor) self.set_pointing_to(cursor)
@ -352,17 +361,19 @@ class Autocompletion(Gtk.Popover):
self.list_box.insert(label, -1) self.list_box.insert(label, -1)
label.show_all() label.show_all()
def _update_target_key_capabilities(self, *_): def _on_mapping_loaded(self, mapping: MappingData):
target = self.target_selector.get_active_id() if mapping and self._uinputs:
self._target_key_capabilities = global_uinputs.get_uinput( target = mapping.target_uinput or "keyboard"
target self._target_key_capabilities = self._uinputs[target][EV_KEY]
).capabilities()[EV_KEY]
def _on_uinputs_changed(self, data: UInputsData):
self._uinputs = data.uinputs
def _on_suggestion_clicked(self, _, selected_row): def _on_suggestion_clicked(self, _, selected_row):
"""An autocompletion suggestion was selected and should be inserted.""" """An autocompletion suggestion was selected and should be inserted."""
selected_label = selected_row.get_children()[0] selected_label = selected_row.get_children()[0]
suggestion = selected_label.suggestion suggestion = selected_label.suggestion
buffer = self.text_input.get_buffer() buffer = self.code_editor.gui.get_buffer()
# make sure to replace the complete unfinished word. Look to the right and # make sure to replace the complete unfinished word. Look to the right and
# remove whatever there is # remove whatever there is
@ -371,7 +382,7 @@ class Autocompletion(Gtk.Popover):
match = re.match(r"^(\w+)", right) match = re.match(r"^(\w+)", right)
right = match[1] if match else "" right = match[1] if match else ""
Gtk.TextView.do_delete_from_cursor( Gtk.TextView.do_delete_from_cursor(
self.text_input, Gtk.DeleteType.CHARS, len(right) self.code_editor.gui, Gtk.DeleteType.CHARS, len(right)
) )
# do the same to the left # do the same to the left
@ -380,11 +391,11 @@ class Autocompletion(Gtk.Popover):
match = re.match(r".*?(\w+)$", re.sub("\n", " ", left)) match = re.match(r".*?(\w+)$", re.sub("\n", " ", left))
left = match[1] if match else "" left = match[1] if match else ""
Gtk.TextView.do_delete_from_cursor( Gtk.TextView.do_delete_from_cursor(
self.text_input, Gtk.DeleteType.CHARS, -len(left) self.code_editor.gui, Gtk.DeleteType.CHARS, -len(left)
) )
# insert the autocompletion # insert the autocompletion
Gtk.TextView.do_insert_at_cursor(self.text_input, suggestion) Gtk.TextView.do_insert_at_cursor(self.code_editor.gui, suggestion)
self.emit("suggestion-inserted") self.emit("suggestion-inserted")

File diff suppressed because it is too large Load Diff

@ -0,0 +1,557 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations # needed for the TYPE_CHECKING import
import re
from functools import partial
from typing import TYPE_CHECKING, Optional, Union, Literal, Sequence, Dict, Callable
from evdev.ecodes import EV_KEY, EV_REL, EV_ABS
from gi.repository import Gtk
from inputremapper.configs.mapping import MappingData, UIMapping
from inputremapper.event_combination import EventCombination
from inputremapper.exceptions import DataManagementError
from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME
from inputremapper.gui.gettext import _
from inputremapper.gui.helper import is_helper_running
from inputremapper.gui.utils import CTX_APPLY, CTX_ERROR, CTX_WARNING, CTX_MAPPING
from inputremapper.injection.injector import (
RUNNING,
FAILED,
NO_GRAB,
UPGRADE_EVDEV,
STARTING,
STOPPED,
InjectorState,
)
from inputremapper.input_event import InputEvent
from inputremapper.logger import logger
from inputremapper.gui.message_broker import (
MessageBroker,
MessageType,
PresetData,
StatusData,
CombinationRecorded,
UserConfirmRequest,
)
if TYPE_CHECKING:
# avoids gtk import error in tests
from inputremapper.gui.user_interface import UserInterface
MAPPING_DEFAULTS = {"target_uinput": "keyboard"}
class Controller:
"""implements the behaviour of the gui"""
def __init__(self, message_broker: MessageBroker, data_manager: DataManager):
self.message_broker = message_broker
self.data_manager = data_manager
self.gui: Optional[UserInterface] = None
self.button_left_warn = False
self._attach_to_events()
def set_gui(self, gui: UserInterface):
self.gui = gui
def _attach_to_events(self) -> None:
self.message_broker.subscribe(MessageType.groups, self._on_groups_changed)
self.message_broker.subscribe(MessageType.preset, self._on_preset_changed)
self.message_broker.subscribe(MessageType.init, self._on_init)
self.message_broker.subscribe(
MessageType.preset, self._send_mapping_errors_as_status_msg
)
self.message_broker.subscribe(
MessageType.mapping, self._send_mapping_errors_as_status_msg
)
def _on_init(self, __):
"""initialize the gui and the data_manager"""
# make sure we get a groups_changed event when everything is ready
# this might not be necessary if the helper takes longer to provide the
# initial groups
self.data_manager.send_groups()
self.data_manager.send_uinputs()
if not is_helper_running():
self.show_status(CTX_ERROR, _("The helper did not start"))
def _on_groups_changed(self, _):
"""load the newest group as soon as everyone got notified
about the updated groups"""
group_key = self.get_a_group()
if group_key:
self.load_group(self.get_a_group())
def _on_preset_changed(self, data: PresetData):
"""load a mapping as soon as everyone got notified about the new preset"""
if data.mappings:
mappings = list(data.mappings)
mappings.sort(key=lambda t: t[0] or t[1].beautify())
combination = mappings[0][1]
self.load_mapping(combination)
self.load_event(combination[0])
else:
# send an empty mapping to make sure the ui is reset to default values
self.message_broker.send(MappingData(**MAPPING_DEFAULTS))
def _on_combination_recorded(self, data: CombinationRecorded):
self.update_combination(data.combination)
def _send_mapping_errors_as_status_msg(self, *__):
"""send mapping ValidationErrors to the MessageBroker."""
if not self.data_manager.active_preset:
return
if self.data_manager.active_preset.is_valid():
self.message_broker.send(StatusData(CTX_MAPPING))
return
for mapping in self.data_manager.active_preset:
if not mapping.get_error():
continue
position = mapping.name or mapping.event_combination.beautify()
msg = _("Mapping error at %s, hover for info") % position
self.show_status(CTX_MAPPING, msg, self._get_ui_error_string(mapping))
@staticmethod
def _get_ui_error_string(mapping: UIMapping) -> str:
"""get a human readable error message from a mapping error"""
error_string = str(mapping.get_error())
# check all the different error messages which are not useful for the user
if (
"output_symbol is a macro:" in error_string
or "output_symbol and output_code mismatch:" in error_string
) and mapping.event_combination.has_input_axis():
return _(
"Remove the macro or key from the macro input field "
"when specifying an analog output"
)
elif (
"output_symbol is a macro:" in error_string
or "output_symbol and output_code mismatch:" in error_string
) and not mapping.event_combination.has_input_axis():
return _(
"Remove the Analog Output Axis "
"when specifying an macro or key output"
)
if "missing output axis:" in error_string:
return _(
"The input specifies a analog axis, but no output axis is selected"
)
return error_string
def get_a_preset(self) -> str:
"""attempts to get the newest preset in the current group
creates a new preset if that fails"""
try:
return self.data_manager.get_newest_preset_name()
except FileNotFoundError:
pass
self.data_manager.create_preset(self.data_manager.get_available_preset_name())
return self.data_manager.get_newest_preset_name()
def get_a_group(self) -> Optional[str]:
"""attempts to get the group with the newest preset
returns any if that fails"""
try:
return self.data_manager.get_newest_group_key()
except FileNotFoundError:
pass
keys = self.data_manager.get_group_keys()
return keys[0] if keys else None
def copy_preset(self):
"""create a copy of the active preset and name it `preset_name copy`"""
name = self.data_manager.active_preset.name
match = re.search(" copy *\d*$", name)
if match:
name = name[: match.start()]
self.data_manager.copy_preset(
self.data_manager.get_available_preset_name(f"{name} copy")
)
def update_combination(self, combination: EventCombination):
"""update the event_combination of the active mapping"""
try:
self.data_manager.update_mapping(event_combination=combination)
self.save()
except KeyError:
# the combination was a duplicate
return
if combination.is_problematic():
self.show_status(
CTX_WARNING,
_("ctrl, alt and shift may not combine properly"),
_("Your system might reinterpret combinations ")
+ _("with those after they are injected, and by doing so ")
+ _("break them."),
)
def move_event_in_combination(
self, event: InputEvent, direction: Union[Literal["up"], Literal["down"]]
):
"""move the active_event up or down in the event_combination of the
active_mapping"""
if (
not self.data_manager.active_mapping
or len(self.data_manager.active_mapping.event_combination) == 1
):
return
combination: Sequence[
InputEvent
] = self.data_manager.active_mapping.event_combination
i = combination.index(event)
if (
i + 1 == len(combination)
and direction == "down"
or i == 0
and direction == "up"
):
return
if direction == "up":
combination = (
list(combination[: i - 1])
+ [event]
+ [combination[i - 1]]
+ list(combination[i + 1 :])
)
elif direction == "down":
combination = (
list(combination[:i])
+ [combination[i + 1]]
+ [event]
+ list(combination[i + 2 :])
)
else:
raise ValueError(f"unknown direction: {direction}")
self.update_combination(EventCombination(combination))
self.load_event(event)
def load_event(self, event: InputEvent):
"""load an InputEvent form the active mapping event combination"""
self.data_manager.load_event(event)
def update_event(self, new_event: InputEvent):
"""modify the active event"""
try:
self.data_manager.update_event(new_event)
except KeyError:
# we need to synchronize the gui
self.data_manager.send_mapping()
self.data_manager.send_event()
def remove_event(self):
"""remove the active InputEvent from the active mapping event combination"""
if not self.data_manager.active_mapping or not self.data_manager.active_event:
return
combination = list(self.data_manager.active_mapping.event_combination)
combination.remove(self.data_manager.active_event)
try:
self.data_manager.update_mapping(
event_combination=EventCombination(combination)
)
self.load_event(combination[0])
except (KeyError, ValueError):
# we need to synchronize the gui
self.data_manager.send_mapping()
self.data_manager.send_event()
def set_event_as_analog(self, analog: bool):
"""use the active event as an analog input"""
assert self.data_manager.active_event is not None
event = self.data_manager.active_event
if event.type == EV_KEY:
pass
elif analog:
try:
self.data_manager.update_event(event.modify(value=0))
return
except KeyError:
pass
else:
try_values = {EV_REL: [1, -1], EV_ABS: [10, -10]}
for value in try_values[event.type]:
try:
self.data_manager.update_event(event.modify(value=value))
return
except KeyError:
pass
# didn't update successfully
# we need to synchronize the gui
self.data_manager.send_mapping()
self.data_manager.send_event()
def load_groups(self):
"""refresh the groups"""
self.data_manager.refresh_groups()
def load_group(self, group_key: str):
"""load the group and then a preset of that group"""
self.data_manager.load_group(group_key)
self.load_preset(self.get_a_preset())
def load_preset(self, name: str):
"""load the preset"""
self.data_manager.load_preset(name)
# self.load_mapping(...) # not needed because we have on_preset_changed()
def rename_preset(self, new_name: str):
"""rename the active_preset"""
if (
not self.data_manager.active_preset
or not new_name
or new_name == self.data_manager.active_preset.name
):
return
name = self.data_manager.get_available_preset_name(new_name)
self.data_manager.rename_preset(name)
def add_preset(self, name: str = DEFAULT_PRESET_NAME):
"""create a new preset, add it to the active_group and name it `new preset n`"""
name = self.data_manager.get_available_preset_name(name)
try:
self.data_manager.create_preset(name)
self.data_manager.load_preset(name)
except PermissionError as e:
self.show_status(CTX_ERROR, _("Permission denied!"), str(e))
def delete_preset(self):
"""delete the active_preset from the disc"""
def f(answer: bool):
if answer:
self.data_manager.delete_preset()
self.data_manager.load_preset(self.get_a_preset())
if not self.data_manager.active_preset:
return
msg = (
_("Are you sure you want to delete the \npreset: '%s' ?")
% self.data_manager.active_preset.name
)
self.message_broker.send(UserConfirmRequest(msg, f))
def load_mapping(self, event_combination: EventCombination):
"""load the mapping with the given event_combination form the active_preset"""
self.data_manager.load_mapping(event_combination)
self.load_event(event_combination[0])
def update_mapping(self, **kwargs):
"""update the active_mapping with the given keywords and values"""
self.data_manager.update_mapping(**kwargs)
self.save()
def create_mapping(self):
"""create a new empty mapping in the active_preset"""
try:
self.data_manager.create_mapping()
except KeyError:
# there is already an empty mapping
return
self.data_manager.load_mapping(combination=EventCombination.empty_combination())
self.data_manager.update_mapping(**MAPPING_DEFAULTS)
def delete_mapping(self):
"""remove the active_mapping form the active_preset"""
def f(answer: bool):
if answer:
self.data_manager.delete_mapping()
self.save()
if not self.data_manager.active_mapping:
return
self.message_broker.send(
UserConfirmRequest(_("Are you sure you want to delete \nthis mapping?"), f)
)
def set_autoload(self, autoload: bool):
"""set the autoload state for the active_preset and active_group"""
self.data_manager.set_autoload(autoload)
self.data_manager.refresh_service_config_path()
def save(self):
"""save all data to the disc"""
try:
self.data_manager.save()
except PermissionError as e:
self.show_status(CTX_ERROR, _("Permission denied!"), str(e))
def start_key_recording(self):
"""recorde the input of the active_group and update the
active_mapping.event_combination with the recorded events"""
state = self.data_manager.get_state()
if state == RUNNING or state == STARTING:
self.message_broker.signal(MessageType.recording_finished)
self.show_status(
CTX_ERROR, _('Use "Stop Injection" to stop before editing')
)
return
logger.debug("Recording Keys")
def f(_):
self.message_broker.unsubscribe(f)
self.message_broker.unsubscribe(self._on_combination_recorded)
self.gui.connect_shortcuts()
self.gui.disconnect_shortcuts()
self.message_broker.subscribe(
MessageType.combination_recorded, self._on_combination_recorded
)
self.message_broker.subscribe(MessageType.recording_finished, f)
self.data_manager.start_combination_recording()
def stop_key_recording(self):
"""stop recording the input"""
logger.debug("Stopping Key recording")
self.data_manager.stop_combination_recording()
def start_injecting(self):
"""inject the active_preset for the active_group"""
if len(self.data_manager.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"))
return
if not self.button_left_warn:
if self.data_manager.active_preset.dangerously_mapped_btn_left():
self.show_status(
CTX_ERROR,
"This would disable your click button",
"Map a button to BTN_LEFT to avoid this.\n"
"To overwrite this warning, press apply again.",
)
self.button_left_warn = True
return
# todo: warn about unreleased keys
self.button_left_warn = False
self.message_broker.subscribe(
MessageType.injector_state, self.show_injector_result
)
self.show_status(CTX_APPLY, _("Starting injection..."))
if not self.data_manager.start_injecting():
self.message_broker.unsubscribe(self.show_injector_result)
self.show_status(
CTX_APPLY,
_("Failed to apply preset %s") % self.data_manager.active_preset.name,
)
def show_injector_result(self, msg: InjectorState):
"""Show if the injection was successfully started."""
self.message_broker.unsubscribe(self.show_injector_result)
state = msg.state
def running():
msg = _("Applied preset %s") % self.data_manager.active_preset.name
if self.data_manager.active_preset.get_mapping(
EventCombination(InputEvent.btn_left())
):
msg += _(", CTRL + DEL to stop")
self.show_status(CTX_APPLY, msg)
logger.info(
'Group "%s" is currently mapped', self.data_manager.active_group.key
)
assert self.data_manager.active_preset # make mypy happy
state_calls: Dict[int, Callable] = {
RUNNING: running,
FAILED: partial(
self.show_status,
CTX_ERROR,
_("Failed to apply preset %s") % self.data_manager.active_preset.name,
),
NO_GRAB: partial(
self.show_status,
CTX_ERROR,
"The device was not grabbed",
"Either another application is already grabbing it or "
"your preset doesn't contain anything that is sent by the "
"device.",
),
UPGRADE_EVDEV: partial(
self.show_status,
CTX_ERROR,
"Upgrade python-evdev",
"Your python-evdev version is too old.",
),
}
state_calls[state]()
def stop_injecting(self):
"""stop injecting any preset for the active_group"""
def show_result(msg: InjectorState):
self.message_broker.unsubscribe(show_result)
assert msg.state == STOPPED
self.show_status(CTX_APPLY, _("Applied the system default"))
try:
self.message_broker.subscribe(MessageType.injector_state, show_result)
self.data_manager.stop_injecting()
except DataManagementError:
self.message_broker.unsubscribe(show_result)
def show_status(
self, ctx_id: int, msg: Optional[str] = None, tooltip: Optional[str] = None
):
"""send a status message to the ui to show it in the status-bar"""
self.message_broker.send(StatusData(ctx_id, msg, tooltip))
def is_empty_mapping(self) -> bool:
"""check if the active_mapping is empty"""
return (
self.data_manager.active_mapping == UIMapping(**MAPPING_DEFAULTS)
or self.data_manager.active_mapping is None
)
def refresh_groups(self):
"""reload the connected devices and send them as a groups message
runs asynchronously"""
self.data_manager.refresh_groups()
def close(self):
"""safely close the application"""
logger.debug("Closing Application")
self.save()
self.message_broker.signal(MessageType.terminate)
logger.debug("Quitting")
Gtk.main_quit()
def set_focus(self, component):
"""focus the given component"""
self.gui.window.set_focus(component)

@ -0,0 +1,564 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import glob
import os
import re
import time
from typing import Optional, List, Tuple, Set
from gi.repository import GLib
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.configs.mapping import UIMapping
from inputremapper.configs.paths import get_preset_path, mkdir, split_all
from inputremapper.configs.preset import Preset
from inputremapper.configs.system_mapping import SystemMapping
from inputremapper.daemon import DaemonProxy
from inputremapper.event_combination import EventCombination
from inputremapper.exceptions import DataManagementError
from inputremapper.groups import _Group
from inputremapper.gui.message_broker import (
MessageBroker,
GroupData,
PresetData,
CombinationUpdate,
UInputsData,
)
from inputremapper.gui.reader import Reader
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.injector import (
STOPPED,
RUNNING,
FAILED,
UPGRADE_EVDEV,
NO_GRAB,
InjectorState,
)
from inputremapper.input_event import InputEvent
from inputremapper.logger import logger
DEFAULT_PRESET_NAME = "new preset"
# useful type aliases
Name = str
GroupKey = str
class DataManager:
"""DataManager provides an interface to create and modify configurations as well
as modify the state of the Service.
Any state changes will be announced via the MessageBroker.
"""
def __init__(
self,
message_broker: MessageBroker,
config: GlobalConfig,
reader: Reader,
daemon: DaemonProxy,
uinputs: GlobalUInputs,
system_mapping: SystemMapping,
):
self.message_broker = message_broker
self._reader = reader
self._daemon = daemon
self._uinputs = uinputs
self._system_mapping = system_mapping
uinputs.prepare_all()
self._config = config
self._config.load_config()
self._active_preset: Optional[Preset[UIMapping]] = None
self._active_mapping: Optional[UIMapping] = None
self._active_event: Optional[InputEvent] = None
def send_group(self):
"""send active group to the MessageBroker.
This is internally called whenever the group changes.
It is usually not necessary to call this explicitly from
outside DataManager"""
self.message_broker.send(
GroupData(self.active_group.key, self.get_preset_names())
)
def send_preset(self):
"""send active preset to the MessageBroker.
This is internally called whenever the preset changes.
It is usually not necessary to call this explicitly from
outside DataManager"""
self.message_broker.send(
PresetData(
self.active_preset.name, self.get_mappings(), self.get_autoload()
)
)
def send_mapping(self):
"""send active mapping to the MessageBroker
This is internally called whenever the mapping changes.
It is usually not necessary to call this explicitly from
outside DataManager"""
if self.active_mapping:
self.message_broker.send(self.active_mapping.get_bus_message())
def send_event(self):
"""send active event to the MessageBroker.
This is internally called whenever the event changes.
It is usually not necessary to call this explicitly from
outside DataManager"""
if self.active_event:
assert self.active_event in self.active_mapping.event_combination
self.message_broker.send(self.active_event)
def send_uinputs(self):
"""send the "uinputs" message on the MessageBroker"""
self.message_broker.send(
UInputsData(
{
name: uinput.capabilities()
for name, uinput in self._uinputs.devices.items()
}
)
)
def send_groups(self):
"""send the "groups" message on the MessageBroker"""
self._reader.send_groups()
def send_injector_state(self):
"""send the "injector_state" message with the state of the injector
for the active_group"""
if not self.active_group:
return
self.message_broker.send(InjectorState(self.get_state()))
@property
def active_group(self) -> Optional[_Group]:
"""the currently loaded group"""
return self._reader.group
@property
def active_preset(self) -> Optional[Preset[UIMapping]]:
"""the currently loaded preset"""
return self._active_preset
@property
def active_mapping(self) -> Optional[UIMapping]:
"""the currently loaded mapping"""
return self._active_mapping
@property
def active_event(self) -> Optional[InputEvent]:
"""the currently loaded event"""
return self._active_event
def get_group_keys(self) -> Tuple[GroupKey, ...]:
"""Get all group keys (plugged devices)"""
return tuple(group.key for group in self._reader.groups.filter())
def get_preset_names(self) -> Tuple[Name, ...]:
"""Get all preset names for active_group and current user,
starting with the newest."""
if not self.active_group:
raise DataManagementError("cannot find presets: Group is not set")
device_folder = get_preset_path(self.active_group.name)
mkdir(device_folder)
paths = glob.glob(os.path.join(device_folder, "*.json"))
presets = [
os.path.splitext(os.path.basename(path))[0]
for path in sorted(paths, key=os.path.getmtime)
]
# the highest timestamp to the front
presets.reverse()
return tuple(presets)
def get_mappings(self) -> Optional[List[Tuple[Optional[Name], EventCombination]]]:
"""all mapping names and their combination from the active_preset"""
if not self._active_preset:
return None
return [
(mapping.name, mapping.event_combination) for mapping in self._active_preset
]
def get_autoload(self) -> bool:
"""the autoload status of the active_preset"""
if not self.active_preset or not self.active_group:
return False
return self._config.is_autoloaded(
self.active_group.key, self.active_preset.name
)
def set_autoload(self, status: bool):
"""set the autoload status of the active_preset.
Will send "preset" message on the MessageBroker
"""
if not self.active_preset or not self.active_group:
raise DataManagementError("cannot set autoload status: Preset is not set")
if status:
self._config.set_autoload_preset(
self.active_group.key, self.active_preset.name
)
elif self.get_autoload:
self._config.set_autoload_preset(self.active_group.key, None)
self.send_preset()
def get_newest_group_key(self) -> GroupKey:
"""group_key of the group with the most recently modified preset"""
paths = []
for path in glob.glob(os.path.join(get_preset_path(), "*/*.json")):
if self._reader.groups.find(key=split_all(path)[-2]):
paths.append((path, os.path.getmtime(path)))
if not paths:
raise FileNotFoundError()
path, _ = max(paths, key=lambda x: x[1])
return split_all(path)[-2]
def get_newest_preset_name(self) -> Name:
"""preset name of the most recently modified preset in the active group"""
if not self.active_group:
raise DataManagementError("cannot find newest preset: Group is not set")
paths = [
(path, os.path.getmtime(path))
for path in glob.glob(
os.path.join(get_preset_path(self.active_group.name), "*.json")
)
]
if not paths:
raise FileNotFoundError()
path, _ = max(paths, key=lambda x: x[1])
return os.path.split(path)[-1].split(".")[0]
def get_available_preset_name(self, name=DEFAULT_PRESET_NAME) -> Name:
"""the first available preset in the active group"""
if not self.active_group:
raise DataManagementError("unable find preset name. Group is not set")
name = name.strip()
# find a name that is not already taken
if os.path.exists(get_preset_path(self.active_group.name, name)):
# if there already is a trailing number, increment it instead of
# adding another one
match = re.match(r"^(.+) (\d+)$", name)
if match:
name = match[1]
i = int(match[2]) + 1
else:
i = 2
while os.path.exists(
get_preset_path(self.active_group.name, f"{name} {i}")
):
i += 1
return f"{name} {i}"
return name
def load_group(self, group_key: str):
"""Load a group. will send "groups" and "injector_state"
messages on the MessageBroker.
this will render the active_mapping and active_preset invalid
"""
if group_key not in self.get_group_keys():
raise DataManagementError("Unable to load non existing group")
self._active_event = None
self._active_mapping = None
self._active_preset = None
group = self._reader.groups.find(key=group_key)
self._reader.set_group(group)
self.send_group()
self.send_injector_state()
def load_preset(self, name: str):
"""Load a preset. Will send "preset" message on the MessageBroker
this will render the active_mapping invalid
"""
if not self.active_group:
raise DataManagementError("Unable to load preset. Group is not set")
preset_path = get_preset_path(self.active_group.name, name)
preset = Preset(preset_path, mapping_factory=UIMapping)
preset.load()
self._active_event = None
self._active_mapping = None
self._active_preset = preset
self.send_preset()
def load_mapping(self, combination: EventCombination):
"""Load a mapping. Will send "mapping" message on the MessageBroker"""
if not self._active_preset:
raise DataManagementError("Unable to load mapping. Preset is not set")
mapping = self._active_preset.get_mapping(combination)
if not mapping:
raise KeyError(
f"the mapping with {combination = } does not "
f"exist in the {self._active_preset.path}"
)
self._active_event = None
self._active_mapping = mapping
self.send_mapping()
def load_event(self, event: InputEvent):
"""Load a InputEvent from the combination in the active mapping.
Will send "event" message on the MessageBroker"""
if not self.active_mapping:
raise DataManagementError("Unable to load event. mapping is not set")
if event not in self.active_mapping.event_combination:
raise ValueError(
f"{event} is not member of active_mapping.event_combination: "
f"{self.active_mapping.event_combination}"
)
self._active_event = event
self.send_event()
def rename_preset(self, new_name: str):
"""rename the current preset and move the correct file
Will send "group" and then "preset" message on the MessageBroker
"""
if not self.active_preset or not self.active_group:
raise DataManagementError("Unable rename preset: Preset is not set")
if self.active_preset.path == get_preset_path(self.active_group.name, new_name):
return
old_path = self.active_preset.path
assert old_path is not None
old_name = os.path.basename(old_path).split(".")[0]
new_path = get_preset_path(self.active_group.name, new_name)
if os.path.exists(new_path):
raise ValueError(
f"cannot rename {old_name} to " f"{new_name}, preset already exists"
)
logger.info('Moving "%s" to "%s"', old_path, new_path)
os.rename(old_path, new_path)
now = time.time()
os.utime(new_path, (now, now))
if self._config.is_autoloaded(self.active_group.key, old_name):
self._config.set_autoload_preset(self.active_group.key, new_name)
self.active_preset.path = get_preset_path(self.active_group.name, new_name)
self.send_group()
self.send_preset()
def copy_preset(self, name: str):
"""copy the current preset to the given name.
Will send "group" and "preset" message to the MessageBroker and load the copy
"""
# todo: Do we want to load the copy here? or is this up to the controller?
if not self.active_preset or not self.active_group:
raise DataManagementError("Unable to copy preset: Preset is not set")
if self.active_preset.path == get_preset_path(self.active_group.name, name):
return
if name in self.get_preset_names():
raise ValueError(f"a preset with the name {name} already exits")
new_path = get_preset_path(self.active_group.name, name)
logger.info('Copy "%s" to "%s"', self.active_preset.path, new_path)
self.active_preset.path = new_path
self.save()
self.send_group()
self.send_preset()
def create_preset(self, name: str):
"""create empty preset in the active_group.
Will send "group" message to the MessageBroker
"""
if not self.active_group:
raise DataManagementError("Unable to add preset. Group is not set")
path = get_preset_path(self.active_group.name, name)
if os.path.exists(path):
raise DataManagementError("Unable to add preset. Preset exists")
Preset(path).save()
self.send_group()
def delete_preset(self):
"""delete the active preset
Will send "group" message to the MessageBroker
this will invalidate the active mapping,
"""
preset_path = self._active_preset.path
logger.info('Removing "%s"', preset_path)
os.remove(preset_path)
self._active_mapping = None
self._active_preset = None
self.send_group()
def update_mapping(self, **kwargs):
"""update the active mapping with the given keywords and values.
Will send "mapping" message to the MessageBroker. In case of a new event_combination
this will first send a "combination_update" message
"""
if not self._active_mapping:
raise DataManagementError("Cannot modify Mapping: mapping is not set")
if symbol := kwargs.get("output_symbol"):
kwargs["output_symbol"] = self._system_mapping.correct_case(symbol)
combination = self.active_mapping.event_combination
for key, value in kwargs.items():
setattr(self._active_mapping, key, value)
if (
"event_combination" in kwargs
and combination != self.active_mapping.event_combination
):
self._active_event = None
self.message_broker.send(
CombinationUpdate(combination, self._active_mapping.event_combination)
)
self.send_mapping()
def update_event(self, new_event: InputEvent):
"""update the active event.
Will send "combination_update", "mapping" and "event" messages to the
MessageBroker (in that order)
"""
if not self.active_mapping or not self.active_event:
raise DataManagementError("Cannot modify event: event is not set")
combination = list(self.active_mapping.event_combination)
combination[combination.index(self.active_event)] = new_event
self.update_mapping(event_combination=EventCombination(combination))
self._active_event = new_event
self.send_event()
def create_mapping(self):
"""create empty mapping in the active preset.
Will send "preset" message to the MessageBroker
"""
if not self._active_preset:
raise DataManagementError("cannot create mapping: preset is not set")
self._active_preset.add(UIMapping())
self.send_preset()
def delete_mapping(self):
"""delete the active mapping
Will send "preset" message to the MessageBroker
"""
if not self._active_mapping:
raise DataManagementError(
"cannot delete active mapping: active mapping is not set"
)
self._active_preset.remove(self._active_mapping.event_combination)
self._active_mapping = None
self.send_preset()
def save(self):
"""save the active preset"""
if self._active_preset:
self._active_preset.save()
def refresh_groups(self):
"""refresh the groups (plugged devices)
Should send "groups" message to MessageBroker this will not happen immediately
because the system might take a bit until the groups are available
"""
self._reader.refresh_groups()
def start_combination_recording(self):
"""Record user input.
Will send "combination_recorded" messages as new input arrives.
Will eventually send a "recording_finished" message.
"""
self._reader.start_recorder()
def stop_combination_recording(self):
"""Stop recording user input.
Will send RecordingFinished message if a recording is running.
"""
self._reader.stop_recorder()
def stop_injecting(self) -> None:
"""stop injecting for the active group
Will send "injector_state" message once the injector has stopped"""
if not self.active_group:
raise DataManagementError("cannot stop injection: group is not set")
self._daemon.stop_injecting(self.active_group.key)
self.do_when_injector_state({STOPPED}, self.send_injector_state)
def start_injecting(self) -> bool:
"""start injecting the active preset for the active group.
returns if the startup was successfully initialized.
Will send "injector_state" message once the startup is complete.
"""
if not self.active_preset or not self.active_group:
raise DataManagementError("cannot start injection: preset is not set")
self._daemon.set_config_dir(self._config.get_dir())
assert self.active_preset.name is not None
if self._daemon.start_injecting(self.active_group.key, self.active_preset.name):
self.do_when_injector_state(
{RUNNING, FAILED, NO_GRAB, UPGRADE_EVDEV}, self.send_injector_state
)
return True
return False
def get_state(self) -> int:
"""the state of the injector"""
if not self.active_group:
raise DataManagementError("cannot read state: group is not set")
return self._daemon.get_state(self.active_group.key)
def refresh_service_config_path(self):
"""tell the service to refresh its config path"""
self._daemon.set_config_dir(self._config.get_dir())
def do_when_injector_state(self, states: Set[int], callback):
"""run callback once the injector state is one of states"""
def do():
if self.get_state() in states:
callback()
return False
return True
GLib.timeout_add(100, do)

@ -1,748 +0,0 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""The editor with multiline code input, recording toggle and autocompletion."""
import re
import locale
import gettext
import os
import time
from typing import Optional
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
from inputremapper.injection.global_uinputs import global_uinputs
class SelectionLabel(Gtk.ListBoxRow):
"""One label per mapping in the preset.
This wrapper serves as a storage for the information the inherited label represents.
"""
__gtype_name__ = "SelectionLabel"
def __init__(self):
super().__init__()
self.combination = None
self.symbol = ""
label = Gtk.Label()
# Make the child label widget break lines, important for
# long combinations
label.set_line_wrap(True)
label.set_line_wrap_mode(Gtk.WrapMode.WORD)
label.set_justify(Gtk.Justification.CENTER)
self.label = label
self.add(label)
self.show_all()
def set_combination(self, combination: EventCombination):
"""Set the combination this button represents
Parameters
----------
combination : EventCombination
"""
self.combination = combination
if combination:
self.label.set_label(combination.beautify())
else:
self.label.set_label(_("new entry"))
def get_combination(self) -> EventCombination:
return self.combination
def set_label(self, label):
return self.label.set_label(label)
def get_label(self):
return self.label.get_label()
def __str__(self):
return f"SelectionLabel({str(self.combination)})"
def __repr__(self):
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."""
def wrapped(self, *args, **kwargs):
if self.user_interface.preset_name:
self.gather_changes_and_save()
return func(self, *args, **kwargs)
return wrapped
SET_KEY_FIRST = _("Set the key first")
RECORD_ALL = float("inf")
RECORD_NONE = 0
class Editor:
"""Maintains the widgets of the editor."""
def __init__(self, user_interface):
self.user_interface = user_interface
self.autocompletion = None
self.active_mapping: Optional[UIMapping] = None
self._setup_target_selector()
self._setup_source_view()
self._setup_recording_toggle()
self.window = self.get("window")
self.timeouts = [
GLib.timeout_add(100, self.check_add_new_key),
GLib.timeout_add(1000, self.update_toggle_opacity),
]
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)
self.device = user_interface.group
# keys were not pressed yet
self._input_has_arrived = False
self.record_events_until = RECORD_NONE
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)
target_selector = self.get_target_selector()
target_selector.connect("changed", self._on_target_input_changed)
def __del__(self):
for timeout in self.timeouts:
GLib.source_remove(timeout)
self.timeouts = []
def _on_toggle_clicked(self, toggle, event=None):
if toggle.get_active():
self._show_press_key()
else:
self._show_change_key()
@ensure_everything_saved
def _on_toggle_unfocus(self, toggle, event=None):
toggle.set_active(False)
@ensure_everything_saved
def on_text_input_unfocus(self, *_):
"""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 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.
Instead, it will save the preset when it is really needed, i.e. when a button
that requires a saved preset is pressed. For this there exists the
@ensure_everything_saved decorator.
To avoid maybe forgetting to add this decorator somewhere, it will also save
when unfocusing the text input.
If the scroll wheel is used to interact with gtk widgets it won't unfocus,
so this focus-out handler is not the solution to everything as well.
One could debounce saving on text-change to avoid those logs, but that just
sounds like a huge source of race conditions and is also hard to test.
"""
pass # the decorator will be triggered
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."""
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.
This is really important to do before loading a different preset.
Otherwise the inputs will be read and then saved into the next preset.
"""
if self.active_selection_label:
self.set_combination(None)
self.disable_symbol_input(clear=True)
self.set_target_selection("keyboard") # sane default
self.disable_target_selector()
self._reset_keycode_consumption()
self.clear_mapping_list()
def clear_mapping_list(self):
"""Clear the labels from the mapping selection and add an empty one."""
selection_label_listbox = self.get("selection_label_listbox")
selection_label_listbox.forall(selection_label_listbox.remove)
self.add_empty()
selection_label_listbox.select_row(selection_label_listbox.get_children()[0])
def _setup_target_selector(self):
"""Prepare the target selector combobox."""
target_store = Gtk.ListStore(str)
for uinput in global_uinputs.devices:
target_store.append([uinput])
target_input = self.get_target_selector()
target_input.set_model(target_store)
renderer_text = Gtk.CellRendererText()
target_input.pack_start(renderer_text, False)
target_input.add_attribute(renderer_text, "text", 0)
target_input.set_id_column(0)
def _setup_recording_toggle(self):
"""Prepare the toggle button for recording key inputs."""
toggle = self.get_recording_toggle()
toggle.connect("focus-out-event", self._show_change_key)
toggle.connect("focus-in-event", self._show_press_key)
toggle.connect("clicked", self._on_toggle_clicked)
toggle.connect("focus-out-event", self._reset_keycode_consumption)
toggle.connect("focus-out-event", self._on_toggle_unfocus)
toggle.connect("toggled", self._on_recording_toggle_toggle)
# Don't leave the input when using arrow keys or tab. wait for the
# window to consume the keycode from the reader. I.e. a tab input should
# be recorded, instead of causing the recording to stop.
toggle.connect("key-press-event", lambda *args: Gdk.EVENT_STOP)
def _show_press_key(self, *args):
"""Show user friendly instructions."""
self.get_recording_toggle().set_label(_("Press Key"))
def _show_change_key(self, *args):
"""Show user friendly instructions."""
self.get_recording_toggle().set_label(_("Change Key"))
def _setup_source_view(self):
"""Prepare the 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
# scrollable by a few pixels.
# Found this after making blind guesses with settings in glade, and then
# actually looking at the snaphot preview! In glades editor this didn have an
# effect.
source_view.set_resize_mode(Gtk.ResizeMode.IMMEDIATE)
source_view.get_buffer().connect("changed", self.show_line_numbers_if_multiline)
# Syntax Highlighting
# Thanks to https://github.com/wolfthefallen/py-GtkSourceCompletion-example
# language_manager = GtkSource.LanguageManager()
# fun fact: without saving LanguageManager into its own variable it doesn't work
# python = language_manager.get_language("python")
# source_view.get_buffer().set_language(python)
# TODO there are some similarities with python, but overall it's quite useless.
# commented out until there is proper highlighting for input-remappers syntax.
autocompletion = Autocompletion(source_view, self.get_target_selector())
autocompletion.set_relative_to(self.get("code_editor_container"))
autocompletion.connect("suggestion-inserted", self.gather_changes_and_save)
self.autocompletion = autocompletion
def show_line_numbers_if_multiline(self, *_):
"""Show line numbers if a macro is being edited."""
code_editor = self.get_code_editor()
symbol = self.get_symbol_input_text() or ""
if "\n" in symbol:
code_editor.set_show_line_numbers(True)
code_editor.set_monospace(True)
code_editor.get_style_context().add_class("multiline")
else:
code_editor.set_show_line_numbers(False)
code_editor.set_monospace(False)
code_editor.get_style_context().remove_class("multiline")
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")
selection_label_listbox = selection_label_listbox.get_children()
for selection_label in selection_label_listbox:
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:
self.add_empty()
return True
def disable_symbol_input(self, clear=False):
"""Display help information and dont allow entering a symbol.
Without this, maybe a user enters a symbol or writes a macro, switches
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 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() == "":
# don't overwrite user input
self.set_symbol_input_text(SET_KEY_FIRST)
def enable_symbol_input(self):
"""Don't display help information anymore and allow changing the symbol."""
logger.debug("Enabling the code editor")
text_input = self.get_code_editor()
text_input.set_sensitive(True)
text_input.set_opacity(1)
buffer = text_input.get_buffer()
symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True)
if symbol == SET_KEY_FIRST:
# don't overwrite user input
self.set_symbol_input_text("")
def disable_target_selector(self):
"""Don't allow any selection."""
selector = self.get_target_selector()
selector.set_sensitive(False)
selector.set_opacity(0.5)
def enable_target_selector(self):
selector = self.get_target_selector()
selector.set_sensitive(True)
selector.set_opacity(1)
@ensure_everything_saved
def on_mapping_selected(self, _=None, selection_label=None):
"""One of the buttons in the left "combination" column was clicked.
Load the information from that mapping entry into the editor.
"""
self.active_selection_label = selection_label
if selection_label is None:
return
combination = selection_label.combination
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")
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:
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_code_editor())
def add_empty(self):
"""Add one empty row for a single mapped key."""
selection_label_listbox = self.get("selection_label_listbox")
mapping_selection = SelectionLabel()
mapping_selection.set_label(_("new entry"))
mapping_selection.show_all()
selection_label_listbox.insert(mapping_selection, -1)
@ensure_everything_saved
def load_custom_mapping(self):
"""Display the entries in active_preset."""
selection_label_listbox = self.get("selection_label_listbox")
selection_label_listbox.forall(selection_label_listbox.remove)
for mapping in active_preset:
selection_label = SelectionLabel()
selection_label.set_combination(mapping.event_combination)
selection_label_listbox.insert(selection_label, -1)
self.check_add_new_key()
# select the first entry
selection_labels = selection_label_listbox.get_children()
if len(selection_labels) == 0:
self.add_empty()
selection_labels = selection_label_listbox.get_children()
selection_label_listbox.select_row(selection_labels[0])
def get_recording_toggle(self) -> Gtk.ToggleButton:
return self.get("key_recording_toggle")
def get_code_editor(self) -> GtkSource.View:
return self.get("code_editor")
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()
else:
self.disable_symbol_input()
def get_combination(self):
"""Get the EventCombination object from the left column.
Or None if no code is mapped on this row.
"""
if self.active_selection_label is None:
return None
return self.active_selection_label.combination
def set_symbol_input_text(self, symbol):
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(
code_editor,
Gtk.MovementStep.BUFFER_ENDS,
-1,
False,
)
def get_symbol_input_text(self):
"""Get the assigned symbol from the text input.
This might not be stored in active_preset yet, and might therefore also not
be part of the preset json file yet.
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()
symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True)
if symbol == SET_KEY_FIRST:
# not configured yet
return ""
return symbol
def set_target_selection(self, target):
selector = self.get_target_selector()
selector.set_active_id(target)
def get_target_selection(self):
return self.get_target_selector().get_active_id()
def get(self, name):
"""Get a widget from the window."""
return self.user_interface.builder.get_object(name)
def update_toggle_opacity(self):
"""If the key can't be mapped, grey it out.
During injection, when the device is grabbed and weird things are being
done, it is not possible.
"""
toggle = self.get_recording_toggle()
if not self.user_interface.can_modify_preset():
toggle.set_opacity(0.4)
else:
toggle.set_opacity(1)
return True
def _on_recording_toggle_toggle(self, toggle):
"""Refresh useful usage information."""
if not toggle.get_active():
# if more events arrive from the time when the toggle was still on,
# use them.
self.record_events_until = time.time()
return
self.record_events_until = RECORD_ALL
self._reset_keycode_consumption()
reader.clear()
if not self.user_interface.can_modify_preset():
# because the device is in grab mode by the daemon and
# therefore the original keycode inaccessible
logger.info("Cannot change keycodes while injecting")
self.user_interface.show_status(
CTX_ERROR, _('Use "Stop Injection" to stop before editing')
)
toggle.set_active(False)
def _on_delete_button_clicked(self, *_):
"""Destroy the row and remove it from the config."""
accept = Gtk.ResponseType.ACCEPT
if (
len(self.get_symbol_input_text()) > 0
and self._show_confirm_delete() != accept
):
return
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)
self.load_custom_mapping()
def _show_confirm_delete(self):
"""Blocks until the user decided about an action."""
confirm_delete = self.get("confirm-delete")
text = _("Are you sure to delete this mapping?")
self.get("confirm-delete-label").set_text(text)
confirm_delete.show()
response = confirm_delete.run()
confirm_delete.hide()
return response
def gather_changes_and_save(self, *_):
"""Look into the ui if new changes should be written, and save the preset."""
# correct case
symbol = self.get_symbol_input_text()
target = self.get_target_selection()
if not symbol or not target:
return
# save to disk if required
if active_preset.is_valid():
self.user_interface.save_preset()
def is_waiting_for_input(self):
"""Check if the user is trying to record buttons."""
return self.get_recording_toggle().get_active()
def should_record_combination(self, combination):
"""Check if the combination was written when the toggle was active."""
# At this point the toggle might already be off, because some keys that are
# used while the toggle was still on might cause the focus of the toggle to
# be lost, like multimedia keys. This causes the toggle to be disabled.
# Yet, this event should be mapped.
timestamp = max([event.timestamp() for event in combination])
return timestamp < self.record_events_until
def consume_newest_keycode(self, combination: EventCombination):
"""To capture events from keyboards, mice and gamepads."""
self._switch_focus_if_complete()
if combination is None:
return
if not self.should_record_combination(combination):
# the event arrived after the toggle has been deactivated
logger.debug("Recording toggle is not on")
return
if not isinstance(combination, EventCombination):
raise TypeError("Expected new_key to be a EventCombination object")
# keycode is already set by some other row
existing = active_preset.get_mapping(combination)
if existing is not None:
msg = _('"%s" already mapped to "%s"') % (
combination.beautify(),
existing.event_combination.beautify(),
)
logger.info("%s %s", combination, msg)
self.user_interface.show_status(CTX_KEYCODE, msg)
return
if combination.is_problematic():
self.user_interface.show_status(
CTX_WARNING,
_("ctrl, alt and shift may not combine properly"),
_("Your system might reinterpret combinations ")
+ _("with those after they are injected, and by doing so ")
+ _("break them."),
)
# the newest_keycode is populated since the ui regularly polls it
# in order to display it in the status bar.
previous_key = self.get_combination()
# it might end up being a key combination, wait for more
self._input_has_arrived = True
# keycode didn't change, do nothing
if combination == previous_key:
logger.debug("%s didn't change", previous_key)
return
self.set_combination(combination)
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.
States:
1. not doing anything, waiting for the user to start using it
2. user focuses it, no keys pressed
3. user presses keys
4. user releases keys. no keys are pressed, just like in step 2, but this time
the focus needs to switch.
"""
if not self.is_waiting_for_input():
self._reset_keycode_consumption()
return
all_keys_released = reader.get_unreleased_keys() is None
if all_keys_released and self._input_has_arrived and self.get_combination():
logger.debug("Recording complete")
# A key was pressed and then released.
# Switch to the symbol. idle_add this so that the
# keycode event won't write into the symbol input as well.
window = self.user_interface.window
self.enable_symbol_input()
self.enable_target_selector()
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
# reached it.
self._input_has_arrived = True
return
self._reset_keycode_consumption()
def _reset_keycode_consumption(self, *_):
self._input_has_arrived = False

@ -18,11 +18,11 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import os.path
import gettext import gettext
import locale import locale
import os.path
from inputremapper.configs.data import get_data_path from inputremapper.configs.data import get_data_path
from argparse import ArgumentParser
APP_NAME = "input-remapper" APP_NAME = "input-remapper"
LOCALE_DIR = os.path.join(get_data_path(), "lang") LOCALE_DIR = os.path.join(get_data_path(), "lang")

@ -30,25 +30,34 @@ The service shouldn't do that even though it has root rights, because that
would provide a key-logger that can be accessed by any user at all times, would provide a key-logger that can be accessed by any user at all times,
whereas for the helper to start a password is needed and it stops when the ui whereas for the helper to start a password is needed and it stops when the ui
closes. closes.
"""
This uses the backend injection.event_reader and mapping_handlers to process all the
different input-events into simple on/off events and sends them to the gui.
"""
from __future__ import annotations
import sys import asyncio
import select
import multiprocessing import multiprocessing
import subprocess import subprocess
import time import sys
from collections import defaultdict
from typing import Set, List
import evdev import evdev
from evdev.ecodes import EV_KEY, EV_ABS from evdev.ecodes import EV_KEY, EV_ABS, EV_REL, REL_HWHEEL, REL_WHEEL
from inputremapper.configs.mapping import UIMapping
from inputremapper.event_combination import EventCombination
from inputremapper.groups import _Groups, _Group
from inputremapper.injection.event_reader import EventReader
from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
from inputremapper.injection.mapping_handlers.mapping_handler import MappingHandler
from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.ipc.pipe import Pipe from inputremapper.ipc.pipe import Pipe
from inputremapper.logger import logger from inputremapper.logger import logger
from inputremapper.groups import groups
from inputremapper import utils
from inputremapper.user import USER from inputremapper.user import USER
# received by the helper # received by the helper
CMD_TERMINATE = "terminate" CMD_TERMINATE = "terminate"
CMD_REFRESH_GROUPS = "refresh_groups" CMD_REFRESH_GROUPS = "refresh_groups"
@ -76,160 +85,226 @@ class RootHelper:
or strings to start listening on a specific device. or strings to start listening on a specific device.
""" """
def __init__(self): # the speed threshold at which relative axis are considered moving
# and will be sent as "pressed" to the frontend.
# We want to allow some mouse movement before we record it as an input
rel_speed = defaultdict(lambda: 3)
# wheel events usually don't produce values higher than 1
rel_speed[REL_WHEEL] = 1
rel_speed[REL_HWHEEL] = 1
def __init__(self, groups: _Groups):
"""Construct the helper and initialize its sockets.""" """Construct the helper and initialize its sockets."""
self.groups = groups
self._results = Pipe(f"/tmp/input-remapper-{USER}/results") self._results = Pipe(f"/tmp/input-remapper-{USER}/results")
self._commands = Pipe(f"/tmp/input-remapper-{USER}/commands") self._commands = Pipe(f"/tmp/input-remapper-{USER}/commands")
self._send_groups()
self.group = None
self._pipe = multiprocessing.Pipe() self._pipe = multiprocessing.Pipe()
self._tasks: Set[asyncio.Task] = set()
self._stop_event = asyncio.Event()
def run(self): def run(self):
"""Start doing stuff. Blocks.""" """Start doing stuff. Blocks."""
logger.debug("Waiting for the first command")
# the reader will check for new commands later, once it is running # the reader will check for new commands later, once it is running
# it keeps running for one device or another. # it keeps running for one device or another.
select.select([self._commands], [], []) loop = asyncio.get_event_loop()
logger.debug("Discovering initial groups")
# possibly an alternative to select: self.groups.refresh()
"""while True: self._send_groups()
if self._commands.poll(): logger.debug("Waiting commands")
break loop.run_until_complete(self._read_commands())
logger.debug("Helper terminates")
time.sleep(0.1)""" sys.exit(0)
logger.debug("Starting mainloop")
while True:
self._read_commands()
self._start_reading()
def _send_groups(self): def _send_groups(self):
"""Send the groups to the gui.""" """Send the groups to the gui."""
logger.debug("Sending groups") logger.debug("Sending groups")
self._results.send({"type": MSG_GROUPS, "message": groups.dumps()}) self._results.send({"type": MSG_GROUPS, "message": self.groups.dumps()})
def _read_commands(self): async def _read_commands(self):
"""Handle all unread commands.""" """Handle all unread commands.
while self._commands.poll(): this will run until it receives CMD_TERMINATE
cmd = self._commands.recv() """
async for cmd in self._commands:
logger.debug('Received command "%s"', cmd) logger.debug('Received command "%s"', cmd)
if cmd == CMD_TERMINATE: if cmd == CMD_TERMINATE:
logger.debug("Helper terminates") await self._stop_reading()
sys.exit(0) return
if cmd == CMD_REFRESH_GROUPS: if cmd == CMD_REFRESH_GROUPS:
groups.refresh() self.groups.refresh()
self._send_groups() self._send_groups()
continue continue
group = groups.find(key=cmd) group = self.groups.find(key=cmd)
if group is None: if group is None:
groups.refresh() # this will block for a bit maybe we want to do this async?
group = groups.find(key=cmd) self.groups.refresh()
group = self.groups.find(key=cmd)
if group is not None: if group is not None:
self.group = group await self._stop_reading()
self._start_reading(group)
continue continue
logger.error('Received unknown command "%s"', cmd) logger.error('Received unknown command "%s"', cmd)
logger.debug("No more commands in pipe") def _start_reading(self, group: _Group):
"""find all devices of that group, filter interesting ones and send the events
def _start_reading(self): to the gui"""
"""Tell the evdev lib to start looking for keycodes. sources = []
for path in group.paths:
If read is called without prior start_reading, no keycodes
will be available.
This blocks forever until it discovers a new command on the socket.
"""
rlist = {}
if self.group is None:
logger.error("group is None")
return
virtual_devices = []
# Watch over each one of the potentially multiple devices per
# hardware
for path in self.group.paths:
try: try:
device = evdev.InputDevice(path) device = evdev.InputDevice(path)
except FileNotFoundError: except (FileNotFoundError, OSError):
continue logger.error('Could not find "%s"', path)
return None
if evdev.ecodes.EV_KEY in device.capabilities():
virtual_devices.append(device) capabilities = device.capabilities(absinfo=False)
if (
if len(virtual_devices) == 0: EV_KEY in capabilities
logger.debug('No interesting device for "%s"', self.group.key) or EV_ABS in capabilities
return or EV_REL in capabilities
):
for device in virtual_devices: sources.append(device)
rlist[device.fd] = device
context = self._create_event_pipeline(sources)
logger.debug( # create the event reader and start it
'Starting reading keycodes from "%s"', for device in sources:
'", "'.join([device.name for device in virtual_devices]), reader = EventReader(context, device, ForwardDummy, self._stop_event)
) self._tasks.add(asyncio.create_task(reader.run()))
rlist[self._commands] = self._commands async def _stop_reading(self):
"""stop the running event_reader"""
while True: self._stop_event.set()
ready_fds = select.select(rlist, [], []) if self._tasks:
if len(ready_fds[0]) == 0: await asyncio.gather(*self._tasks)
# happens with sockets sometimes. Sockets are not stable and self._tasks = set()
# not used, so nothing to worry about now. self._stop_event.clear()
continue
def _create_event_pipeline(self, sources: List[evdev.InputDevice]) -> ContextDummy:
for fd in ready_fds[0]: """create a custom event pipeline for each event code in the
if rlist[fd] == self._commands: device capabilities.
# all commands will cause the reader to start over Instead of sending the events to a uinput they will be sent to the frontend"""
# (possibly for a different device). context = ContextDummy()
# _read_commands will check what is going on # create a context for each source
logger.debug("Stops reading due to new command") for device in sources:
return capabilities = device.capabilities(absinfo=False)
device = rlist[fd] for ev_code in capabilities.get(EV_KEY) or ():
context.notify_callbacks[(EV_KEY, ev_code)].append(
try: ForwardToUIHandler(self._results).notify
event = device.read_one() )
if event:
self._send_event(event, device) for ev_code in capabilities.get(EV_ABS) or ():
except OSError: # positive direction
logger.debug('Device "%s" disappeared', device.path) mapping = UIMapping(
return event_combination=EventCombination((EV_ABS, ev_code, 30)),
target_uinput="keyboard",
def _send_event(self, event, device): )
"""Write the event into the pipe to the main process. handler: MappingHandler = AbsToBtnHandler(
EventCombination((EV_ABS, ev_code, 30)), mapping
Parameters )
---------- handler.set_sub_handler(ForwardToUIHandler(self._results))
event : evdev.InputEvent context.notify_callbacks[(EV_ABS, ev_code)].append(handler.notify)
device : evdev.InputDevice
""" # negative direction
# value: 1 for down, 0 for up, 2 for hold. mapping = UIMapping(
if event.type == EV_KEY and event.value == 2: event_combination=EventCombination((EV_ABS, ev_code, -30)),
# ignore hold-down events target_uinput="keyboard",
return )
handler = AbsToBtnHandler(
blacklisted_keys = [evdev.ecodes.BTN_TOOL_DOUBLETAP] EventCombination((EV_ABS, ev_code, -30)), mapping
)
if event.type == EV_KEY and event.code in blacklisted_keys: handler.set_sub_handler(ForwardToUIHandler(self._results))
return context.notify_callbacks[(EV_ABS, ev_code)].append(handler.notify)
if event.type == EV_ABS: for ev_code in capabilities.get(EV_REL) or ():
abs_range = utils.get_abs_range(device, event.code) # positive direction
event.value = utils.classify_action(event, abs_range) mapping = UIMapping(
else: event_combination=EventCombination(
event.value = utils.classify_action(event) (EV_REL, ev_code, self.rel_speed[ev_code])
),
self._results.send( target_uinput="keyboard",
{ release_timeout=0.3,
"type": MSG_EVENT, force_release_timeout=True,
"message": (event.sec, event.usec, event.type, event.code, event.value), )
} handler = RelToBtnHandler(
) EventCombination((EV_REL, ev_code, self.rel_speed[ev_code])),
mapping,
)
handler.set_sub_handler(ForwardToUIHandler(self._results))
context.notify_callbacks[(EV_REL, ev_code)].append(handler.notify)
# negative direction
mapping = UIMapping(
event_combination=EventCombination(
(EV_REL, ev_code, -self.rel_speed[ev_code])
),
target_uinput="keyboard",
release_timeout=0.3,
force_release_timeout=True,
)
handler = RelToBtnHandler(
EventCombination((EV_REL, ev_code, -self.rel_speed[ev_code])),
mapping,
)
handler.set_sub_handler(ForwardToUIHandler(self._results))
context.notify_callbacks[(EV_REL, ev_code)].append(handler.notify)
return context
class ContextDummy:
def __init__(self):
self.listeners = set()
self.notify_callbacks = defaultdict(list)
def reset(self):
pass
class ForwardDummy:
@staticmethod
def write(*_):
pass
class ForwardToUIHandler:
"""implements the InputEventHandler protocol. Sends all events into the pipe"""
def __init__(self, pipe: Pipe):
self.pipe = pipe
self._last_event = InputEvent.from_tuple((99, 99, 99))
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
forward: evdev.UInput,
supress: bool = False,
) -> bool:
"""filter duplicates and send into the pipe"""
if event != self._last_event:
self._last_event = event
if EventActions.negative_trigger in event.actions:
event = event.modify(value=-1)
logger.debug_key(event, f"to frontend:")
self.pipe.send(
{
"type": MSG_EVENT,
"message": (
event.sec,
event.usec,
event.type,
event.code,
event.value,
),
}
)
return True
def reset(self):
pass

@ -0,0 +1,238 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import os.path
import re
import traceback
from collections import defaultdict, deque
from dataclasses import dataclass
from enum import Enum
from typing import (
Callable,
Dict,
Set,
Protocol,
Tuple,
Deque,
Optional,
List,
Any,
TYPE_CHECKING,
)
from inputremapper.groups import DeviceType
from inputremapper.logger import logger
if TYPE_CHECKING:
from inputremapper.event_combination import EventCombination
class MessageType(Enum):
reset_gui = "reset_gui"
terminate = "terminate"
init = "init"
uinputs = "uinputs"
groups = "groups"
group = "group"
preset = "preset"
mapping = "mapping"
selected_event = "selected_event"
combination_recorded = "combination_recorded"
recording_finished = "recording_finished"
combination_update = "combination_update"
status_msg = "status_msg"
injector_state = "injector_state"
gui_focus_request = "gui_focus_request"
user_confirm_request = "user_confirm_request"
# for unit tests:
test1 = "test1"
test2 = "test2"
class Message(Protocol):
"""the protocol any message must follow to be sent with the MessageBroker"""
message_type: MessageType
# useful type aliases
MessageListener = Callable[[Any], None]
Capabilities = Dict[int, List]
Name = str
Key = str
DeviceTypes = List[DeviceType]
class MessageBroker:
shorten_path = re.compile("inputremapper/")
def __init__(self):
self._listeners: Dict[MessageType, Set[MessageListener]] = defaultdict(set)
self._messages: Deque[Tuple[Message, str, int]] = deque()
self._sending = False
def send(self, data: Message):
"""schedule a massage to be sent.
The message will be sent after all currently pending messages are sent"""
self._messages.append((data, *self.get_caller()))
self._send_all()
def signal(self, signal: MessageType):
"""send a signal without any data payload"""
self.send(Signal(signal))
def _send(self, data: Message, file: str, line: int):
logger.debug(f"from {file}:{line}: Signal={data.message_type.name}: {data}")
for listener in self._listeners[data.message_type].copy():
listener(data)
def _send_all(self):
"""send all scheduled messages in order"""
if self._sending:
# don't run this twice, so we not mess up the order
return
self._sending = True
try:
while self._messages:
self._send(*self._messages.popleft())
finally:
self._sending = False
def subscribe(self, massage_type: MessageType, listener: MessageListener):
"""attach a listener to an event"""
logger.debug("adding new Listener: %s", listener)
self._listeners[massage_type].add(listener)
return self
@staticmethod
def get_caller(position: int = 3) -> Tuple[str, int]:
"""extract a file and line from current stack and format for logging"""
tb = traceback.extract_stack(limit=position)[0]
return os.path.basename(tb.filename), tb.lineno or 0
def unsubscribe(self, listener: MessageListener) -> None:
for listeners in self._listeners.values():
try:
listeners.remove(listener)
except KeyError:
pass
@dataclass(frozen=True)
class UInputsData:
message_type = MessageType.uinputs
uinputs: Dict[Name, Capabilities]
def __str__(self):
string = f"{self.__class__.__name__}(uinputs={self.uinputs})"
# find all sequences of comma+space separated numbers, and shorten them
# to the first and last number
all_matches = [m for m in re.finditer("(\d+, )+", string)]
all_matches.reverse()
for match in all_matches:
start = match.start()
end = match.end()
start += string[start:].find(",") + 2
if start == end:
continue
string = f"{string[:start]}... {string[end:]}"
return string
@dataclass(frozen=True)
class GroupsData:
"""Message containing all available groups and their device types"""
message_type = MessageType.groups
groups: Dict[Key, DeviceTypes]
@dataclass(frozen=True)
class GroupData:
"""Message with the active group and available presets for the group"""
message_type = MessageType.group
group_key: str
presets: Tuple[str, ...]
@dataclass(frozen=True)
class PresetData:
"""Message with the active preset name and mapping names/combinations"""
message_type = MessageType.preset
name: Optional[Name]
mappings: Optional[Tuple[Tuple[Name, "EventCombination"], ...]]
autoload: bool = False
@dataclass(frozen=True)
class StatusData:
"""Message with the strings and id for the status bar"""
message_type = MessageType.status_msg
ctx_id: int
msg: Optional[str] = None
tooltip: Optional[str] = None
@dataclass(frozen=True)
class CombinationRecorded:
"""Message with the latest recoded combination"""
message_type = MessageType.combination_recorded
combination: "EventCombination"
@dataclass(frozen=True)
class CombinationUpdate:
"""Message with the old and new combination (hash for a mapping) when it changed"""
message_type = MessageType.combination_update
old_combination: "EventCombination"
new_combination: "EventCombination"
@dataclass(frozen=True)
class UserConfirmRequest:
"""Message for requesting a user response (confirm/cancel) from the gui"""
message_type = MessageType.user_confirm_request
msg: str
respond: Callable[[bool], None] = lambda _: None
class Signal(Message):
"""Send a Message without any associated data over the MassageBus"""
def __init__(self, message_type: MessageType):
self.message_type: MessageType = message_type
def __str__(self):
return f"Signal: {self.message_type}"
def __eq__(self, other):
return str(self) == str(other)

@ -23,32 +23,32 @@
see gui.helper.helper see gui.helper.helper
""" """
from typing import Optional, List, Generator, Dict, Tuple, Set
from typing import Optional import evdev
from evdev.ecodes import EV_REL from gi.repository import GLib
from inputremapper.input_event import InputEvent
from inputremapper.logger import logger
from inputremapper.event_combination import EventCombination from inputremapper.event_combination import EventCombination
from inputremapper.groups import groups, GAMEPAD from inputremapper.groups import _Groups, _Group
from inputremapper.ipc.pipe import Pipe
from inputremapper.gui.helper import ( from inputremapper.gui.helper import (
MSG_EVENT, MSG_EVENT,
MSG_GROUPS, MSG_GROUPS,
CMD_TERMINATE, CMD_TERMINATE,
CMD_REFRESH_GROUPS, CMD_REFRESH_GROUPS,
) )
from inputremapper import utils from inputremapper.gui.message_broker import (
from inputremapper.gui.active_preset import active_preset MessageBroker,
GroupsData,
MessageType,
CombinationRecorded,
)
from inputremapper.input_event import InputEvent
from inputremapper.ipc.pipe import Pipe
from inputremapper.logger import logger
from inputremapper.user import USER from inputremapper.user import USER
BLACKLISTED_EVENTS = [(1, evdev.ecodes.BTN_TOOL_DOUBLETAP)]
DEBOUNCE_TICKS = 3 RecordingGenerator = Generator[None, InputEvent, None]
def will_report_up(ev_type):
"""Check if this event will ever report a key up (wheels)."""
return ev_type != EV_REL
class Reader: class Reader:
@ -61,192 +61,150 @@ class Reader:
has knowledge of buttons like the middle-mouse button. has knowledge of buttons like the middle-mouse button.
""" """
def __init__(self): def __init__(self, message_broker: MessageBroker, groups: _Groups):
self.previous_event = None self.groups = groups
self.previous_result = None self.message_broker = message_broker
self._unreleased = {}
self._debounce_remove = {}
self._groups_updated = False
self._cleared_at = 0
self.group = None
self.group: Optional[_Group] = None
self.read_timeout: Optional[int] = None
self._recording_generator: Optional[RecordingGenerator] = None
self._results = None self._results = None
self._commands = None self._commands = None
self.connect() self.connect()
self.attach_to_events()
self._read_continuously()
def connect(self): def connect(self):
"""Connect to the helper.""" """Connect to the helper."""
self._results = Pipe(f"/tmp/input-remapper-{USER}/results") self._results = Pipe(f"/tmp/input-remapper-{USER}/results")
self._commands = Pipe(f"/tmp/input-remapper-{USER}/commands") self._commands = Pipe(f"/tmp/input-remapper-{USER}/commands")
def are_new_groups_available(self): def attach_to_events(self):
"""Check if groups contains new devices. """connect listeners to event_reader"""
self.message_broker.subscribe(MessageType.terminate, lambda _: self.terminate())
The ui should then update its list. def _read_continuously(self):
""" """poll the result pipe in regular intervals"""
outdated = self._groups_updated self.read_timeout = GLib.timeout_add(30, self._read)
self._groups_updated = False # assume the ui will react accordingly
return outdated
def _get_event(self, message) -> Optional[InputEvent]: def _read(self):
"""Return an InputEvent if the message contains one. None otherwise.""" """Read the messages from the helper and handle them"""
message_type = message["type"] while self._results.poll():
message_body = message["message"] message = self._results.recv()
if message_type == MSG_GROUPS:
if message_body != groups.dumps():
groups.loads(message_body)
logger.debug("Received %d devices", len(groups))
self._groups_updated = True
return None
if message_type == MSG_EVENT:
return InputEvent(*message_body)
logger.error('Received unknown message "%s"', message)
return None
def read(self):
"""Get the newest key/combination as EventCombination object.
Only reports keys from down-events.
On key-down events the pipe returns changed combinations. Release message_type = message["type"]
events won't cause that and the reader will return None as in message_body = message["message"]
"nothing new to report". So In order to change a combination, one if message_type == MSG_GROUPS:
of its keys has to be released and then a different one pressed. self._update_groups(message_body)
continue
Otherwise making combinations wouldn't be possible. Because at if message_type == MSG_EVENT:
some point the keys have to be released, and that shouldn't cause if not self._recording_generator:
the combination to get trimmed. continue
# update the generator
try:
self._recording_generator.send(InputEvent(*message_body))
except StopIteration:
self.message_broker.signal(MessageType.recording_finished)
self._recording_generator = None
return True
def start_recorder(self) -> None:
"""recorde user input"""
self._recording_generator = self._recorder()
next(self._recording_generator)
def stop_recorder(self) -> None:
"""Stop recording the input.
Will send RecordingFinished message.
""" """
# this is in some ways similar to the keycode_mapper and if self._recording_generator:
# joystick_to_mouse, but its much simpler because it doesn't self._recording_generator.close()
# have to trigger anything, manage any macros and only self._recording_generator = None
# reports key-down events. This function is called periodically self.message_broker.signal(MessageType.recording_finished)
# by the window.
# remember the previous down-event from the pipe in order to
# be able to tell if the reader should return the updated combination
previous_event = self.previous_event
key_down_received = False
self._debounce_tick() def _recorder(self) -> RecordingGenerator:
"""Generator which receives InputEvents.
while self._results.poll():
message = self._results.recv()
event = self._get_event(message)
if event is None:
continue
gamepad = GAMEPAD in self.group.types it accumulates them into EventCombinations and sends those on the message_broker.
if not utils.should_map_as_btn(event, active_preset, gamepad): it will stop once all keys or inputs are released.
"""
active: Set[Tuple[int, int]] = set()
accumulator: List[InputEvent] = []
while True:
event: InputEvent = yield
if event.type_and_code in BLACKLISTED_EVENTS:
continue continue
if event.value == 0: if event.value == 0:
logger.debug_key(event, "release") try:
self._release(event.type_and_code) active.remove((event.type, event.code))
continue except KeyError:
# we haven't seen this before probably a key got released which
if self._unreleased.get(event.type_and_code) == event: # was pressed before we started recording. ignore it.
logger.debug_key(event, "duplicate key down") continue
self._debounce_start(event.event_tuple)
if not active:
# all previously recorded events are released
return
continue continue
# to keep track of combinations. active.add(event.type_and_code)
# "I have got this release event, what was this for?" A release accu_type_code = [e.type_and_code for e in accumulator]
# event for a D-Pad axis might be any direction, hence this maps if event.type_and_code in accu_type_code and event not in accumulator:
# from release to input in order to remember it. Since all release # the value has changed but the event is already in the accumulator
# events have value 0, the value is not used in the combination. # update the event
key_down_received = True i = accu_type_code.index(event.type_and_code)
logger.debug_key(event, "down") accumulator[i] = event
self._unreleased[event.type_and_code] = event self.message_broker.send(
self._debounce_start(event.event_tuple) CombinationRecorded(EventCombination(accumulator))
previous_event = event )
if not key_down_received: if event not in accumulator:
# This prevents writing a subset of the combination into accumulator.append(event)
# result after keys were released. In order to control the gui, self.message_broker.send(
# they have to be released. CombinationRecorded(EventCombination(accumulator))
return None )
self.previous_event = previous_event def set_group(self, group):
if len(self._unreleased) > 0:
result = EventCombination(self._unreleased.values())
if result == self.previous_result:
# don't return the same stuff twice
return None
self.previous_result = result
logger.debug_key(result, "read result")
return result
return None
def start_reading(self, group):
"""Start reading keycodes for a device.""" """Start reading keycodes for a device."""
logger.debug('Sending start msg to helper for "%s"', group.key) logger.debug('Sending start msg to helper for "%s"', group.key)
if self._recording_generator:
self._recording_generator.close()
self._recording_generator = None
self._commands.send(group.key) self._commands.send(group.key)
self.group = group self.group = group
self.clear()
def terminate(self): def terminate(self):
"""Stop reading keycodes for good.""" """Stop reading keycodes for good."""
logger.debug("Sending close msg to helper") logger.debug("Sending close msg to helper")
self._commands.send(CMD_TERMINATE) self._commands.send(CMD_TERMINATE)
if self.read_timeout:
GLib.source_remove(self.read_timeout)
while self._results.poll():
self._results.recv()
def refresh_groups(self): def refresh_groups(self):
"""Ask the helper for new device groups.""" """Ask the helper for new device groups."""
self._commands.send(CMD_REFRESH_GROUPS) self._commands.send(CMD_REFRESH_GROUPS)
def clear(self): def send_groups(self):
"""Next time when reading don't return the previous keycode.""" """announce all known groups"""
logger.debug("Clearing reader") groups: Dict[str, List[str]] = {
while self._results.poll(): group.key: group.types or []
# clear the results pipe and handle any non-event messages, for group in self.groups.filter(include_inputremapper=False)
# otherwise a 'groups' message might get lost }
message = self._results.recv() self.message_broker.send(GroupsData(groups))
self._get_event(message)
def _update_groups(self, dump):
self._unreleased = {} if dump != self.groups.dumps():
self.previous_event = None self.groups.loads(dump)
self.previous_result = None logger.debug("Received %d devices", len(self.groups))
self._groups_updated = True
def get_unreleased_keys(self):
"""Get a EventCombination object of the current keyboard state.""" # send this even if the groups did not change, as the user expects the ui
unreleased = list(self._unreleased.values()) # to respond in some form
self.send_groups()
if len(unreleased) == 0:
return None
return EventCombination(unreleased)
def _release(self, type_code):
"""Modify the state to recognize the releasing of the key."""
if type_code in self._unreleased:
del self._unreleased[type_code]
if type_code in self._debounce_remove:
del self._debounce_remove[type_code]
def _debounce_start(self, event_tuple):
"""Act like the key was released if no new event arrives in time."""
if not will_report_up(event_tuple[0]):
self._debounce_remove[event_tuple[:2]] = DEBOUNCE_TICKS
def _debounce_tick(self):
"""If the counter reaches 0, the key is not considered held down."""
for type_code in list(self._debounce_remove.keys()):
if type_code not in self._unreleased:
continue
# clear wheel events from unreleased after some time
if self._debounce_remove[type_code] == 0:
logger.debug_key(self._unreleased[type_code], "Considered as released")
self._release(type_code)
else:
self._debounce_remove[type_code] -= 1
reader = Reader()

@ -20,59 +20,42 @@
"""User Interface.""" """User Interface."""
from typing import Dict, Callable
from gi.repository import Gtk, GtkSource, Gdk, GObject
import math
import os
import re
import sys
from inputremapper.gui.gettext import _
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.data import get_data_path
from inputremapper.exceptions import MacroParsingError from inputremapper.configs.mapping import MappingData
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
from inputremapper.configs.preset import (
find_newest_preset,
get_presets,
delete_preset,
rename_preset,
get_available_preset_name,
)
from inputremapper.logger import logger, COMMIT_HASH, VERSION, EVDEV_VERSION, is_debug
from inputremapper.groups import (
groups,
GAMEPAD,
KEYBOARD,
UNKNOWN,
GRAPHICS_TABLET,
TOUCHPAD,
MOUSE,
)
from inputremapper.gui.editor.editor import Editor
from inputremapper.event_combination import EventCombination from inputremapper.event_combination import EventCombination
from inputremapper.gui.reader import reader from inputremapper.gui.autocompletion import Autocompletion
from inputremapper.gui.helper import is_helper_running from inputremapper.gui.components import (
from inputremapper.injection.injector import RUNNING, FAILED, NO_GRAB, UPGRADE_EVDEV DeviceSelection,
from inputremapper.daemon import Daemon PresetSelection,
from inputremapper.configs.global_config import global_config MappingListBox,
from inputremapper.injection.macros.parse import is_this_a_macro, parse TargetSelection,
from inputremapper.injection.global_uinputs import global_uinputs CodeEditor,
RecordingToggle,
StatusBar,
AutoloadSwitch,
ReleaseCombinationSwitch,
CombinationListbox,
AnalogInputSwitch,
TriggerThresholdInput,
OutputAxisSelector,
ConfirmCancelDialog,
KeyAxisStack,
ReleaseTimeoutInput,
TransformationDrawArea,
Sliders,
)
from inputremapper.gui.controller import Controller
from inputremapper.gui.message_broker import MessageBroker, MessageType
from inputremapper.gui.utils import ( from inputremapper.gui.utils import (
CTX_ERROR,
CTX_MAPPING,
CTX_APPLY,
CTX_WARNING,
gtk_iteration, gtk_iteration,
debounce,
) )
from inputremapper.injection.injector import InjectorState
from inputremapper.logger import logger, COMMIT_HASH, VERSION, EVDEV_VERSION
from inputremapper.gui.gettext import _
# TODO add to .deb and AUR dependencies # TODO add to .deb and AUR dependencies
# https://cjenkins.wordpress.com/2012/05/08/use-gtksourceview-widget-in-glade/ # https://cjenkins.wordpress.com/2012/05/08/use-gtksourceview-widget-in-glade/
@ -81,77 +64,61 @@ GObject.type_register(GtkSource.View)
# https://stackoverflow.com/questions/60126579/gtk-builder-error-quark-invalid-object-type-webkitwebview # https://stackoverflow.com/questions/60126579/gtk-builder-error-quark-invalid-object-type-webkitwebview
CONTINUE = True def on_close_about(about, _):
GO_BACK = False
ICON_NAMES = {
GAMEPAD: "input-gaming",
MOUSE: "input-mouse",
KEYBOARD: "input-keyboard",
GRAPHICS_TABLET: "input-tablet",
TOUCHPAD: "input-touchpad",
UNKNOWN: None,
}
# sort types that most devices would fall in easily to the right.
ICON_PRIORITIES = [GRAPHICS_TABLET, TOUCHPAD, GAMEPAD, MOUSE, KEYBOARD, UNKNOWN]
def if_group_selected(func):
"""Decorate a function to only execute if a device is selected."""
# this should only happen if no device was found at all
def wrapped(self, *args, **kwargs):
if self.group is None:
return True # work with timeout_add
return func(self, *args, **kwargs)
return wrapped
def if_preset_selected(func):
"""Decorate a function to only execute if a preset is selected."""
# this should only happen if no device was found at all
def wrapped(self, *args, **kwargs):
if self.preset_name is None or self.group is None:
return True # work with timeout_add
return func(self, *args, **kwargs)
return wrapped
def on_close_about(about, event):
"""Hide the about dialog without destroying it.""" """Hide the about dialog without destroying it."""
about.hide() about.hide()
return True return True
def ensure_everything_saved(func):
"""Make sure the editor has written its changes to active_preset and save."""
def wrapped(self, *args, **kwargs):
if self.preset_name:
self.editor.gather_changes_and_save()
return func(self, *args, **kwargs)
return wrapped
class UserInterface: class UserInterface:
"""The input-remapper gtk window.""" """The input-remapper gtk window."""
def __init__(self): def __init__(
self.dbus = None self,
message_broker: MessageBroker,
self.start_processes() controller: Controller,
):
self.message_broker = message_broker
self.controller = controller
# all shortcuts executed when ctrl+...
self.shortcuts: Dict[int, Callable] = {
Gdk.KEY_q: self.controller.close,
Gdk.KEY_r: self.controller.refresh_groups,
Gdk.KEY_Delete: self.controller.stop_injecting,
}
# stores the ids for all the listeners attached to the gui
self.gtk_listeners: Dict[Callable, int] = {}
self.message_broker.subscribe(MessageType.terminate, lambda _: self.close())
self.builder = Gtk.Builder()
self._build_ui()
self.window: Gtk.Window = self.get("window")
self.confirm_cancel_dialog: Gtk.MessageDialog = self.get("confirm-cancel")
self.about: Gtk.Window = self.get("about-dialog")
self.combination_editor: Gtk.Dialog = self.get("combination-editor")
self._create_dialogs()
self._create_components()
self._connect_gtk_signals()
self._connect_message_listener()
self.window.show()
# hide everything until stuff is populated
self.get("vertical-wrapper").set_opacity(0)
# 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()
self.group = None # now show the proper finished content of the window
self.preset_name = None self.get("vertical-wrapper").set_opacity(1)
global_uinputs.prepare_all() def _build_ui(self):
"""build the window from stylesheet and gladefile"""
css_provider = Gtk.CssProvider() css_provider = Gtk.CssProvider()
with open(get_data_path("style.css"), "r") as file: with open(get_data_path("style.css"), "r") as file:
css_provider.load_from_data(bytes(file.read(), encoding="UTF-8")) css_provider.load_from_data(bytes(file.read(), encoding="UTF-8"))
@ -162,34 +129,68 @@ class UserInterface:
) )
gladefile = get_data_path("input-remapper.glade") gladefile = get_data_path("input-remapper.glade")
builder = Gtk.Builder() self.builder.add_from_file(gladefile)
builder.add_from_file(gladefile) self.builder.connect_signals(self)
builder.connect_signals(self)
self.builder = builder def _create_components(self):
"""setup all objects which manage individual components of the ui"""
self.editor = Editor(self) message_broker = self.message_broker
controller = self.controller
# set up the device selection DeviceSelection(message_broker, controller, self.get("device_selection"))
# https://python-gtk-3-tutorial.readthedocs.io/en/latest/treeview.html#the-view PresetSelection(message_broker, controller, self.get("preset_selection"))
combobox: Gtk.ComboBox = self.get("device_selection") MappingListBox(message_broker, controller, self.get("selection_label_listbox"))
self.device_store = Gtk.ListStore(str, str, str) TargetSelection(message_broker, controller, self.get("target-selector"))
combobox.set_model(self.device_store) RecordingToggle(message_broker, controller, self.get("key_recording_toggle"))
renderer_icon = Gtk.CellRendererPixbuf() StatusBar(
renderer_text = Gtk.CellRendererText() message_broker,
renderer_text.set_padding(5, 0) controller,
combobox.pack_start(renderer_icon, False) self.get("status_bar"),
combobox.pack_start(renderer_text, False) self.get("error_status_icon"),
combobox.add_attribute(renderer_icon, "icon-name", 1) self.get("warning_status_icon"),
combobox.add_attribute(renderer_text, "text", 2) )
combobox.set_id_column(0) AutoloadSwitch(message_broker, controller, self.get("preset_autoload_switch"))
ReleaseCombinationSwitch(
self.confirm_delete = builder.get_object("confirm-delete") message_broker, controller, self.get("release-combination-switch")
self.about = builder.get_object("about-dialog") )
CombinationListbox(message_broker, controller, self.get("combination-listbox"))
AnalogInputSwitch(message_broker, controller, self.get("analog-input-switch"))
TriggerThresholdInput(
message_broker, controller, self.get("trigger-threshold-spin-btn")
)
OutputAxisSelector(message_broker, controller, self.get("output-axis-selector"))
ConfirmCancelDialog(
message_broker,
controller,
self.get("confirm-cancel"),
self.get("confirm-cancel-label"),
)
KeyAxisStack(message_broker, controller, self.get("editor-stack"))
ReleaseTimeoutInput(
message_broker, controller, self.get("release-timeout-spin-button")
)
TransformationDrawArea(
message_broker, controller, self.get("transformation-draw-area")
)
Sliders(
message_broker,
controller,
self.get("gain-scale"),
self.get("deadzone-scale"),
self.get("expo-scale"),
)
# code editor and autocompletion
code_editor = CodeEditor(message_broker, controller, self.get("code_editor"))
autocompletion = Autocompletion(message_broker, code_editor)
autocompletion.set_relative_to(self.get("code_editor_container"))
self.autocompletion = autocompletion # only for testing
def _create_dialogs(self):
"""setup different dialogs, such as the about page"""
self.about.connect("delete-event", on_close_about) self.about.connect("delete-event", on_close_about)
# set_position needs to be done once initially, otherwise the # set_position needs to be done once initially, otherwise the
# dialog is not centered when it is opened for the first time # dialog is not centered when it is opened for the first time
self.about.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) self.about.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
self.get("version-label").set_text( self.get("version-label").set_text(
f"input-remapper {VERSION} {COMMIT_HASH[:7]}" f"input-remapper {VERSION} {COMMIT_HASH[:7]}"
f"\npython-evdev {EVDEV_VERSION}" f"\npython-evdev {EVDEV_VERSION}"
@ -197,537 +198,132 @@ class UserInterface:
else "" else ""
) )
window = self.get("window") def _connect_gtk_signals(self):
window.show() self.get("delete_preset").connect(
# hide everything until stuff is populated "clicked", lambda *_: self.controller.delete_preset()
self.get("vertical-wrapper").set_opacity(0) )
self.window = window self.get("copy_preset").connect(
"clicked", lambda *_: self.controller.copy_preset()
source_view = self.get("code_editor") )
source_view.get_buffer().connect("changed", self.check_on_typing) self.get("create_preset").connect(
"clicked", lambda *_: self.controller.add_preset()
# if any of the next steps take a bit to complete, have the window )
# already visible (without content) to make it look more responsive. self.get("apply_preset").connect(
gtk_iteration() "clicked", lambda *_: self.controller.start_injecting()
self.populate_devices() )
self.get("apply_system_layout").connect(
self.timeouts = [] "clicked", lambda *_: self.controller.stop_injecting()
self.setup_timeouts() )
self.get("rename-button").connect("clicked", self.on_gtk_rename_clicked)
# now show the proper finished content of the window self.get("preset_name_input").connect(
self.get("vertical-wrapper").set_opacity(1) "key-release-event", self.on_gtk_preset_name_input_return
)
self.ctrl = False self.get("create_mapping_button").connect(
self.unreleased_warn = False "clicked", lambda *_: self.controller.create_mapping()
self.button_left_warn = False )
self.get("delete-mapping").connect(
if not is_helper_running(): "clicked", lambda *_: self.controller.delete_mapping()
self.show_status(CTX_ERROR, _("The helper did not start")) )
self.combination_editor.connect(
def setup_timeouts(self): # it only takes self as argument, but delete-events provides more
"""Setup all GLib timeouts.""" # probably a gtk bug
self.timeouts = [ "delete-event",
GLib.timeout_add(1000 / 30, self.consume_newest_keycode), lambda dialog, *_: Gtk.Widget.hide_on_delete(dialog),
] )
self.get("edit-combination-btn").connect(
def start_processes(self): "clicked", lambda *_: self.combination_editor.show()
"""Start helper and daemon via pkexec to run in the background.""" )
# this function is overwritten in tests self.get("remove-event-btn").connect(
self.dbus = Daemon.connect() "clicked", lambda *_: self.controller.remove_event()
)
debug = " -d" if is_debug() else "" self.connect_shortcuts()
cmd = f"pkexec input-remapper-control --command helper {debug}"
logger.debug("Running `%s`", cmd)
exit_code = os.system(cmd)
if exit_code != 0:
logger.error("Failed to pkexec the helper, code %d", exit_code)
sys.exit(11)
def show_confirm_delete(self):
"""Blocks until the user decided about an action."""
text = _("Are you sure to delete preset %s?") % self.preset_name
self.get("confirm-delete-label").set_text(text)
self.confirm_delete.show()
response = self.confirm_delete.run()
self.confirm_delete.hide()
return response
def on_key_press(self, window, event):
"""To execute shortcuts.
This has nothing to do with the keycode reader.
"""
if self.editor.is_waiting_for_input():
# don't perform shortcuts while keys are being recorded
return
gdk_keycode = event.get_keyval()[1]
if gdk_keycode in [Gdk.KEY_Control_L, Gdk.KEY_Control_R]:
self.ctrl = True
if self.ctrl:
# shortcuts
if gdk_keycode == Gdk.KEY_q:
self.on_close()
if gdk_keycode == Gdk.KEY_r: def _connect_message_listener(self):
reader.refresh_groups() self.message_broker.subscribe(
MessageType.mapping, self.update_combination_label
)
self.message_broker.subscribe(
MessageType.injector_state, self.on_injector_state_msg
)
if gdk_keycode == Gdk.KEY_Delete: def on_injector_state_msg(self, msg: InjectorState):
self.on_stop_injecting_clicked() """update the ui to reflect the status of the injector"""
stop_injection_btn: Gtk.Button = self.get("apply_system_layout")
recording_toggle: Gtk.ToggleButton = self.get("key_recording_toggle")
if msg.active():
stop_injection_btn.set_opacity(1)
stop_injection_btn.set_sensitive(True)
recording_toggle.set_opacity(0.4)
else:
stop_injection_btn.set_opacity(0.4)
stop_injection_btn.set_sensitive(True)
recording_toggle.set_opacity(1)
def on_key_release(self, window, event): def disconnect_shortcuts(self):
"""To execute shortcuts. """stop listening for shortcuts
This has nothing to do with the keycode reader. e.g. when recording key combinations
""" """
gdk_keycode = event.get_keyval()[1] try:
self.window.disconnect(self.gtk_listeners.pop(self.on_gtk_shortcut))
if gdk_keycode in [Gdk.KEY_Control_L, Gdk.KEY_Control_R]: except KeyError:
self.ctrl = False logger.debug("key listeners seem to be not connected")
def connect_shortcuts(self):
"""stop listening for shortcuts"""
if not self.gtk_listeners.get(self.on_gtk_shortcut):
self.gtk_listeners[self.on_gtk_shortcut] = self.window.connect(
"key-press-event", self.on_gtk_shortcut
)
def get(self, name): def get(self, name):
"""Get a widget from the window""" """Get a widget from the window"""
return self.builder.get_object(name) return self.builder.get_object(name)
@ensure_everything_saved def close(self):
def on_close(self, *args): """Close the window"""
"""Safely close the application."""
logger.debug("Closing window") logger.debug("Closing window")
self.window.hide() self.window.hide()
for timeout in self.timeouts:
GLib.source_remove(timeout)
self.timeouts = []
reader.terminate()
Gtk.main_quit()
@ensure_everything_saved
def select_newest_preset(self):
"""Find and select the newest preset (and its device)."""
group_name, preset = find_newest_preset()
if group_name is not None:
self.get("device_selection").set_active_id(group_name)
if preset is not None:
self.get("preset_selection").set_active_id(preset)
@ensure_everything_saved
def populate_devices(self):
"""Make the devices selectable."""
device_selection = self.get("device_selection")
with HandlerDisabled(device_selection, self.on_select_device):
self.device_store.clear()
for group in groups.filter(include_inputremapper=False):
types = group.types
if len(types) > 0:
device_type = sorted(types, key=ICON_PRIORITIES.index)[0]
icon_name = ICON_NAMES[device_type]
else:
icon_name = None
self.device_store.append([group.key, icon_name, group.key])
self.select_newest_preset()
@if_group_selected
@ensure_everything_saved
def populate_presets(self):
"""Show the available presets for the selected device.
This will destroy unsaved changes in the active_preset.
"""
presets = get_presets(self.group.name)
if len(presets) == 0:
new_preset = get_available_preset_name(self.group.name)
active_preset.clear()
path = self.group.get_preset_path(new_preset)
active_preset.path = path
active_preset.save()
presets = [new_preset]
else:
logger.debug('"%s" presets: "%s"', self.group.name, '", "'.join(presets))
preset_selection = self.get("preset_selection")
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)
# and select the newest one (on the top). triggers on_select_preset
preset_selection.set_active(0)
@if_group_selected
def can_modify_preset(self, *args) -> bool:
"""if changing the preset is possible."""
return self.dbus.get_state(self.group.key) != RUNNING
def consume_newest_keycode(self):
"""To capture events from keyboards, mice and gamepads."""
# the "event" event of Gtk.Window wouldn't trigger on gamepad
# events, so it became a GLib timeout to periodically check kernel
# events.
# letting go of one of the keys of a combination won't just make
# it return the leftover key, it will continue to return None because
# they have already been read.
combination = reader.read()
if reader.are_new_groups_available():
self.populate_devices()
# giving editor its own interval and making it call reader.read itself causes
# incredibly frustrating and miraculous problems. Do not do it. Observations:
# - test_autocomplete_key fails if the gui has been launched and closed by a
# previous test already
# Maybe it has something to do with the order of editor.consume_newest_keycode
# and user_interface.populate_devices.
self.editor.consume_newest_keycode(combination)
return True
@if_group_selected
def on_stop_injecting_clicked(self, *args):
"""Stop injecting the preset."""
self.dbus.stop_injecting(self.group.key)
self.show_status(CTX_APPLY, _("Applied the system default"))
GLib.timeout_add(100, self.show_device_mapping_status)
def show_status(self, context_id, message, tooltip=None):
"""Show a status message and set its tooltip.
If message is None, it will remove the newest message of the
given context_id.
"""
status_bar = self.get("status_bar")
if message is None:
status_bar.remove_all(context_id)
if context_id in (CTX_ERROR, CTX_MAPPING):
self.get("error_status_icon").hide()
if context_id == CTX_WARNING:
self.get("warning_status_icon").hide()
status_bar.set_tooltip_text("")
else:
if tooltip is None:
tooltip = message
self.get("error_status_icon").hide()
self.get("warning_status_icon").hide()
if context_id in (CTX_ERROR, CTX_MAPPING):
self.get("error_status_icon").show()
if context_id == CTX_WARNING:
self.get("warning_status_icon").show()
max_length = 45
if len(message) > max_length:
message = message[: max_length - 3] + "..."
status_bar.push(context_id, message)
status_bar.set_tooltip_text(tooltip)
@debounce(500)
def check_on_typing(self, *_):
"""To save latest input from code editor and call syntax check."""
self.editor.gather_changes_and_save()
self.check_macro_syntax()
def check_macro_syntax(self):
"""Check if the programmed macros are allright."""
# this is totally redundant as the mapping itself has already checked for
# validity but will be reworked anyway.
self.show_status(CTX_MAPPING, None)
for mapping in active_preset:
if not is_this_a_macro(mapping.output_symbol):
continue
try:
parse(mapping.output_symbol)
except MacroParsingError as error:
position = mapping.event_combination.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."""
new_name = self.get("preset_name_input").get_text()
if new_name in ["", self.preset_name]:
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
is_autoloaded = global_config.is_autoloaded(self.group.key, self.preset_name)
if is_autoloaded:
global_config.set_autoload_preset(self.group.key, new_name)
self.get("preset_name_input").set_text("")
self.populate_presets()
@if_preset_selected
def on_delete_preset_clicked(self, *args):
"""Delete a preset from the file system."""
accept = Gtk.ResponseType.ACCEPT
if len(active_preset) > 0 and self.show_confirm_delete() != accept:
return
# avoid having the text of the symbol input leak into the active_preset again
# via a gazillion hooks, causing the preset to be saved again after deleting.
self.editor.clear()
delete_preset(self.group.name, self.preset_name)
self.populate_presets()
@if_preset_selected
def on_apply_preset_clicked(self, button):
"""Apply a preset without saving changes."""
self.save_preset()
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"))
return
preset = self.preset_name def update_combination_label(self, mapping: MappingData):
logger.info('Applying preset "%s" for "%s"', preset, self.group.key) """listens for mapping and updates the combination label"""
label: Gtk.Label = self.get("combination-label")
if not self.button_left_warn: if mapping.event_combination.beautify() == label.get_label():
if active_preset.dangerously_mapped_btn_left():
self.show_status(
CTX_ERROR,
"This would disable your click button",
"Map a button to BTN_LEFT to avoid this.\n"
"To overwrite this warning, press apply again.",
)
self.button_left_warn = True
return
if not self.unreleased_warn:
unreleased = reader.get_unreleased_keys()
if unreleased is not None and unreleased != EventCombination(
InputEvent.btn_left()
):
# it's super annoying if that happens and may break the user
# input in such a way to prevent disabling the preset
logger.error(
"Tried to apply a preset while keys were held down: %s", unreleased
)
self.show_status(
CTX_ERROR,
"Please release your pressed keys first",
"X11 will think they are held down forever otherwise.\n"
"To overwrite this warning, press apply again.",
)
self.unreleased_warn = True
return
self.unreleased_warn = False
self.button_left_warn = False
self.dbus.set_config_dir(get_config_path())
self.dbus.start_injecting(self.group.key, preset)
self.show_status(CTX_APPLY, _("Starting injection..."))
GLib.timeout_add(100, self.show_injection_result)
def on_autoload_switch(self, switch, active):
"""Load the preset automatically next time the user logs in."""
key = self.group.key
preset = self.preset_name
global_config.set_autoload_preset(key, preset if active else None)
# tell the service to refresh its config
self.dbus.set_config_dir(get_config_path())
@ensure_everything_saved
def on_select_device(self, dropdown):
"""List all presets, create one if none exist yet."""
if self.group and dropdown.get_active_id() == self.group.key:
return return
if mapping.event_combination == EventCombination.empty_combination():
group_key = dropdown.get_active_id() label.set_opacity(0.4)
label.set_label(_("no input configured"))
if group_key is None:
return
logger.debug('Selecting device "%s"', group_key)
self.group = groups.find(key=group_key)
self.preset_name = None
self.populate_presets()
reader.start_reading(groups.find(key=group_key))
self.show_device_mapping_status()
def show_injection_result(self):
"""Show if the injection was successfully started."""
state = self.dbus.get_state(self.group.key)
if state == RUNNING:
msg = _("Applied preset %s") % self.preset_name
if active_preset.get_mapping(EventCombination(InputEvent.btn_left())):
msg += _(", CTRL + DEL to stop")
self.show_status(CTX_APPLY, msg)
self.show_device_mapping_status()
return False
if state == FAILED:
self.show_status(
CTX_ERROR, _("Failed to apply preset %s") % self.preset_name
)
return False
if state == NO_GRAB:
self.show_status(
CTX_ERROR,
"The device was not grabbed",
"Either another application is already grabbing it or "
"your preset doesn't contain anything that is sent by the "
"device.",
)
return False
if state == UPGRADE_EVDEV:
self.show_status(
CTX_ERROR,
"Upgrade python-evdev",
"Your python-evdev version is too old.",
)
return False
# keep the timeout running until a relevant state is found
return True
def show_device_mapping_status(self):
"""Figure out if this device is currently under inputremappers control."""
self.editor.update_toggle_opacity()
group_key = self.group.key
state = self.dbus.get_state(group_key)
if state == RUNNING:
logger.info('Group "%s" is currently mapped', group_key)
self.get("apply_system_layout").set_opacity(1)
else:
self.get("apply_system_layout").set_opacity(0.4)
@if_preset_selected
def on_copy_preset_clicked(self, *args):
"""Copy the current preset and select it."""
self.create_preset(copy=True)
@if_group_selected
def on_create_preset_clicked(self, *args):
"""Create a new empty preset and select it."""
self.create_preset()
@ensure_everything_saved
def create_preset(self, copy=False):
"""Create a new preset and select it."""
name = self.group.name
preset = self.preset_name
try:
if copy:
new_preset = get_available_preset_name(name, preset, copy)
else:
new_preset = get_available_preset_name(name)
self.editor.clear()
active_preset.clear()
path = self.group.get_preset_path(new_preset)
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)
if self.get("preset_selection").get_active_id() != new_preset:
# for whatever reason I have to use set_active_id twice for this
# to work in tests all of the sudden
self.get("preset_selection").set_active_id(new_preset)
except PermissionError as error:
error = str(error)
self.show_status(CTX_ERROR, _("Permission denied!"), error)
logger.error(error)
@ensure_everything_saved
def on_select_preset(self, dropdown):
"""Show the mappings of the preset."""
# beware in tests that this function won't be called at all if the
# active_id stays the same
if dropdown.get_active_id() == self.preset_name:
return
preset = dropdown.get_active_text()
if preset is None:
return
logger.debug('Selecting preset "%s"', preset)
self.editor.clear_mapping_list()
self.preset_name = preset
active_preset.clear()
active_preset.path = self.group.get_preset_path(preset)
active_preset.load()
self.editor.load_custom_mapping()
autoload_switch = self.get("preset_autoload_switch")
with HandlerDisabled(autoload_switch, self.on_autoload_switch):
is_autoloaded = global_config.is_autoloaded(
self.group.key, self.preset_name
)
autoload_switch.set_active(is_autoloaded)
self.get("preset_name_input").set_text("")
def save_preset(self, *args):
"""Write changes in the active_preset to disk."""
if not active_preset.has_unsaved_changes():
# optimization, and also avoids tons of redundant logs
logger.debug("Not saving because preset did not change")
return return
try: label.set_opacity(1)
assert self.preset_name is not None label.set_label(mapping.event_combination.beautify())
active_preset.save()
# after saving the preset, its modification date will be the def on_gtk_shortcut(self, _, event: Gdk.EventKey):
# newest, so populate_presets will automatically select the """execute shortcuts"""
# right one again. if event.state & Gdk.ModifierType.CONTROL_MASK:
self.populate_presets() try:
except PermissionError as error: self.shortcuts[event.keyval]()
error = str(error) except KeyError:
self.show_status(CTX_ERROR, _("Permission denied!"), error) pass
logger.error(error)
self.show_status(CTX_MAPPING, None) def on_gtk_close(self, *_):
self.controller.close()
def on_about_clicked(self, button): def on_gtk_about_clicked(self, _):
"""Show the about/help dialog.""" """Show the about/help dialog."""
self.about.show() self.about.show()
def on_about_key_press(self, window, event): def on_gtk_about_key_press(self, _, event):
"""Hide the about/help dialog.""" """Hide the about/help dialog."""
gdk_keycode = event.get_keyval()[1] gdk_keycode = event.get_keyval()[1]
if gdk_keycode == Gdk.KEY_Escape: if gdk_keycode == Gdk.KEY_Escape:
self.about.hide() self.about.hide()
def on_gtk_rename_clicked(self, *_):
name = self.get("preset_name_input").get_text()
self.controller.rename_preset(name)
self.get("preset_name_input").set_text("")
def on_gtk_preset_name_input_return(self, _, event: Gdk.EventKey):
if event.keyval == Gdk.KEY_Return:
self.on_gtk_rename_clicked()

@ -17,12 +17,13 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import time
from gi.repository import Gtk, GLib from gi.repository import Gtk, GLib
# status ctx ids # status ctx ids
CTX_SAVE = 0 CTX_SAVE = 0
CTX_APPLY = 1 CTX_APPLY = 1
CTX_KEYCODE = 2 CTX_KEYCODE = 2
@ -78,7 +79,11 @@ class HandlerDisabled:
self.widget.handler_unblock_by_func(self.handler) self.widget.handler_unblock_by_func(self.handler)
def gtk_iteration(): def gtk_iteration(iterations=0):
"""Iterate while events are pending.""" """Iterate while events are pending."""
while Gtk.events_pending(): while Gtk.events_pending():
Gtk.main_iteration() Gtk.main_iteration()
for _ in range(iterations):
time.sleep(0.002)
while Gtk.events_pending():
Gtk.main_iteration()

@ -20,34 +20,17 @@
"""Stores injection-process wide information.""" """Stores injection-process wide information."""
import asyncio from collections import defaultdict
from typing import Awaitable, List, Dict, Tuple, Protocol, Set from typing import List, Dict, Tuple, Set
import evdev
from inputremapper.configs.preset import Preset 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 ( from inputremapper.injection.mapping_handlers.mapping_handler import (
InputEventHandler, InputEventHandler,
EventListener, EventListener,
NotifyCallback,
) )
from inputremapper.injection.mapping_handlers.mapping_parser import parse_mappings
from inputremapper.input_event import InputEvent
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: class Context:
@ -78,15 +61,13 @@ class Context:
all entry points to the event pipeline sorted by InputEvent.type_and_code all entry points to the event pipeline sorted by InputEvent.type_and_code
""" """
preset: Preset
listeners: Set[EventListener] listeners: Set[EventListener]
callbacks: Dict[Tuple[int, int], List[NotifyCallback]] notify_callbacks: Dict[Tuple[int, int], List[NotifyCallback]]
_handlers: Dict[InputEvent, List[InputEventHandler]] _handlers: Dict[InputEvent, Set[InputEventHandler]]
def __init__(self, preset: Preset): def __init__(self, preset: Preset):
self.preset = preset
self.listeners = set() self.listeners = set()
self.callbacks = {} self.notify_callbacks = defaultdict(list)
self._handlers = parse_mappings(preset, self) self._handlers = parse_mappings(preset, self)
self._create_callbacks() self._create_callbacks()
@ -94,12 +75,12 @@ class Context:
def reset(self) -> None: def reset(self) -> None:
"""Call the reset method for each handler in the context.""" """Call the reset method for each handler in the context."""
for handlers in self._handlers.values(): for handlers in self._handlers.values():
[handler.reset() for handler in handlers] for handler in handlers:
handler.reset()
def _create_callbacks(self) -> None: def _create_callbacks(self) -> None:
"""Add the notify method from all _handlers to self.callbacks.""" """Add the notify method from all _handlers to self.callbacks."""
for event, handler_list in self._handlers.items(): for event, handler_list in self._handlers.items():
if event.type_and_code not in self.callbacks.keys(): self.notify_callbacks[event.type_and_code].extend(
self.callbacks[event.type_and_code] = [] handler.notify for handler in handler_list
for handler in handler_list: )
self.callbacks[event.type_and_code].append(handler.notify)

@ -21,38 +21,24 @@
"""Because multiple calls to async_read_loop won't work.""" """Because multiple calls to async_read_loop won't work."""
import asyncio import asyncio
import evdev from typing import AsyncIterator, Protocol, Set, Dict, Tuple, List
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): import evdev
return self
def __anext__(self): from inputremapper.injection.mapping_handlers.mapping_handler import (
if self.stop_event.is_set(): EventListener,
raise StopAsyncIteration NotifyCallback,
)
from inputremapper.input_event import InputEvent
from inputremapper.logger import logger
return self.future()
async def future(self): class Context(Protocol):
ev_task = asyncio.Task(self.iterator.__anext__()) listeners: Set[EventListener]
stop_task = self.wait_for_stop notify_callbacks: Dict[Tuple[int, int], List[NotifyCallback]]
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() def reset(self):
...
class EventReader: class EventReader:
@ -89,6 +75,27 @@ class EventReader:
self.context = context self.context = context
self.stop_event = stop_event self.stop_event = stop_event
async def read_loop(self) -> AsyncIterator[evdev.InputEvent]:
stop_task = asyncio.Task(self.stop_event.wait())
loop = asyncio.get_running_loop()
events_ready = asyncio.Event()
loop.add_reader(self._source.fileno(), events_ready.set)
while True:
_, pending = await asyncio.wait(
{stop_task, events_ready.wait()},
return_when=asyncio.FIRST_COMPLETED,
)
if stop_task.done():
for task in pending:
task.cancel()
loop.remove_reader(self._source.fileno())
logger.debug("read loop stopped")
return
events_ready.clear()
while event := self._source.read_one():
yield event
def send_to_handlers(self, event: InputEvent) -> bool: def send_to_handlers(self, event: InputEvent) -> bool:
"""Send the event to callback.""" """Send the event to callback."""
if event.type == evdev.ecodes.EV_MSC: if event.type == evdev.ecodes.EV_MSC:
@ -98,7 +105,7 @@ class EventReader:
return False return False
results = set() results = set()
for callback in self.context.callbacks.get(event.type_and_code) or (): for callback in self.context.notify_callbacks.get(event.type_and_code) or ():
results.add(callback(event, source=self._source, forward=self._forward_to)) results.add(callback(event, source=self._source, forward=self._forward_to))
return True in results return True in results
@ -153,8 +160,8 @@ class EventReader:
async def run(self): async def run(self):
"""Start doing things. """Start doing things.
Can be stopped by stopping the asyncio loop. This loop Can be stopped by stopping the asyncio loop or by setting the stop_event.
reads events from a single device only. This loop reads events from a single device only.
""" """
logger.debug( logger.debug(
"Starting to listen for events from %s, fd %s", "Starting to listen for events from %s, fd %s",
@ -162,7 +169,7 @@ class EventReader:
self._source.fd, self._source.fd,
) )
async for event in _ReadLoop(self._source, self.stop_event): async for event in self.read_loop():
await self.handle(InputEvent.from_event(event)) await self.handle(InputEvent.from_event(event))
self.context.reset() self.context.reset()

@ -17,15 +17,14 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Union
import evdev import evdev
import inputremapper.utils
import inputremapper.exceptions import inputremapper.exceptions
import inputremapper.utils
from inputremapper.logger import logger from inputremapper.logger import logger
DEV_NAME = "input-remapper" DEV_NAME = "input-remapper"
DEFAULT_UINPUTS = { DEFAULT_UINPUTS = {
# for event codes see linux/input-event-codes.h # for event codes see linux/input-event-codes.h
@ -87,7 +86,7 @@ class GlobalUInputs:
"""Manages all uinputs that are shared between all injection processes.""" """Manages all uinputs that are shared between all injection processes."""
def __init__(self): def __init__(self):
self.devices = {} self.devices: Dict[str, Union[UInput, FrontendUInput]] = {}
self._uinput_factory = None self._uinput_factory = None
self.is_service = inputremapper.utils.is_service() self.is_service = inputremapper.utils.is_service()

@ -20,26 +20,29 @@
"""Keeps injecting keycodes in the background based on the preset.""" """Keeps injecting keycodes in the background based on the preset."""
from __future__ import annotations
import os
import sys
import asyncio import asyncio
import time
import multiprocessing import multiprocessing
import sys
import time
from dataclasses import dataclass
from multiprocessing.connection import Connection
from typing import Dict, List, Optional, Tuple
import evdev import evdev
from typing import Dict, List, Optional
from inputremapper.configs.preset import Preset from inputremapper.configs.preset import Preset
from inputremapper.event_combination import EventCombination
from inputremapper.logger import logger from inputremapper.groups import (
from inputremapper.groups import classify, GAMEPAD, _Group _Group,
classify,
DeviceType,
)
from inputremapper.gui.message_broker import MessageType
from inputremapper.injection.context import Context from inputremapper.injection.context import Context
from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock
from inputremapper.injection.event_reader import EventReader from inputremapper.injection.event_reader import EventReader
from inputremapper.event_combination import EventCombination from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock
from inputremapper.logger import logger
CapabilitiesDict = Dict[int, List[int]] CapabilitiesDict = Dict[int, List[int]]
GroupSources = List[evdev.InputDevice] GroupSources = List[evdev.InputDevice]
@ -81,6 +84,15 @@ def get_udev_name(name: str, suffix: str) -> str:
return name return name
@dataclass(frozen=True)
class InjectorState:
message_type = MessageType.injector_state
state: int
def active(self) -> bool:
return self.state == RUNNING or self.state == STARTING or self.state == NO_GRAB
class Injector(multiprocessing.Process): class Injector(multiprocessing.Process):
"""Initializes, starts and stops injections. """Initializes, starts and stops injections.
@ -93,7 +105,7 @@ class Injector(multiprocessing.Process):
preset: Preset preset: Preset
context: Optional[Context] context: Optional[Context]
_state: int _state: int
_msg_pipe: multiprocessing.Pipe _msg_pipe: Tuple[Connection, Connection]
_consumer_controls: List[EventReader] _consumer_controls: List[EventReader]
_stop_event: asyncio.Event _stop_event: asyncio.Event
@ -119,9 +131,8 @@ class Injector(multiprocessing.Process):
self.context = None # only needed inside the injection process self.context = None # only needed inside the injection process
self._consumer_controls = [] self._consumer_controls = []
self._stop_event = None
super().__init__(name=group) super().__init__(name=group.key)
"""Functions to interact with the running process""" """Functions to interact with the running process"""
@ -130,29 +141,30 @@ class Injector(multiprocessing.Process):
Can be safely called from the main process. Can be safely called from the main process.
""" """
# before we try to we try to guess anything lets check if there is a message
state = self._state
while self._msg_pipe[1].poll():
state = self._msg_pipe[1].recv()
# figure out what is going on step by step # figure out what is going on step by step
alive = self.is_alive() alive = self.is_alive()
if self._state == UNKNOWN and not alive: if state == UNKNOWN and not alive:
# `self.start()` has not been called yet # `self.start()` has not been called yet
self._state = state
return self._state return self._state
if self._state == UNKNOWN and alive: if state == UNKNOWN and alive:
# if it is alive, it is definitely at least starting up. # if it is alive, it is definitely at least starting up.
self._state = STARTING state = STARTING
if self._state == STARTING and self._msg_pipe[1].poll(): if state in (STARTING, RUNNING) and not alive:
# if there is a message available, it might have finished starting up
# and the injector has the real status for us
msg = self._msg_pipe[1].recv()
self._state = msg
if self._state in [STARTING, RUNNING] and not alive:
# we thought it is running (maybe it was when get_state was previously), # we thought it is running (maybe it was when get_state was previously),
# but the process is not alive. It probably crashed # but the process is not alive. It probably crashed
self._state = FAILED state = FAILED
logger.error("Injector was unexpectedly found stopped") logger.error("Injector was unexpectedly found stopped")
self._state = state
return self._state return self._state
@ensure_numlock @ensure_numlock
@ -163,75 +175,80 @@ class Injector(multiprocessing.Process):
""" """
logger.info('Stopping injecting keycodes for group "%s"', self.group.key) logger.info('Stopping injecting keycodes for group "%s"', self.group.key)
self._msg_pipe[1].send(CLOSE) self._msg_pipe[1].send(CLOSE)
self._state = STOPPED
"""Process internal stuff""" """Process internal stuff"""
def _grab_devices(self) -> GroupSources: def _grab_devices(self) -> GroupSources:
"""Grab all devices that are needed for the injection.""" ranking = [
sources = [] DeviceType.KEYBOARD,
DeviceType.GAMEPAD,
DeviceType.MOUSE,
DeviceType.TOUCHPAD,
DeviceType.GRAPHICS_TABLET,
DeviceType.CAMERA,
DeviceType.UNKNOWN,
]
# query all devices for their capabilities, and type
devices: List[evdev.InputDevice] = []
for path in self.group.paths: for path in self.group.paths:
source = self._grab_device(path) try:
if source is None: devices.append(evdev.InputDevice(path))
# this path doesn't need to be grabbed for injection, because except (FileNotFoundError, OSError):
# it doesn't provide the events needed to execute the preset logger.error('Could not find "%s"', path)
continue continue
sources.append(source)
return sources # find all devices which have an associated mapping
needed_devices = (
{}
) # use a dict because the InputDevice is not directly hashable
for mapping in self.preset:
candidates: List[evdev.InputDevice] = [
device
for device in devices
if is_in_capabilities(
mapping.event_combination, device.capabilities(absinfo=False)
)
]
if len(candidates) > 1:
# there is more than on input device which can be used for this mapping
# we choose only one determined by the ranking
device = sorted(candidates, key=lambda d: ranking.index(classify(d)))[0]
elif len(candidates) == 1:
device = candidates.pop()
else:
logger.error("Could not find input for %s", mapping)
continue
needed_devices[device.path] = device
def _grab_device(self, path: os.PathLike) -> Optional[evdev.InputDevice]: grabbed_devices = []
"""Try to grab the device, return None if not needed/possible. for device in needed_devices.values():
if device := self._grab_device(device):
grabbed_devices.append(device)
return grabbed_devices
def _grab_device(self, device: evdev.InputDevice) -> Optional[evdev.InputDevice]:
"""Try to grab the device, return None if not possible.
Without grab, original events from it would reach the display server Without grab, original events from it would reach the display server
even though they are mapped. even though they are mapped.
""" """
try: error = None
device = evdev.InputDevice(path) for attempt in range(10):
except (FileNotFoundError, OSError):
logger.error('Could not find "%s"', path)
return None
capabilities = device.capabilities(absinfo=False)
needed = False
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
if not needed:
# skipping reading and checking on events from those devices
# may be beneficial for performance.
logger.debug("No need to grab %s", path)
return None
attempts = 0
while True:
try: try:
device.grab() device.grab()
logger.debug("Grab %s", path) logger.debug("Grab %s", device.path)
break return device
except IOError as error: except IOError as err:
attempts += 1
# it might take a little time until the device is free if # it might take a little time until the device is free if
# it was previously grabbed. # it was previously grabbed.
logger.debug("Failed attempts to grab %s: %d", path, attempts) error = err
logger.debug("Failed attempts to grab %s: %d", device.path, attempt + 1)
if attempts >= 10: time.sleep(self.regrab_timeout)
logger.error("Cannot grab %s, it is possibly in use", path)
logger.error(str(error))
return None
time.sleep(self.regrab_timeout)
return device logger.error("Cannot grab %s, it is possibly in use", device.path)
logger.error(str(error))
return None
def _copy_capabilities(self, input_device: evdev.InputDevice) -> CapabilitiesDict: def _copy_capabilities(self, input_device: evdev.InputDevice) -> CapabilitiesDict:
"""Copy capabilities for a new device.""" """Copy capabilities for a new device."""
@ -274,6 +291,7 @@ class Injector(multiprocessing.Process):
# stop the event loop and cause the process to reach its end # stop the event loop and cause the process to reach its end
# cleanly. Using .terminate prevents coverage from working. # cleanly. Using .terminate prevents coverage from working.
loop.stop() loop.stop()
self._msg_pipe[0].send(STOPPED)
return return
def run(self) -> None: def run(self) -> None:

@ -39,9 +39,8 @@ import asyncio
import copy import copy
import math import math
import re import re
from typing import Optional, List, Callable, Awaitable, Tuple from typing import List, Callable, Awaitable, Tuple
import evdev
from evdev.ecodes import ( from evdev.ecodes import (
ecodes, ecodes,
EV_KEY, EV_KEY,
@ -53,10 +52,11 @@ from evdev.ecodes import (
REL_WHEEL, REL_WHEEL,
REL_HWHEEL, REL_HWHEEL,
) )
from inputremapper.logger import logger
from inputremapper.configs.system_mapping import system_mapping from inputremapper.configs.system_mapping import system_mapping
from inputremapper.ipc.shared_dict import SharedDict
from inputremapper.exceptions import MacroParsingError from inputremapper.exceptions import MacroParsingError
from inputremapper.ipc.shared_dict import SharedDict
from inputremapper.logger import logger
Handler = Callable[[Tuple[int, int, int]], None] Handler = Callable[[Tuple[int, int, int]], None]
MacroTask = Callable[[Handler], Awaitable] MacroTask = Callable[[Handler], Awaitable]

@ -22,13 +22,12 @@
"""Parse macro code""" """Parse macro code"""
import re
import traceback
import inspect import inspect
import re
from inputremapper.logger import logger
from inputremapper.injection.macros.macro import Macro, Variable
from inputremapper.exceptions import MacroParsingError from inputremapper.exceptions import MacroParsingError
from inputremapper.injection.macros.macro import Macro, Variable
from inputremapper.logger import logger
def is_this_a_macro(output): def is_this_a_macro(output):

@ -19,19 +19,18 @@
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import evdev
from typing import Tuple from typing import Tuple
import evdev
from evdev.ecodes import EV_ABS from evdev.ecodes import EV_ABS
from inputremapper.configs.mapping import Mapping from inputremapper.configs.mapping import Mapping
from inputremapper.event_combination import EventCombination 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 ( from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler, MappingHandler,
InputEventHandler, InputEventHandler,
) )
from inputremapper.input_event import InputEvent, EventActions
class AbsToBtnHandler(MappingHandler): class AbsToBtnHandler(MappingHandler):
@ -100,21 +99,20 @@ class AbsToBtnHandler(MappingHandler):
value = event.value value = event.value
if (value < threshold > mid_point) or (value > threshold < mid_point): if (value < threshold > mid_point) or (value > threshold < mid_point):
if self._active: if self._active:
event = event.modify(value=0, action=EventActions.as_key) event = event.modify(value=0, actions=(EventActions.as_key,))
else: else:
# consume the event. # consume the event.
# We could return False to forward events # We could return False to forward events
return True return True
else: else:
if not self._active: if value >= threshold > mid_point:
event = event.modify(value=1, action=EventActions.as_key) direction = EventActions.positive_trigger
else: else:
# consume the event. direction = EventActions.negative_trigger
# We could return False to forward events event = event.modify(value=1, actions=(EventActions.as_key, direction))
return True
self._active = bool(event.value) self._active = bool(event.value)
logger.debug_key(event.event_tuple, "sending to sub_handler") # logger.debug_key(event.event_tuple, "sending to sub_handler")
return self._sub_handler.notify( return self._sub_handler.notify(
event, event,
source=source, source=source,

@ -17,14 +17,13 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from functools import partial
import evdev
import time
import asyncio import asyncio
import math import math
import time
from functools import partial
from typing import Dict, Tuple, Optional
from typing import Dict, Tuple, Optional, List, Union import evdev
from evdev.ecodes import ( from evdev.ecodes import (
EV_REL, EV_REL,
EV_ABS, EV_ABS,
@ -35,16 +34,16 @@ from evdev.ecodes import (
) )
from inputremapper.configs.mapping import Mapping from inputremapper.configs.mapping import Mapping
from inputremapper.event_combination import EventCombination
from inputremapper.injection.global_uinputs import global_uinputs
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
from inputremapper.injection.mapping_handlers.mapping_handler import ( from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler, MappingHandler,
HandlerEnums, HandlerEnums,
InputEventHandler, 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.input_event import InputEvent, EventActions
from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.logger import logger
async def _run_normal(self) -> None: async def _run_normal(self) -> None:
@ -166,7 +165,7 @@ class AbsToRelHandler(MappingHandler):
if event.type_and_code != self._map_axis: if event.type_and_code != self._map_axis:
return False return False
if event.action == EventActions.recenter: if EventActions.recenter in event.actions:
self._stop = True self._stop = True
return True return True

@ -21,16 +21,15 @@ from typing import Dict, Tuple
import evdev import evdev
from inputremapper.logger import logger
from inputremapper.configs.mapping import Mapping from inputremapper.configs.mapping import Mapping
from inputremapper.event_combination import EventCombination from inputremapper.event_combination import EventCombination
from inputremapper.injection.mapping_handlers.mapping_handler import ( from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler, MappingHandler,
ContextProtocol,
HandlerEnums, HandlerEnums,
InputEventHandler, InputEventHandler,
) )
from inputremapper.input_event import InputEvent, EventActions from inputremapper.input_event import InputEvent, EventActions
from inputremapper.logger import logger
class AxisSwitchHandler(MappingHandler): class AxisSwitchHandler(MappingHandler):
@ -97,7 +96,7 @@ class AxisSwitchHandler(MappingHandler):
return False return False
self._active = bool(event.value) self._active = bool(event.value)
if not self._active: if not self._active and self._axis_source:
# recenter the axis # recenter the axis
logger.debug_key(self.mapping.event_combination, "stopping axis") logger.debug_key(self.mapping.event_combination, "stopping axis")
event = InputEvent( event = InputEvent(
@ -105,10 +104,10 @@ class AxisSwitchHandler(MappingHandler):
0, 0,
*self._map_axis, *self._map_axis,
0, 0,
action=EventActions.recenter, actions=(EventActions.recenter,),
) )
self._sub_handler.notify(event, self._axis_source, self._forward_device) self._sub_handler.notify(event, self._axis_source, self._forward_device)
elif self._map_axis[0] == evdev.ecodes.EV_ABS: elif self._map_axis[0] == evdev.ecodes.EV_ABS and self._axis_source:
# send the last cached value so that the abs axis # send the last cached value so that the abs axis
# is at the correct position # is at the correct position
logger.debug_key(self.mapping.event_combination, "starting axis") logger.debug_key(self.mapping.event_combination, "starting axis")

@ -17,23 +17,21 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import asyncio
import evdev from typing import Dict, Tuple
from typing import Dict, Tuple, Optional, List import evdev
from evdev.ecodes import EV_ABS, EV_REL, EV_KEY from evdev.ecodes import EV_ABS, EV_REL
from inputremapper.configs.mapping import Mapping from inputremapper.configs.mapping import Mapping
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.event_combination import EventCombination from inputremapper.event_combination import EventCombination
from inputremapper.logger import logger
from inputremapper.injection.mapping_handlers.mapping_handler import ( from inputremapper.injection.mapping_handlers.mapping_handler import (
ContextProtocol,
MappingHandler, MappingHandler,
InputEventHandler, InputEventHandler,
HandlerEnums, HandlerEnums,
) )
from inputremapper.input_event import InputEvent
from inputremapper.logger import logger
class CombinationHandler(MappingHandler): class CombinationHandler(MappingHandler):
@ -100,10 +98,13 @@ class CombinationHandler(MappingHandler):
self.forward_release(forward) self.forward_release(forward)
event = event.modify(value=1) event = event.modify(value=1)
else: else:
if self._output_state: if self._output_state or self.mapping.is_axis_mapping():
# we ignore the supress argument for release events # we ignore the supress argument for release events
# otherwise we might end up with stuck keys # otherwise we might end up with stuck keys
# (test_event_pipeline.test_combination) # (test_event_pipeline.test_combination)
# we also ignore it if the mapping specifies an output axis
# this will enable us to activate multiple axis with the same button
supress = False supress = False
event = event.modify(value=0) event = event.modify(value=0)
@ -131,13 +132,10 @@ class CombinationHandler(MappingHandler):
this might cause duplicate key-up events but those are ignored by evdev anyway this might cause duplicate key-up events but those are ignored by evdev anyway
""" """
if ( if len(self._pressed_keys) == 1 or not self.mapping.release_combination_keys:
len(self.mapping.event_combination) == 1
or not self.mapping.release_combination_keys
):
return return
for event in self.mapping.event_combination: for type_and_code in self._pressed_keys:
forward.write(*event.type_and_code, 0) forward.write(*type_and_code, 0)
forward.syn() forward.syn()
def needs_ranking(self) -> bool: def needs_ranking(self) -> bool:

@ -17,20 +17,18 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import asyncio from typing import List, Dict
import evdev
import evdev
from evdev.ecodes import EV_ABS, EV_REL from evdev.ecodes import EV_ABS, EV_REL
from typing import List, Dict
from inputremapper.event_combination import EventCombination from inputremapper.event_combination import EventCombination
from inputremapper.input_event import InputEvent
from inputremapper.injection.mapping_handlers.mapping_handler import ( from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler, MappingHandler,
InputEventHandler, InputEventHandler,
HandlerEnums, HandlerEnums,
) )
from inputremapper.input_event import InputEvent
class HierarchyHandler(MappingHandler): class HierarchyHandler(MappingHandler):

@ -18,20 +18,19 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from typing import Tuple, Dict, Optional from typing import Tuple, Dict
from inputremapper import exceptions from inputremapper import exceptions
from inputremapper.configs.mapping import Mapping from inputremapper.configs.mapping import Mapping
from inputremapper.event_combination import EventCombination from inputremapper.event_combination import EventCombination
from inputremapper.exceptions import MappingParsingError from inputremapper.exceptions import MappingParsingError
from inputremapper.injection.global_uinputs import global_uinputs
from inputremapper.injection.mapping_handlers.mapping_handler import ( from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler, MappingHandler,
ContextProtocol,
HandlerEnums, HandlerEnums,
) )
from inputremapper.logger import logger
from inputremapper.input_event import InputEvent from inputremapper.input_event import InputEvent
from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.logger import logger
class KeyHandler(MappingHandler): class KeyHandler(MappingHandler):

@ -18,21 +18,20 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import asyncio import asyncio
from typing import Dict
from typing import Dict, Optional
from inputremapper.configs.mapping import Mapping from inputremapper.configs.mapping import Mapping
from inputremapper.event_combination import EventCombination 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.global_uinputs import global_uinputs
from inputremapper.injection.macros.parse import parse
from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.parse import parse
from inputremapper.injection.mapping_handlers.mapping_handler import ( from inputremapper.injection.mapping_handlers.mapping_handler import (
ContextProtocol, ContextProtocol,
MappingHandler, MappingHandler,
HandlerEnums, HandlerEnums,
) )
from inputremapper.input_event import InputEvent
from inputremapper.logger import logger
class MacroHandler(MappingHandler): class MacroHandler(MappingHandler):

@ -19,7 +19,6 @@
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Provides protocols for mapping handlers """Provides protocols for mapping handlers
*** The architecture behind mapping handlers *** *** The architecture behind mapping handlers ***
Handling an InputEvent is done in 3 steps: Handling an InputEvent is done in 3 steps:
@ -53,6 +52,7 @@ Step 1 and 2:
Step 1, 2 and 3: Step 1, 2 and 3:
- AbsToRelHandler - AbsToRelHandler
- NullHandler
Step 2 and 3: Step 2 and 3:
- KeyHandler - KeyHandler
@ -61,15 +61,14 @@ Step 2 and 3:
from __future__ import annotations from __future__ import annotations
import enum import enum
from typing import Dict, Protocol, Set, Optional, List
import evdev import evdev
from typing import Dict, Protocol, Set, Optional, List
from inputremapper.configs.mapping import Mapping from inputremapper.configs.mapping import Mapping
from inputremapper.configs.preset import Preset from inputremapper.event_combination import EventCombination
from inputremapper.exceptions import MappingParsingError from inputremapper.exceptions import MappingParsingError
from inputremapper.input_event import InputEvent, EventActions from inputremapper.input_event import InputEvent, EventActions
from inputremapper.event_combination import EventCombination
from inputremapper.logger import logger from inputremapper.logger import logger
@ -81,10 +80,25 @@ class EventListener(Protocol):
class ContextProtocol(Protocol): class ContextProtocol(Protocol):
"""The parts from context needed for macros.""" """The parts from context needed for macros."""
preset: Preset
listeners: Set[EventListener] listeners: Set[EventListener]
class NotifyCallback(Protocol):
"""Type signature of InputEventHandler.notify
return True if the event was actually taken care of
"""
def __call__(
self,
event: InputEvent,
source: evdev.InputDevice,
forward: evdev.UInput,
supress: bool = False,
) -> bool:
...
class InputEventHandler(Protocol): class InputEventHandler(Protocol):
"""The protocol any handler, which can be part of an event pipeline, must follow.""" """The protocol any handler, which can be part of an event pipeline, must follow."""
@ -126,7 +140,7 @@ class HandlerEnums(enum.Enum):
disable = enum.auto() disable = enum.auto()
class MappingHandler(InputEventHandler): class MappingHandler:
"""The protocol an InputEventHandler must follow if it should be """The protocol an InputEventHandler must follow if it should be
dynamically integrated in an event-pipeline by the mapping parser dynamically integrated in an event-pipeline by the mapping parser
""" """
@ -155,13 +169,27 @@ class MappingHandler(InputEventHandler):
new_combination = [] new_combination = []
for event in combination: for event in combination:
if event.value != 0: if event.value != 0:
event = event.modify(action=EventActions.as_key) event = event.modify(actions=(EventActions.as_key,))
new_combination.append(event) new_combination.append(event)
self.mapping = mapping self.mapping = mapping
self.input_events = new_combination self.input_events = new_combination
self._sub_handler = None self._sub_handler = None
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
forward: evdev.UInput,
supress: bool = False,
) -> bool:
"""notify this handler about an incoming event"""
raise NotImplementedError
def reset(self) -> None:
"""Reset the state of the handler e.g. release any buttons."""
raise NotImplementedError
def needs_wrapping(self) -> bool: def needs_wrapping(self) -> bool:
"""If this handler needs to be wrapped in another MappingHandler.""" """If this handler needs to be wrapped in another MappingHandler."""
return len(self.wrap_with()) > 0 return len(self.wrap_with()) > 0
@ -175,10 +203,10 @@ class MappingHandler(InputEventHandler):
pass pass
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]: def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
"""A dict of 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 for each EventCombination this handler should be wrapped
# TODO: better explanation with the given MappingHandler"""
return {} return {}
def set_sub_handler(self, handler: InputEventHandler) -> None: def set_sub_handler(self, handler: InputEventHandler) -> None:

@ -18,55 +18,57 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Functions to assemble the mapping handlers""" """Functions to assemble the mapping handlers"""
from collections import defaultdict
from typing import Dict, List, Type, Optional, Set, Iterable, Sized, Tuple, Sequence from typing import Dict, List, Type, Optional, Set, Iterable, Sized, Tuple, Sequence
from evdev.ecodes import ( from evdev.ecodes import (
EV_KEY, EV_KEY,
EV_ABS, EV_ABS,
EV_REL, EV_REL,
) )
from inputremapper.exceptions import MappingParsingError from inputremapper.configs.mapping import Mapping
from inputremapper.logger import logger from inputremapper.configs.preset import Preset
from inputremapper.configs.system_mapping import DISABLE_CODE, DISABLE_NAME
from inputremapper.event_combination import EventCombination from inputremapper.event_combination import EventCombination
from inputremapper.input_event import InputEvent from inputremapper.exceptions import MappingParsingError
from inputremapper.injection.mapping_handlers.mapping_handler import ( from inputremapper.injection.macros.parse import is_this_a_macro
HandlerEnums, from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
MappingHandler, from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler
ContextProtocol, from inputremapper.injection.mapping_handlers.axis_switch_handler import (
AxisSwitchHandler,
) )
from inputremapper.injection.mapping_handlers.combination_handler import ( from inputremapper.injection.mapping_handlers.combination_handler import (
CombinationHandler, CombinationHandler,
) )
from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler 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.key_handler import KeyHandler
from inputremapper.injection.mapping_handlers.axis_switch_handler import ( from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler
AxisSwitchHandler, from inputremapper.injection.mapping_handlers.mapping_handler import (
HandlerEnums,
MappingHandler,
ContextProtocol,
InputEventHandler,
) )
from inputremapper.injection.mapping_handlers.null_handler import NullHandler from inputremapper.injection.mapping_handlers.null_handler import NullHandler
from inputremapper.injection.macros.parse import is_this_a_macro from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler
from inputremapper.configs.preset import Preset from inputremapper.input_event import InputEvent
from inputremapper.configs.mapping import Mapping from inputremapper.logger import logger
from inputremapper.configs.system_mapping import DISABLE_CODE, DISABLE_NAME
EventPipelines = Dict[InputEvent, List[MappingHandler]] EventPipelines = Dict[InputEvent, Set[InputEventHandler]]
mapping_handler_classes: Dict[HandlerEnums, Type[MappingHandler]] = { mapping_handler_classes: Dict[HandlerEnums, Optional[Type[MappingHandler]]] = {
# all available mapping_handlers # all available mapping_handlers
HandlerEnums.abs2btn: AbsToBtnHandler, HandlerEnums.abs2btn: AbsToBtnHandler,
HandlerEnums.rel2btn: RelToBtnHandler, HandlerEnums.rel2btn: RelToBtnHandler,
HandlerEnums.macro: MacroHandler, HandlerEnums.macro: MacroHandler,
HandlerEnums.key: KeyHandler, HandlerEnums.key: KeyHandler,
HandlerEnums.btn2rel: None, # type: ignore HandlerEnums.btn2rel: None, # can be a macro
HandlerEnums.rel2rel: None, # type: ignore HandlerEnums.rel2rel: None,
HandlerEnums.abs2rel: AbsToRelHandler, HandlerEnums.abs2rel: AbsToRelHandler,
HandlerEnums.btn2abs: None, # type: ignore HandlerEnums.btn2abs: None, # can be a macro
HandlerEnums.rel2abs: None, # type: ignore HandlerEnums.rel2abs: None,
HandlerEnums.abs2abs: None, # type: ignore HandlerEnums.abs2abs: None,
HandlerEnums.combination: CombinationHandler, HandlerEnums.combination: CombinationHandler,
HandlerEnums.hierarchy: HierarchyHandler, HandlerEnums.hierarchy: HierarchyHandler,
HandlerEnums.axisswitch: AxisSwitchHandler, HandlerEnums.axisswitch: AxisSwitchHandler,
@ -84,9 +86,12 @@ def parse_mappings(preset: Preset, context: ContextProtocol) -> EventPipelines:
handler_enum = _get_output_handler(mapping) handler_enum = _get_output_handler(mapping)
constructor = mapping_handler_classes[handler_enum] constructor = mapping_handler_classes[handler_enum]
if not constructor: if not constructor:
raise NotImplementedError( logger.warning(
f"mapping handler {handler_enum} is not implemented" "a mapping handler '%s' for %s is not implemented",
handler_enum,
mapping.name or mapping.event_combination.beautify(),
) )
continue
output_handler = constructor( output_handler = constructor(
mapping.event_combination, mapping.event_combination,
@ -99,7 +104,7 @@ def parse_mappings(preset: Preset, context: ContextProtocol) -> EventPipelines:
handlers.extend(_create_event_pipeline(output_handler, context)) handlers.extend(_create_event_pipeline(output_handler, context))
# figure out which handlers need ranking and wrap them with hierarchy_handlers # figure out which handlers need ranking and wrap them with hierarchy_handlers
need_ranking = {} need_ranking = defaultdict(set)
for handler in handlers.copy(): for handler in handlers.copy():
if handler.needs_ranking(): if handler.needs_ranking():
combination = handler.rank_by() combination = handler.rank_by()
@ -109,7 +114,7 @@ def parse_mappings(preset: Preset, context: ContextProtocol) -> EventPipelines:
f"return a combination to rank by", f"return a combination to rank by",
mapping_handler=handler, mapping_handler=handler,
) )
need_ranking[combination] = handler need_ranking[combination].add(handler)
handlers.remove(handler) handlers.remove(handler)
# the HierarchyHandler's might not be the starting point of the event pipeline # the HierarchyHandler's might not be the starting point of the event pipeline
@ -120,18 +125,13 @@ def parse_mappings(preset: Preset, context: ContextProtocol) -> EventPipelines:
# group all handlers by the input events they take care of. One handler might end # 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 # up in multiple groups if it takes care of multiple InputEvents
event_pipelines: EventPipelines = {} event_pipelines: EventPipelines = defaultdict(set)
for handler in handlers: for handler in handlers:
assert handler.input_events assert handler.input_events
for event in 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("event-pipeline with entry point: %s", event.type_and_code) logger.debug_mapping_handler(handler)
logger.debug_mapping_handler(handler) event_pipelines[event].add(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 return event_pipelines
@ -223,7 +223,7 @@ def _maps_axis(combination: EventCombination) -> Optional[InputEvent]:
def _create_hierarchy_handlers( def _create_hierarchy_handlers(
handlers: Dict[EventCombination, MappingHandler] handlers: Dict[EventCombination, Set[MappingHandler]]
) -> Set[MappingHandler]: ) -> Set[MappingHandler]:
"""Sort handlers by input events and create Hierarchy handlers.""" """Sort handlers by input events and create Hierarchy handlers."""
sorted_handlers = set() sorted_handlers = set()
@ -244,15 +244,15 @@ def _create_hierarchy_handlers(
if len(combinations_with_event) == 1: if len(combinations_with_event) == 1:
# there was only one handler containing that event return it as is # there was only one handler containing that event return it as is
sorted_handlers.add(handlers[combinations_with_event[0]]) sorted_handlers.update(handlers[combinations_with_event[0]])
continue continue
# there are multiple handler with the same event. # there are multiple handler with the same event.
# rank them and create the HierarchyHandler # rank them and create the HierarchyHandler
sorted_combinations = _order_combinations(combinations_with_event, event) sorted_combinations = _order_combinations(combinations_with_event, event)
sub_handlers = [] sub_handlers: List[MappingHandler] = []
for combination in sorted_combinations: for combination in sorted_combinations:
sub_handlers.append(handlers[combination]) sub_handlers.append(*handlers[combination])
sorted_handlers.add(HierarchyHandler(sub_handlers, event)) sorted_handlers.add(HierarchyHandler(sub_handlers, event))
for handler in sub_handlers: for handler in sub_handlers:

@ -18,17 +18,16 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import evdev from typing import Dict
from typing import Optional, Dict
from evdev.ecodes import EV_KEY import evdev
from inputremapper.event_combination import EventCombination from inputremapper.event_combination import EventCombination
from inputremapper.input_event import InputEvent
from inputremapper.injection.mapping_handlers.mapping_handler import ( from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler, MappingHandler,
HandlerEnums, HandlerEnums,
) )
from inputremapper.input_event import InputEvent
class NullHandler(MappingHandler): class NullHandler(MappingHandler):

@ -18,23 +18,20 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import evdev
import time
import asyncio import asyncio
import time
from typing import Optional, Dict import evdev
from evdev.ecodes import EV_REL from evdev.ecodes import EV_REL
from inputremapper.configs.mapping import Mapping 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.event_combination import EventCombination
from inputremapper.injection.mapping_handlers.mapping_handler import ( from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler, MappingHandler,
ContextProtocol,
HandlerEnums,
InputEventHandler, InputEventHandler,
) )
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.logger import logger
class RelToBtnHandler(MappingHandler): class RelToBtnHandler(MappingHandler):
@ -82,7 +79,7 @@ class RelToBtnHandler(MappingHandler):
self._abort_release = False self._abort_release = False
return return
event = self._input_event.modify(value=0, action=EventActions.as_key) event = self._input_event.modify(value=0, actions=(EventActions.as_key,))
logger.debug_key(event.event_tuple, "sending to sub_handler") logger.debug_key(event.event_tuple, "sending to sub_handler")
self._sub_handler.notify(event, source, forward, supress) self._sub_handler.notify(event, source, forward, supress)
self._active = False self._active = False
@ -103,25 +100,34 @@ class RelToBtnHandler(MappingHandler):
value = event.value value = event.value
if (value < threshold > 0) or (value > threshold < 0): if (value < threshold > 0) or (value > threshold < 0):
if self._active: if self._active:
# the axis is below the threshold and the stage_release function is running # the axis is below the threshold and the stage_release
event = event.modify(value=0, action=EventActions.as_key) # function is running
if self.mapping.force_release_timeout:
# consume the event
return True
event = event.modify(value=0, actions=(EventActions.as_key,))
logger.debug_key(event.event_tuple, "sending to sub_handler") logger.debug_key(event.event_tuple, "sending to sub_handler")
self._abort_release = True self._abort_release = True
self._active = False
return self._sub_handler.notify(event, source, forward, supress)
else: else:
# don't consume the event. # don't consume the event.
# We could return True to consume events # We could return True to consume events
return False return False
else:
# the axis is above the threshold # the axis is above the threshold
event = event.modify(value=1, action=EventActions.as_key) if not self._active:
self._last_activation = time.time() asyncio.ensure_future(self._stage_release(source, forward, supress))
if not self._active: if value >= threshold > 0:
logger.debug_key(event.event_tuple, "sending to sub_handler") direction = EventActions.positive_trigger
asyncio.ensure_future(self._stage_release(source, forward, supress)) else:
self._active = True direction = EventActions.negative_trigger
return self._sub_handler.notify(event, source, forward, supress) self._last_activation = time.time()
event = event.modify(value=1, actions=(EventActions.as_key, direction))
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: def reset(self) -> None:
if self._active: if self._active:

@ -20,14 +20,16 @@
from __future__ import annotations from __future__ import annotations
import enum import enum
from dataclasses import dataclass
from typing import Tuple, Union, Sequence, Callable, Optional
import evdev import evdev
from evdev import ecodes
from dataclasses import dataclass from inputremapper.configs.system_mapping import system_mapping
from typing import Tuple, Union, Sequence, Callable
from inputremapper.exceptions import InputEventCreationError from inputremapper.exceptions import InputEventCreationError
from inputremapper.gui.message_broker import MessageType
from inputremapper.logger import logger
InputEventValidationType = Union[ InputEventValidationType = Union[
str, str,
@ -39,10 +41,14 @@ InputEventValidationType = Union[
class EventActions(enum.Enum): class EventActions(enum.Enum):
"""Additional information a InputEvent can send through the event pipeline""" """Additional information a InputEvent can send through the event pipeline"""
as_key = enum.auto() as_key = enum.auto() # treat this event as a key event
recenter = enum.auto() recenter = enum.auto() # recenter the axis when receiving this
none = enum.auto() none = enum.auto()
# used in combination with as_key, for originally abs or rel events
positive_trigger = enum.auto() # original event was positive direction
negative_trigger = enum.auto() # original event was negative direction
# Todo: add slots=True as soon as python 3.10 is in common distros # Todo: add slots=True as soon as python 3.10 is in common distros
@dataclass(frozen=True) @dataclass(frozen=True)
@ -52,12 +58,14 @@ class InputEvent:
as a drop in replacement for evdev.InputEvent as a drop in replacement for evdev.InputEvent
""" """
message_type = MessageType.selected_event
sec: int sec: int
usec: int usec: int
type: int type: int
code: int code: int
value: int value: int
action: EventActions = EventActions.none actions: Tuple[EventActions, ...] = ()
def __hash__(self): def __hash__(self):
return hash((self.type, self.code, self.value)) return hash((self.type, self.code, self.value))
@ -161,15 +169,18 @@ class InputEvent:
@property @property
def is_key_event(self) -> bool: def is_key_event(self) -> bool:
"""Whether this is interpreted as a key event.""" """Whether this is interpreted as a key event."""
return self.type == evdev.ecodes.EV_KEY or self.action == EventActions.as_key return self.type == evdev.ecodes.EV_KEY or EventActions.as_key in self.actions
def __str__(self): def __str__(self):
if self.type == evdev.ecodes.EV_KEY: return f"InputEvent{self.event_tuple}"
key_name = evdev.ecodes.bytype[self.type].get(self.code, "unknown")
action = "down" if self.value == 1 else "up" def description(self, exclude_threshold=False, exclude_direction=False) -> str:
return f"<InputEvent {key_name} ({self.code}) {action}>" """get a human-readable description of the event"""
return (
return f"<InputEvent {self.event_tuple}>" f"{self.get_name()} "
f"{self.get_direction() if not exclude_direction else ''} "
f"{self.get_threshold() if not exclude_threshold else ''}".strip()
)
def timestamp(self): def timestamp(self):
"""Return the unix timestamp of when the event was seen.""" """Return the unix timestamp of when the event was seen."""
@ -182,7 +193,7 @@ class InputEvent:
type: int = None, type: int = None,
code: int = None, code: int = None,
value: int = None, value: int = None,
action: EventActions = EventActions.none, actions: Tuple[EventActions, ...] = None,
) -> InputEvent: ) -> InputEvent:
"""Return a new modified event.""" """Return a new modified event."""
return InputEvent( return InputEvent(
@ -191,8 +202,106 @@ class InputEvent:
type if type is not None else self.type, type if type is not None else self.type,
code if code is not None else self.code, code if code is not None else self.code,
value if value is not None else self.value, value if value is not None else self.value,
action if action is not EventActions.none else self.action, actions if actions is not None else self.actions,
) )
def json_str(self) -> str: def json_str(self) -> str:
return ",".join([str(self.type), str(self.code), str(self.value)]) return ",".join([str(self.type), str(self.code), str(self.value)])
def get_name(self) -> Optional[str]:
"""human-readable name"""
if self.type not in ecodes.bytype:
logger.error("Unknown type for %s", self)
return "unknown"
if self.code not in ecodes.bytype[self.type]:
logger.error("Unknown code for %s", self)
return "unknown"
key_name = None
# first try to find the name in xmodmap to not display wrong
# names due to the keyboard layout
if self.type == ecodes.EV_KEY:
key_name = system_mapping.get_name(self.code)
if key_name is None:
# if no result, look in the linux combination constants. On a german
# keyboard for example z and y are switched, which will therefore
# cause the wrong letter to be displayed.
key_name = ecodes.bytype[self.type][self.code]
if isinstance(key_name, list):
key_name = key_name[0]
key_name = key_name.replace("ABS_Z", "Trigger Left")
key_name = key_name.replace("ABS_RZ", "Trigger Right")
key_name = key_name.replace("ABS_HAT0X", "DPad-X")
key_name = key_name.replace("ABS_HAT0Y", "DPad-Y")
key_name = key_name.replace("ABS_HAT1X", "DPad-2-X")
key_name = key_name.replace("ABS_HAT1Y", "DPad-2-Y")
key_name = key_name.replace("ABS_HAT2X", "DPad-3-X")
key_name = key_name.replace("ABS_HAT2Y", "DPad-3-Y")
key_name = key_name.replace("ABS_X", "Joystick-X")
key_name = key_name.replace("ABS_Y", "Joystick-Y")
key_name = key_name.replace("ABS_RX", "Joystick-RX")
key_name = key_name.replace("ABS_RY", "Joystick-RY")
key_name = key_name.replace("BTN_", "Button ")
key_name = key_name.replace("KEY_", "")
key_name = key_name.replace("REL_", "")
key_name = key_name.replace("HWHEEL", "Wheel")
key_name = key_name.replace("WHEEL", "Wheel")
key_name = key_name.replace("_", " ")
key_name = key_name.replace(" ", " ")
return key_name
def get_direction(self) -> str:
if self.type == ecodes.EV_KEY:
return ""
try:
event = self.modify(value=self.value // abs(self.value))
except ZeroDivisionError:
return ""
return {
# D-Pad
(ecodes.ABS_HAT0X, -1): "Left",
(ecodes.ABS_HAT0X, 1): "Right",
(ecodes.ABS_HAT0Y, -1): "Up",
(ecodes.ABS_HAT0Y, 1): "Down",
(ecodes.ABS_HAT1X, -1): "Left",
(ecodes.ABS_HAT1X, 1): "Right",
(ecodes.ABS_HAT1Y, -1): "Up",
(ecodes.ABS_HAT1Y, 1): "Down",
(ecodes.ABS_HAT2X, -1): "Left",
(ecodes.ABS_HAT2X, 1): "Right",
(ecodes.ABS_HAT2Y, -1): "Up",
(ecodes.ABS_HAT2Y, 1): "Down",
# joystick
(ecodes.ABS_X, 1): "Right",
(ecodes.ABS_X, -1): "Left",
(ecodes.ABS_Y, 1): "Down",
(ecodes.ABS_Y, -1): "Up",
(ecodes.ABS_RX, 1): "Right",
(ecodes.ABS_RX, -1): "Left",
(ecodes.ABS_RY, 1): "Down",
(ecodes.ABS_RY, -1): "Up",
# wheel
(ecodes.REL_WHEEL, -1): "Down",
(ecodes.REL_WHEEL, 1): "Up",
(ecodes.REL_HWHEEL, -1): "Left",
(ecodes.REL_HWHEEL, 1): "Right",
}.get((event.code, event.value)) or ("+" if event.value > 0 else "-")
def get_threshold(self) -> str:
if self.value == 0:
return ""
return {
ecodes.EV_REL: f"{abs(self.value)}",
ecodes.EV_ABS: f"{abs(self.value)}%",
}.get(self.type) or ""

@ -35,14 +35,14 @@
Beware that pipes read any available messages, Beware that pipes read any available messages,
even those written by themselves. even those written by themselves.
""" """
import asyncio
import json
import os import os
import time import time
import json from typing import Optional, AsyncIterator
from inputremapper.logger import logger
from inputremapper.configs.paths import mkdir, chown from inputremapper.configs.paths import mkdir, chown
from inputremapper.logger import logger
class Pipe: class Pipe:
@ -54,6 +54,9 @@ class Pipe:
self._unread = [] self._unread = []
self._created_at = time.time() self._created_at = time.time()
self._transport: Optional[asyncio.ReadTransport] = None
self._async_iterator: Optional[AsyncIterator] = None
paths = (f"{path}r", f"{path}w") paths = (f"{path}r", f"{path}w")
mkdir(os.path.dirname(path)) mkdir(os.path.dirname(path))
@ -93,6 +96,13 @@ class Pipe:
leftover = self.recv() leftover = self.recv()
logger.debug('Cleared leftover message "%s"', leftover) logger.debug('Cleared leftover message "%s"', leftover)
def __del__(self):
if self._transport:
logger.debug("closing transport")
self._transport.close()
for file in self._handles:
file.close()
def recv(self): def recv(self):
"""Read an object from the pipe or None if nothing available. """Read an object from the pipe or None if nothing available.
@ -107,6 +117,9 @@ class Pipe:
if len(line) == 0: if len(line) == 0:
return None return None
return self._get_msg(line)
def _get_msg(self, line):
parsed = json.loads(line) parsed = json.loads(line)
if parsed[0] < self._created_at and os.environ.get("UNITTEST"): if parsed[0] < self._created_at and os.environ.get("UNITTEST"):
# important to avoid race conditions between multiple unittests, # important to avoid race conditions between multiple unittests,
@ -143,3 +156,23 @@ class Pipe:
def fileno(self): def fileno(self):
"""Compatibility to select.select.""" """Compatibility to select.select."""
return self._handles[0].fileno() return self._handles[0].fileno()
def __aiter__(self):
return self
async def __anext__(self):
if not self._async_iterator:
loop = asyncio.get_running_loop()
reader = asyncio.StreamReader()
self._transport, _ = await loop.connect_read_pipe(
lambda: asyncio.StreamReaderProtocol(reader), self._handles[0]
)
self._async_iterator = reader.__aiter__()
return self._get_msg(await self._async_iterator.__anext__())
async def recv_async(self):
"""read the next line with async. Do not use this when using
the async for loop."""
return await self.__aiter__().__anext__()

@ -22,8 +22,8 @@
"""Share a dictionary across processes.""" """Share a dictionary across processes."""
import multiprocessing
import atexit import atexit
import multiprocessing
import select import select
from inputremapper.logger import logger from inputremapper.logger import logger

@ -50,15 +50,14 @@ are much easier to handle.
# by _Server all the time. # by _Server all the time.
import json
import os
import select import select
import socket import socket
import os
import time import time
import json
from inputremapper.logger import logger
from inputremapper.configs.paths import mkdir, chown from inputremapper.configs.paths import mkdir, chown
from inputremapper.logger import logger
# something funny that most likely won't appear in messages. # something funny that most likely won't appear in messages.
# also add some ones so that 01 in the payload won't offset # also add some ones so that 01 in the payload won't offset

@ -22,18 +22,14 @@
"""Logging setup for input-remapper.""" """Logging setup for input-remapper."""
import logging
import os import os
import re
import sys import sys
import shutil
import time import time
import logging from datetime import datetime
from typing import cast from typing import cast
import pkg_resources import pkg_resources
from datetime import datetime
from inputremapper.user import HOME
try: try:
from inputremapper.commit_hash import COMMIT_HASH from inputremapper.commit_hash import COMMIT_HASH

@ -22,8 +22,8 @@
"""Figure out the user.""" """Figure out the user."""
import os
import getpass import getpass
import os
import pwd import pwd

@ -39,8 +39,6 @@ from evdev.ecodes import (
) )
from inputremapper.logger import logger from inputremapper.logger import logger
from inputremapper.configs.global_config import BUTTONS
# other events for ABS include buttons # other events for ABS include buttons
JOYSTICK = [ JOYSTICK = [

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 62 KiB

@ -10,17 +10,19 @@ You can also start it via `input-remapper-gtk`.
<img src="usage_2.png"/> <img src="usage_2.png"/>
</p> </p>
First, select your device (like your keyboard) from the large dropdown on the top. First, select your device (like your keyboard) from the large dropdown on the top,
Then you can already edit your keys, as shown in the screenshots. and add a mapping.
Then you can already edit your inputs, as shown in the screenshots.
In the text input field, type the key to which you would like to map this key. 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). More information about the possible mappings can be found [below](#key-names).
Changes are saved automatically. Afterwards press the "Apply" button. Changes are saved automatically.
Press the "Apply" button to activate (inject) the mapping you created.
To change the mapping, you need to use the "Stop Injection" button, so that If you later want to modify the Input of your mapping you need to use the
the application can read the original keycode. It would otherwise be "Stop Injection" button, so that the application can read your original input.
invisible since the daemon maps it independently of the GUI. It would otherwise be invisible since the daemon maps it independently of the GUI.
## Troubleshooting ## Troubleshooting
@ -35,31 +37,22 @@ No injection should be running anymore.
## Combinations ## Combinations
Change the key of your mapping (`Change Key` - Button) and hold a few of your You can use combinations of different inputs to trigger a mapping: While you recorde
device keys down. Releasing them will make your text cursor jump into the the input (`Recorde Input` - Button) press multiple keys and/or move axis at once.
mapping column to type in what you want to map it to. The mapping will be triggered as soon as all the recorded inputs are pressed.
Combinations involving Modifiers might not work. Configuring a combination If you use an axis an input you can modify the threshold at which the mapping is
of two keys to output a single key will require you to push down the first activated in the `Advanced Input Configuration`.
key, which of course ends up injecting that first key. Then the second key
will trigger the mapping, because the combination is complete. This is
not a bug. Otherwise every combination would have to automatically disable
all keys that are involved in it.
For example a combination of `LEFTSHIFT + a` for `b` would write "B" instead, A mapping with an input combination is only injected once all combination keys
because shift will be activated before you hit the "a". Therefore the are pressed. This means all the input keys you press before the combination is complete
environment will see shift and a "b", which will then be capitalized. will be injected unmodified. In some cases this can be desirable, in others not.
In the `Advanced Input Configuration` is the `Release Input` toggle.
Consider using a different key for the combination than shift. You could use This will release all inputs which are part of the combination before the mapping is
`KP1 + a` and map `KP1` to `disable`. injected. Consider a mapping `Shift+1 -> a` this will inject a lowercase `a` if the
toggle is on and an uppercase `A` if it is off. The exact behaviour if the toggle is off
The second option is to release the modifier in your combination by writing is dependent on keys (are modifiers involved?), the order in which they are pressed and
the modifier one more time. This will write lowercase "b" characters. To make on your environment (X11/Wayland). By default the toggle is on.
this work shift has to be injected via key-mappers devices though, which just
means it has to be forwarded. So the complete mapping for this would look like:
- `Shift L + a` -> `key(Shift_L).hold(b)`
- `Shift L` -> `Shift_L`
## Writing Combinations ## Writing Combinations
@ -111,6 +104,18 @@ and it won't be able to inject anything a usb keyboard wouldn't been able to. Th
the benefit of being compatible to all display servers, but means the environment will the benefit of being compatible to all display servers, but means the environment will
ultimately decide which character to write. ultimately decide which character to write.
## Analog Axis
It is possible to map analog inputs to analog outputs. E.g. use a gamepad as a mouse.
For this you need to create a mapping and recorde the input axis. Then go to
`Advanced Input Configuration` and select `Use as Analog`. Make sure to select a target
which supports analog axis and switch to the `Analog Axis` tab.
There you can select an output axis and use the different sliders to configure the
sensitivity, non-linearity and other parameters as you like.
It is also possible to use an analog output with an input combination.
This will result in the analog axis to be only injected if the combination is pressed
# External tools # External tools
Repositories listed here are made by input-remappers users. Feel free to extend. Beware, Repositories listed here are made by input-remappers users. Feel free to extend. Beware,
@ -131,7 +136,7 @@ Note for the Beta branch: All configuration files are copied to:
The default configuration is stored at `~/.config/input-remapper/config.json`, The default configuration is stored at `~/.config/input-remapper/config.json`,
which doesn't include any mappings, but rather other parameters that which doesn't include any mappings, but rather other parameters that
are interesting for injections. The current default configuration as of 1.5 are interesting for injections. The current default configuration as of 1.6
looks like, with an example autoload entry: looks like, with an example autoload entry:
```json ```json
@ -139,7 +144,7 @@ looks like, with an example autoload entry:
"autoload": { "autoload": {
"Logitech USB Keyboard": "preset name" "Logitech USB Keyboard": "preset name"
}, },
"version": "1.5" "version": "1.6"
} }
``` ```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@ -4,7 +4,7 @@ set -xeuo pipefail
# native deps # native deps
# gettext required to generate translations, others are python deps # gettext required to generate translations, others are python deps
sudo apt-get install -y gettext python3-evdev python3-pydbus python3-pydantic sudo apt-get install -y gettext python3-evdev python3-pydbus python3-pydantic python3-gi gir1.2-gtk-3.0 gir1.2-gtksource-4
# ensure pip and setuptools/wheel up to date so can install all pip modules # ensure pip and setuptools/wheel up to date so can install all pip modules
python -m pip install --upgrade pip python -m pip install --upgrade pip

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -0,0 +1,107 @@
import unittest
from unittest.mock import MagicMock, patch
from evdev.ecodes import EV_KEY, KEY_A
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("GLib", "2.0")
gi.require_version("GtkSource", "4")
from gi.repository import Gtk, GtkSource, Gdk, GObject, GLib
from inputremapper.gui.utils import gtk_iteration
from tests.test import quick_cleanup
from inputremapper.gui.message_broker import MessageBroker, MessageType
from inputremapper.gui.user_interface import UserInterface
from inputremapper.configs.mapping import MappingData
from inputremapper.event_combination import EventCombination
class TestUserInterface(unittest.TestCase):
def setUp(self) -> None:
self.message_broker = MessageBroker()
self.controller_mock = MagicMock()
self.user_interface = UserInterface(self.message_broker, self.controller_mock)
def tearDown(self) -> None:
super().tearDown()
self.message_broker.signal(MessageType.terminate)
GLib.timeout_add(0, self.user_interface.window.destroy)
GLib.timeout_add(0, Gtk.main_quit)
Gtk.main()
quick_cleanup()
def test_shortcut(self):
mock = MagicMock()
self.user_interface.shortcuts[Gdk.KEY_x] = mock
event = Gdk.Event()
event.key.keyval = Gdk.KEY_x
event.key.state = Gdk.ModifierType.SHIFT_MASK
self.user_interface.window.emit("key-press-event", event)
gtk_iteration()
mock.assert_not_called()
event.key.state = Gdk.ModifierType.CONTROL_MASK
self.user_interface.window.emit("key-press-event", event)
gtk_iteration()
mock.assert_called_once()
mock.reset_mock()
event.key.keyval = Gdk.KEY_y
self.user_interface.window.emit("key-press-event", event)
gtk_iteration()
mock.assert_not_called()
def test_connected_shortcuts(self):
should_be_connected = {Gdk.KEY_q, Gdk.KEY_r, Gdk.KEY_Delete}
connected = set(self.user_interface.shortcuts.keys())
self.assertEqual(connected, should_be_connected)
self.assertIs(
self.user_interface.shortcuts[Gdk.KEY_q], self.controller_mock.close
)
self.assertIs(
self.user_interface.shortcuts[Gdk.KEY_r],
self.controller_mock.refresh_groups,
)
self.assertIs(
self.user_interface.shortcuts[Gdk.KEY_Delete],
self.controller_mock.stop_injecting,
)
def test_connect_disconnect_shortcuts(self):
mock = MagicMock()
self.user_interface.shortcuts[Gdk.KEY_x] = mock
event = Gdk.Event()
event.key.keyval = Gdk.KEY_x
event.key.state = Gdk.ModifierType.CONTROL_MASK
self.user_interface.disconnect_shortcuts()
self.user_interface.window.emit("key-press-event", event)
gtk_iteration()
mock.assert_not_called()
self.user_interface.connect_shortcuts()
gtk_iteration()
self.user_interface.window.emit("key-press-event", event)
gtk_iteration()
mock.assert_called_once()
def test_combination_label_shows_combination(self):
self.message_broker.send(
MappingData(
event_combination=EventCombination((EV_KEY, KEY_A, 1)), name="foo"
)
)
gtk_iteration()
label: Gtk.Label = self.user_interface.get("combination-label")
self.assertEqual(label.get_text(), "a")
self.assertEqual(label.get_opacity(), 1)
def test_combination_label_shows_text_when_empty_mapping(self):
self.message_broker.send(MappingData())
gtk_iteration()
label: Gtk.Label = self.user_interface.get("combination-label")
self.assertEqual(label.get_text(), "no input configured")
self.assertEqual(label.get_opacity(), 0.4)

@ -25,9 +25,28 @@ This module needs to be imported first in test files.
""" """
import argparse import argparse
import json
import os import os
import sys import sys
import tempfile import tempfile
import traceback
import warnings
from multiprocessing.connection import Connection
from typing import Dict, Tuple
import tracemalloc
tracemalloc.start()
try:
sys.modules.get("tests.test").main
raise AssertionError(
"test.py was already imported. "
"Always use 'from tests.test import ...' "
"not 'from test import ...' to import this"
)
# have fun debugging infinitely blocking tests without this
except AttributeError:
pass
def get_project_root(): def get_project_root():
@ -132,7 +151,7 @@ tmp = temporary_directory.name
uinput_write_history = [] uinput_write_history = []
# for tests that makes the injector create its processes # for tests that makes the injector create its processes
uinput_write_history_pipe = multiprocessing.Pipe() uinput_write_history_pipe = multiprocessing.Pipe()
pending_events = {} pending_events: Dict[str, Tuple[Connection, Connection]] = {}
def read_write_history_pipe(): def read_write_history_pipe():
@ -166,7 +185,10 @@ fixtures = {
# see if the groups correct attribute is used in functions and paths. # see if the groups correct attribute is used in functions and paths.
"/dev/input/event11": { "/dev/input/event11": {
"capabilities": { "capabilities": {
evdev.ecodes.EV_KEY: [evdev.ecodes.BTN_LEFT], evdev.ecodes.EV_KEY: [
evdev.ecodes.BTN_LEFT,
evdev.ecodes.BTN_TOOL_DOUBLETAP,
],
evdev.ecodes.EV_REL: [ evdev.ecodes.EV_REL: [
evdev.ecodes.REL_X, evdev.ecodes.REL_X,
evdev.ecodes.REL_Y, evdev.ecodes.REL_Y,
@ -200,6 +222,26 @@ fixtures = {
"name": "Foo Device qux", "name": "Foo Device qux",
"group_key": "Foo Device 2", "group_key": "Foo Device 2",
}, },
"/dev/input/event15": {
"capabilities": {
evdev.ecodes.EV_SYN: [],
evdev.ecodes.EV_ABS: [
evdev.ecodes.ABS_X,
evdev.ecodes.ABS_Y,
evdev.ecodes.ABS_RX,
evdev.ecodes.ABS_RY,
evdev.ecodes.ABS_Z,
evdev.ecodes.ABS_RZ,
evdev.ecodes.ABS_HAT0X,
evdev.ecodes.ABS_HAT0Y,
],
evdev.ecodes.EV_KEY: [evdev.ecodes.BTN_A],
},
"phys": f"{phys_foo}/input4",
"info": info_foo,
"name": "Foo Device bar",
"group_key": "Foo Device 2",
},
# Bar Device # Bar Device
"/dev/input/event20": { "/dev/input/event20": {
"capabilities": {evdev.ecodes.EV_KEY: keyboard_keys}, "capabilities": {evdev.ecodes.EV_KEY: keyboard_keys},
@ -256,6 +298,7 @@ def setup_pipe(group_key):
which in turn will be sent to the reader which in turn will be sent to the reader
""" """
if pending_events.get(group_key) is None: if pending_events.get(group_key) is None:
logger.info("creating Pipe for %s", group_key)
pending_events[group_key] = multiprocessing.Pipe() pending_events[group_key] = multiprocessing.Pipe()
@ -284,6 +327,7 @@ def push_event(group_key, event):
event : InputEvent event : InputEvent
""" """
setup_pipe(group_key) setup_pipe(group_key)
logger.info("Simulating %s for %s", event, group_key)
pending_events[group_key][0].send(event) pending_events[group_key][0].send(event)
@ -355,19 +399,21 @@ class InputDevice:
logger.info("ungrab %s %s", self.name, self.path) logger.info("ungrab %s %s", self.name, self.path)
async def async_read_loop(self): async def async_read_loop(self):
if pending_events.get(self.group_key) is None: logger.info("starting read loop for %s", self.path)
self.log("no events to read", self.group_key) new_frame = asyncio.Event()
return asyncio.get_running_loop().add_reader(self.fd, new_frame.set)
while True:
# consume all of them await new_frame.wait()
while pending_events[self.group_key][1].poll(): new_frame.clear()
result = pending_events[self.group_key][1].recv() if not pending_events[self.group_key][1].poll():
self.log(result, "async_read_loop") # todo: why? why do we need this?
yield result # sometimes this happens, as if a other process calls recv on
await asyncio.sleep(0.01) # the pipe
continue
# doesn't loop endlessly in order to run tests for the injector in event = pending_events[self.group_key][1].recv()
# the main process logger.info("got %s at %s", event, self.path)
yield event
def read(self): def read(self):
# the patched fake InputDevice objects read anything pending from # the patched fake InputDevice objects read anything pending from
@ -396,13 +442,12 @@ class InputDevice:
def read_one(self): def read_one(self):
"""Read one event or none if nothing available.""" """Read one event or none if nothing available."""
if pending_events.get(self.group_key) is None: if not pending_events.get(self.group_key):
return None return None
if len(pending_events[self.group_key]) == 0: if not pending_events[self.group_key][1].poll():
return None return None
time.sleep(EVENT_READ_TIMEOUT)
try: try:
event = pending_events[self.group_key][1].recv() event = pending_events[self.group_key][1].recv()
except (UnpicklingError, EOFError): except (UnpicklingError, EOFError):
@ -541,6 +586,19 @@ def clear_write_history():
uinput_write_history_pipe[0].recv() uinput_write_history_pipe[0].recv()
def warn_with_traceback(message, category, filename, lineno, file=None, line=None):
log = file if hasattr(file, "write") else sys.stderr
traceback.print_stack(file=log)
log.write(warnings.formatwarning(message, category, filename, lineno, line))
def patch_warnings():
# show traceback
warnings.showwarning = warn_with_traceback
warnings.simplefilter("always")
# quickly fake some stuff before any other file gets a chance to import # quickly fake some stuff before any other file gets a chance to import
# the original versions # the original versions
patch_paths() patch_paths()
@ -548,23 +606,26 @@ patch_evdev()
patch_events() patch_events()
patch_os_system() patch_os_system()
patch_check_output() patch_check_output()
# patch_warnings()
from inputremapper.logger import update_verbosity from inputremapper.logger import update_verbosity
update_verbosity(True) update_verbosity(True)
from inputremapper.daemon import DaemonProxy
from inputremapper.input_event import InputEvent as InternalInputEvent from inputremapper.input_event import InputEvent as InternalInputEvent
from inputremapper.injection.injector import Injector from inputremapper.injection.injector import Injector, RUNNING, STOPPED
from inputremapper.injection.macros.macro import macro_variables
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.configs.global_config import global_config from inputremapper.configs.global_config import global_config
from inputremapper.configs.mapping import Mapping, UIMapping from inputremapper.configs.mapping import Mapping, UIMapping
from inputremapper.gui.reader import reader from inputremapper.groups import groups, _Groups
from inputremapper.groups import groups
from inputremapper.configs.system_mapping import system_mapping from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.active_preset import active_preset from inputremapper.gui.message_broker import MessageBroker
from inputremapper.configs.paths import get_config_path from inputremapper.gui.reader import Reader
from inputremapper.injection.macros.macro import macro_variables from inputremapper.configs.paths import get_config_path, get_preset_path
from inputremapper.configs.preset import Preset
# from inputremapper.injection.mapping_handlers.keycode_mapper import active_macros, unreleased
from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.injection.global_uinputs import global_uinputs
# no need for a high number in tests # no need for a high number in tests
@ -602,16 +663,6 @@ def get_ui_mapping(
) )
def send_event_to_reader(event):
"""Act like the helper and send input events to the reader."""
reader._results._unread.append(
{
"type": "event",
"message": (event.sec, event.usec, event.type, event.code, event.value),
}
)
def quick_cleanup(log=True): def quick_cleanup(log=True):
"""Reset the applications state.""" """Reset the applications state."""
if log: if log:
@ -628,11 +679,6 @@ def quick_cleanup(log=True):
pending_events[device] = None pending_events[device] = None
setup_pipe(device) setup_pipe(device)
try:
reader.terminate()
except (BrokenPipeError, OSError):
pass
try: try:
if asyncio.get_event_loop().is_running(): if asyncio.get_event_loop().is_running():
for task in asyncio.all_tasks(): for task in asyncio.all_tasks():
@ -662,7 +708,6 @@ def quick_cleanup(log=True):
global_config._save_config() global_config._save_config()
system_mapping.populate() system_mapping.populate()
active_preset.empty()
clear_write_history() clear_write_history()
@ -685,8 +730,6 @@ def quick_cleanup(log=True):
if device not in environ_copy: if device not in environ_copy:
del os.environ[device] del os.environ[device]
reader.clear()
for _, pipe in pending_events.values(): for _, pipe in pending_events.values():
assert not pipe.poll() assert not pipe.poll()
@ -725,6 +768,75 @@ def spy(obj, name):
return patch.object(obj, name, wraps=obj.__getattribute__(name)) return patch.object(obj, name, wraps=obj.__getattribute__(name))
class FakeDaemonProxy:
def __init__(self):
self.calls = {
"stop_injecting": [],
"get_state": [],
"start_injecting": [],
"stop_all": 0,
"set_config_dir": [],
"autoload": 0,
"autoload_single": [],
"hello": [],
}
def stop_injecting(self, group_key: str) -> None:
self.calls["stop_injecting"].append(group_key)
def get_state(self, group_key: str) -> int:
self.calls["get_state"].append(group_key)
return STOPPED
def start_injecting(self, group_key: str, preset: str) -> bool:
self.calls["start_injecting"].append((group_key, preset))
return True
def stop_all(self) -> None:
self.calls["stop_all"] += 1
def set_config_dir(self, config_dir: str) -> None:
self.calls["set_config_dir"].append(config_dir)
def autoload(self) -> None:
self.calls["autoload"] += 1
def autoload_single(self, group_key: str) -> None:
self.calls["autoload_single"].append(group_key)
def hello(self, out: str) -> str:
self.calls["hello"].append(out)
return out
def prepare_presets():
"""prepare a few presets for use in tests
"Foo Device 2/preset3" is the newest and "Foo Device 2/preset2" is set to autoload
"""
preset1 = Preset(get_preset_path("Foo Device", "preset1"))
preset1.add(get_key_mapping(combination="1,1,1", output_symbol="b"))
preset1.add(get_key_mapping(combination="1,2,1"))
preset1.save()
time.sleep(0.05)
preset2 = Preset(get_preset_path("Foo Device", "preset2"))
preset2.add(get_key_mapping(combination="1,3,1"))
preset2.add(get_key_mapping(combination="1,4,1"))
preset2.save()
time.sleep(0.05) # make sure the timestamp of preset 3 is the newest
preset3 = Preset(get_preset_path("Foo Device", "preset3"))
preset3.add(get_key_mapping(combination="1,5,1"))
preset3.save()
with open(get_config_path("config.json"), "w") as file:
json.dump({"autoload": {"Foo Device 2": "preset2"}}, file, indent=4)
global_config.load_config()
return preset1, preset2, preset3
cleanup() cleanup()

@ -85,9 +85,9 @@ class TestContext(unittest.TestCase):
(1, 33): 1, (1, 33): 1,
(1, 34): 1, (1, 34): 1,
} }
self.assertEqual(set(callbacks.keys()), set(context.callbacks.keys())) self.assertEqual(set(callbacks.keys()), set(context.notify_callbacks.keys()))
for key, val in callbacks.items(): for key, val in callbacks.items():
self.assertEqual(val, len(context.callbacks[key])) self.assertEqual(val, len(context.notify_callbacks[key]))
self.assertEqual( self.assertEqual(
7, len(context._handlers) 7, len(context._handlers)

@ -32,7 +32,6 @@ import collections
from importlib.util import spec_from_loader, module_from_spec from importlib.util import spec_from_loader, module_from_spec
from importlib.machinery import SourceFileLoader from importlib.machinery import SourceFileLoader
from inputremapper.gui.active_preset import active_preset
from inputremapper.configs.global_config import global_config from inputremapper.configs.global_config import global_config
from inputremapper.daemon import Daemon from inputremapper.daemon import Daemon
from inputremapper.configs.preset import Preset from inputremapper.configs.preset import Preset
@ -42,7 +41,6 @@ from inputremapper.groups import groups
def import_control(): def import_control():
"""Import the core function of the input-remapper-control command.""" """Import the core function of the input-remapper-control command."""
active_preset.empty()
bin_path = os.path.join(os.getcwd(), "bin", "input-remapper-control") bin_path = os.path.join(os.getcwd(), "bin", "input-remapper-control")

File diff suppressed because it is too large Load Diff

@ -160,9 +160,9 @@ class TestDaemon(unittest.TestCase):
self.assertEqual(event.value, 1) self.assertEqual(event.value, 1)
self.daemon.stop_injecting(group.key) self.daemon.stop_injecting(group.key)
time.sleep(0.2)
self.assertEqual(self.daemon.get_state(group.key), STOPPED) self.assertEqual(self.daemon.get_state(group.key), STOPPED)
time.sleep(0.1)
try: try:
self.assertFalse(uinput_write_history_pipe[0].poll()) self.assertFalse(uinput_write_history_pipe[0].poll())
except AssertionError: except AssertionError:
@ -171,13 +171,13 @@ class TestDaemon(unittest.TestCase):
raise raise
"""Injection 2""" """Injection 2"""
self.daemon.start_injecting(group.key, preset_name)
time.sleep(0.1)
# -1234 will be classified as -1 by the injector # -1234 will be classified as -1 by the injector
push_events(group.key, [new_event(*ev_2, -1234)]) push_events(group.key, [new_event(*ev_2, -1234)])
self.daemon.start_injecting(group.key, preset_name)
time.sleep(0.1) time.sleep(0.1)
self.assertTrue(uinput_write_history_pipe[0].poll()) self.assertTrue(uinput_write_history_pipe[0].poll())
# the written key is a key-down event, not the original # the written key is a key-down event, not the original
@ -255,6 +255,7 @@ class TestDaemon(unittest.TestCase):
self.assertEqual(event.t, (EV_KEY, KEY_A, 1)) self.assertEqual(event.t, (EV_KEY, KEY_A, 1))
self.daemon.stop_injecting(group_key) self.daemon.stop_injecting(group_key)
time.sleep(0.2)
self.assertEqual(self.daemon.get_state(group_key), STOPPED) self.assertEqual(self.daemon.get_state(group_key), STOPPED)
def test_refresh_for_unknown_key(self): def test_refresh_for_unknown_key(self):
@ -354,6 +355,7 @@ class TestDaemon(unittest.TestCase):
self.assertNotIn(group.key, daemon.autoload_history._autoload_history) self.assertNotIn(group.key, daemon.autoload_history._autoload_history)
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset_name)) self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset_name))
self.assertIn(group.key, daemon.injectors) self.assertIn(group.key, daemon.injectors)
time.sleep(0.2)
self.assertEqual(previous_injector.get_state(), STOPPED) self.assertEqual(previous_injector.get_state(), STOPPED)
# a different injetor is now running # a different injetor is now running
self.assertNotEqual(previous_injector, daemon.injectors[group.key]) self.assertNotEqual(previous_injector, daemon.injectors[group.key])
@ -377,6 +379,7 @@ class TestDaemon(unittest.TestCase):
# stop # stop
daemon.stop_injecting(group.key) daemon.stop_injecting(group.key)
time.sleep(0.2)
self.assertNotIn(group.key, daemon.autoload_history._autoload_history) self.assertNotIn(group.key, daemon.autoload_history._autoload_history)
self.assertEqual(daemon.injectors[group.key].get_state(), STOPPED) self.assertEqual(daemon.injectors[group.key].get_state(), STOPPED)
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset_name)) self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset_name))
@ -409,7 +412,7 @@ class TestDaemon(unittest.TestCase):
injector = daemon.injectors[group.key] injector = daemon.injectors[group.key]
self.assertEqual(len_before + 1, len_after) self.assertEqual(len_before + 1, len_after)
# calling duplicate _autoload does nothing # calling duplicate get_autoload does nothing
self.daemon._autoload(group.key) self.daemon._autoload(group.key)
self.assertEqual( self.assertEqual(
daemon.autoload_history._autoload_history[group.key][1], preset_name daemon.autoload_history._autoload_history[group.key][1], preset_name

@ -0,0 +1,893 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import json
import os
import time
import unittest
from itertools import permutations
from typing import List, Dict, Any
from unittest.mock import MagicMock, call
from inputremapper.configs.global_config import global_config
from inputremapper.configs.mapping import UIMapping, MappingData
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.event_combination import EventCombination
from inputremapper.exceptions import DataManagementError
from inputremapper.groups import _Groups
from inputremapper.gui.message_broker import (
MessageBroker,
MessageType,
GroupData,
PresetData,
CombinationUpdate,
)
from inputremapper.gui.reader import Reader
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.input_event import InputEvent
from tests.test import get_key_mapping, quick_cleanup, FakeDaemonProxy, prepare_presets
from inputremapper.configs.paths import get_preset_path, get_config_path
from inputremapper.configs.preset import Preset
from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME
class Listener:
def __init__(self):
self.calls: List = []
def __call__(self, data):
self.calls.append(data)
class TestDataManager(unittest.TestCase):
def setUp(self) -> None:
self.message_broker = MessageBroker()
self.reader = Reader(self.message_broker, _Groups())
self.uinputs = GlobalUInputs()
self.uinputs.prepare_all()
self.data_manager = DataManager(
self.message_broker,
global_config,
self.reader,
FakeDaemonProxy(),
self.uinputs,
system_mapping,
)
def tearDown(self) -> None:
quick_cleanup()
def test_load_group_provides_presets(self):
"""we should get all preset of a group, when loading it"""
prepare_presets()
response: List[GroupData] = []
def listener(data: GroupData):
response.append(data)
self.message_broker.subscribe(MessageType.group, listener)
self.data_manager.load_group("Foo Device 2")
for preset_name in response[0].presets:
self.assertIn(
preset_name,
(
"preset1",
"preset2",
"preset3",
),
)
self.assertEqual(response[0].group_key, "Foo Device 2")
def test_load_group_without_presets_provides_none(self):
"""we should get no presets when loading a group without presets"""
response: List[GroupData] = []
def listener(data: GroupData):
response.append(data)
self.message_broker.subscribe(MessageType.group, listener)
self.data_manager.load_group(group_key="Foo Device 2")
self.assertEqual(len(response[0].presets), 0)
def test_load_non_existing_group(self):
"""we should not be able to load an unknown group"""
with self.assertRaises(DataManagementError):
self.data_manager.load_group(group_key="Some Unknown Device")
def test_cannot_load_preset_without_group(self):
"""loading a preset without a loaded group should
raise a DataManagementError"""
prepare_presets()
self.assertRaises(
DataManagementError,
self.data_manager.load_preset,
name="preset1",
)
def test_load_preset(self):
"""loading an existing preset should be possible"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device")
listener = Listener()
self.message_broker.subscribe(MessageType.preset, listener)
self.data_manager.load_preset(name="preset1")
mappings = listener.calls[0].mappings
preset_name = listener.calls[0].name
expected_preset = Preset(get_preset_path("Foo Device", "preset1"))
expected_preset.load()
expected_mappings = [
(mapping.name, mapping.event_combination) for mapping in expected_preset
]
self.assertEqual(preset_name, "preset1")
for mapping in expected_mappings:
self.assertIn(mapping, mappings)
def test_cannot_load_non_existing_preset(self):
"""loading a non-existing preset should raise an KeyError"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device")
self.assertRaises(
FileNotFoundError,
self.data_manager.load_preset,
name="unknownPreset",
)
def test_save_preset(self):
"""modified preses should be saved to the disc"""
prepare_presets()
# make sure the correct preset is loaded
self.data_manager.load_group(group_key="Foo Device")
self.data_manager.load_preset(name="preset1")
listener = Listener()
self.message_broker.subscribe(MessageType.mapping, listener)
self.data_manager.load_mapping(combination=EventCombination("1,1,1"))
mapping: MappingData = listener.calls[0]
control_preset = Preset(get_preset_path("Foo Device", "preset1"))
control_preset.load()
self.assertEqual(
control_preset.get_mapping(EventCombination("1,1,1")).output_symbol,
mapping.output_symbol,
)
# change the mapping provided with the mapping_changed event and save
self.data_manager.update_mapping(output_symbol="key(a)")
self.data_manager.save()
# reload the control_preset
control_preset.empty()
control_preset.load()
self.assertEqual(
control_preset.get_mapping(EventCombination("1,1,1")).output_symbol,
"key(a)",
)
def test_copy_preset(self):
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
listener = Listener()
self.message_broker.subscribe(MessageType.group, listener)
self.message_broker.subscribe(MessageType.preset, listener)
self.data_manager.copy_preset("foo")
# we expect the first data to be group data and the second
# one a preset data of the new copy
presets_in_group = [preset for preset in listener.calls[0].presets]
self.assertIn("preset2", presets_in_group)
self.assertIn("foo", presets_in_group)
self.assertEqual(listener.calls[1].name, "foo")
# this should pass without error:
self.data_manager.load_preset("preset2")
self.data_manager.copy_preset("preset2")
def test_cannot_copy_preset(self):
prepare_presets()
self.assertRaises(
DataManagementError,
self.data_manager.copy_preset,
"foo",
)
self.data_manager.load_group("Foo Device 2")
self.assertRaises(
DataManagementError,
self.data_manager.copy_preset,
"foo",
)
def test_copy_preset_to_existing_name_raises_error(self):
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.assertRaises(
ValueError,
self.data_manager.copy_preset,
"preset3",
)
def test_rename_preset(self):
"""should be able to rename a preset"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
listener = Listener()
self.message_broker.subscribe(MessageType.group, listener)
self.message_broker.subscribe(MessageType.preset, listener)
self.data_manager.rename_preset(new_name="new preset")
# we expect the first data to be group data and the second
# one a preset data
presets_in_group = [preset for preset in listener.calls[0].presets]
self.assertNotIn("preset2", presets_in_group)
self.assertIn("new preset", presets_in_group)
self.assertEqual(listener.calls[1].name, "new preset")
# this should pass without error:
self.data_manager.load_preset(name="new preset")
self.data_manager.rename_preset(new_name="new preset")
def test_rename_preset_sets_autoload_correct(self):
"""when renaming a preset the autoload status should still be set correctly"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
listener = Listener()
self.message_broker.subscribe(MessageType.preset, listener)
self.data_manager.load_preset(name="preset2") # sends PresetData
# sends PresetData with updated name, e. e. should be equal
self.data_manager.rename_preset(new_name="foo")
self.assertEqual(listener.calls[0].autoload, listener.calls[1].autoload)
def test_cannot_rename_preset(self):
"""rename preset should raise a DataManagementError if a preset
with the new name already exists in the current group"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.assertRaises(
ValueError,
self.data_manager.rename_preset,
new_name="preset3",
)
def test_cannot_rename_preset_without_preset(self):
prepare_presets()
self.assertRaises(
DataManagementError,
self.data_manager.rename_preset,
new_name="foo",
)
self.data_manager.load_group(group_key="Foo Device 2")
self.assertRaises(
DataManagementError,
self.data_manager.rename_preset,
new_name="foo",
)
def test_add_preset(self):
"""should be able to add a preset"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
listener = Listener()
self.message_broker.subscribe(MessageType.group, listener)
# should emit group_changed
self.data_manager.create_preset(name="new preset")
presets_in_group = [preset for preset in listener.calls[0].presets]
self.assertIn("preset2", presets_in_group)
self.assertIn("preset3", presets_in_group)
self.assertIn("new preset", presets_in_group)
def test_cannot_add_preset(self):
"""adding a preset with the same name as an already existing
preset (of the current group) should raise a DataManagementError"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.assertRaises(
DataManagementError,
self.data_manager.create_preset,
name="preset3",
)
def test_cannot_add_preset_without_group(self):
self.assertRaises(
DataManagementError,
self.data_manager.create_preset,
name="foo",
)
def test_delete_preset(self):
"""should be able to delete the current preset"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
listener = Listener()
self.message_broker.subscribe(MessageType.group, listener)
self.message_broker.subscribe(MessageType.preset, listener)
self.message_broker.subscribe(MessageType.mapping, listener)
# should emit only group_changed
self.data_manager.delete_preset()
presets_in_group = [preset for preset in listener.calls[0].presets]
self.assertEqual(len(presets_in_group), 2)
self.assertNotIn("preset2", presets_in_group)
self.assertEqual(len(listener.calls), 1)
def test_load_mapping(self):
"""should be able to load a mapping"""
preset, _, _ = prepare_presets()
expected_mapping = preset.get_mapping(EventCombination("1,1,1"))
self.data_manager.load_group(group_key="Foo Device")
self.data_manager.load_preset(name="preset1")
listener = Listener()
self.message_broker.subscribe(MessageType.mapping, listener)
self.data_manager.load_mapping(combination=EventCombination("1,1,1"))
mapping = listener.calls[0]
self.assertEqual(mapping, expected_mapping)
def test_cannot_load_non_existing_mapping(self):
"""loading a mapping tha is not present in the preset should raise a KeyError"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.assertRaises(
KeyError,
self.data_manager.load_mapping,
combination=EventCombination("1,1,1"),
)
def test_cannot_load_mapping_without_preset(self):
"""loading a mapping if no preset is loaded
should raise an DataManagementError"""
prepare_presets()
self.assertRaises(
DataManagementError,
self.data_manager.load_mapping,
combination=EventCombination("1,1,1"),
)
self.data_manager.load_group("Foo Device")
self.assertRaises(
DataManagementError,
self.data_manager.load_mapping,
combination=EventCombination("1,1,1"),
)
def test_load_event(self):
prepare_presets()
mock = MagicMock()
self.message_broker.subscribe(MessageType.selected_event, mock)
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.data_manager.load_mapping(EventCombination("1,1,1"))
self.data_manager.load_event(InputEvent.from_string("1,1,1"))
mock.assert_called_once_with(InputEvent.from_string("1,1,1"))
self.assertEqual(
self.data_manager.active_event, InputEvent.from_string("1,1,1")
)
def test_cannot_load_event_when_mapping_not_set(self):
prepare_presets()
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
with self.assertRaises(DataManagementError):
self.data_manager.load_event(InputEvent.from_string("1,1,1"))
def test_cannot_load_event_when_not_in_mapping_combination(self):
prepare_presets()
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.data_manager.load_mapping(EventCombination("1,1,1"))
with self.assertRaises(ValueError):
self.data_manager.load_event(InputEvent.from_string("1,5,1"))
def test_update_event(self):
prepare_presets()
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.data_manager.load_mapping(EventCombination("1,1,1"))
self.data_manager.load_event(InputEvent.from_string("1,1,1"))
self.data_manager.update_event(InputEvent.from_string("1,5,1"))
self.assertEqual(
self.data_manager.active_event, InputEvent.from_string("1,5,1")
)
def test_update_event_sends_messages(self):
prepare_presets()
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.data_manager.load_mapping(EventCombination("1,1,1"))
self.data_manager.load_event(InputEvent.from_string("1,1,1"))
mock = MagicMock()
self.message_broker.subscribe(MessageType.selected_event, mock)
self.message_broker.subscribe(MessageType.combination_update, mock)
self.message_broker.subscribe(MessageType.mapping, mock)
self.data_manager.update_event(InputEvent.from_string("1,5,1"))
expected = [
call(
CombinationUpdate(EventCombination("1,1,1"), EventCombination("1,5,1"))
),
call(self.data_manager.active_mapping.get_bus_message()),
call(InputEvent.from_string("1,5,1")),
]
mock.assert_has_calls(expected, any_order=False)
def test_cannot_update_event_when_resulting_combination_exists(self):
prepare_presets()
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.data_manager.load_mapping(EventCombination("1,1,1"))
self.data_manager.load_event(InputEvent.from_string("1,1,1"))
with self.assertRaises(KeyError):
self.data_manager.update_event(InputEvent.from_string("1,2,1"))
def test_cannot_update_event_when_not_loaded(self):
prepare_presets()
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.data_manager.load_mapping(EventCombination("1,1,1"))
with self.assertRaises(DataManagementError):
self.data_manager.update_event(InputEvent.from_string("1,2,1"))
def test_update_mapping_emits_mapping_changed(self):
"""update mapping should emit a mapping_changed event"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.data_manager.load_mapping(combination=EventCombination("1,4,1"))
listener = Listener()
self.message_broker.subscribe(MessageType.mapping, listener)
self.data_manager.update_mapping(
name="foo",
output_symbol="f",
release_timeout=0.3,
)
response = listener.calls[0]
self.assertEqual(response.name, "foo")
self.assertEqual(response.output_symbol, "f")
self.assertEqual(response.release_timeout, 0.3)
def test_updated_mapping_can_be_saved(self):
"""make sure that updated changes can be saved"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.data_manager.load_mapping(combination=EventCombination("1,4,1"))
self.data_manager.update_mapping(
name="foo",
output_symbol="f",
release_timeout=0.3,
)
self.data_manager.save()
preset = Preset(get_preset_path("Foo Device", "preset2"), UIMapping)
preset.load()
mapping = preset.get_mapping(EventCombination("1,4,1"))
self.assertEqual(mapping.name, "foo")
self.assertEqual(mapping.output_symbol, "f")
self.assertEqual(mapping.release_timeout, 0.3)
def test_updated_mapping_saves_invalid_mapping(self):
"""make sure that updated changes can be saved even if they are not valid"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.data_manager.load_mapping(combination=EventCombination("1,4,1"))
self.data_manager.update_mapping(
output_symbol="bar", # not a macro and not a valid symbol
)
self.data_manager.save()
preset = Preset(get_preset_path("Foo Device", "preset2"), UIMapping)
preset.load()
mapping = preset.get_mapping(EventCombination("1,4,1"))
self.assertIsNotNone(mapping.get_error())
self.assertEqual(mapping.output_symbol, "bar")
def test_update_mapping_combination_sends_massage(self):
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.data_manager.load_mapping(combination=EventCombination("1,4,1"))
listener = Listener()
self.message_broker.subscribe(MessageType.mapping, listener)
self.message_broker.subscribe(MessageType.combination_update, listener)
# we expect a message for combination update first, and then for mapping
self.data_manager.update_mapping(
event_combination=EventCombination.from_string("1,5,1+1,6,1")
)
self.assertEqual(listener.calls[0].message_type, MessageType.combination_update)
self.assertEqual(
listener.calls[0].old_combination,
EventCombination.from_string("1,4,1"),
)
self.assertEqual(
listener.calls[0].new_combination,
EventCombination.from_string("1,5,1+1,6,1"),
)
self.assertEqual(listener.calls[1].message_type, MessageType.mapping)
self.assertEqual(
listener.calls[1].event_combination,
EventCombination.from_string("1,5,1+1,6,1"),
)
def test_cannot_update_mapping_combination(self):
"""updating a mapping with an already existing combination
should raise a KeyError"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.data_manager.load_mapping(combination=EventCombination("1,4,1"))
self.assertRaises(
KeyError,
self.data_manager.update_mapping,
event_combination=EventCombination("1,3,1"),
)
def test_cannot_update_mapping(self):
"""updating a mapping should not be possible if the mapping was not loaded"""
prepare_presets()
self.assertRaises(
DataManagementError,
self.data_manager.update_mapping,
name="foo",
)
self.data_manager.load_group(group_key="Foo Device 2")
self.assertRaises(
DataManagementError,
self.data_manager.update_mapping,
name="foo",
)
self.data_manager.load_preset("preset2")
self.assertRaises(
DataManagementError,
self.data_manager.update_mapping,
name="foo",
)
def test_create_mapping(self):
"""should be able to add a mapping to the current preset"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
listener = Listener()
self.message_broker.subscribe(MessageType.mapping, listener)
self.message_broker.subscribe(MessageType.preset, listener)
self.data_manager.create_mapping() # emits preset_changed
self.data_manager.load_mapping(combination=EventCombination.empty_combination())
self.assertEqual(listener.calls[0].name, "preset2")
self.assertEqual(len(listener.calls[0].mappings), 3)
self.assertEqual(listener.calls[1], UIMapping())
def test_cannot_create_mapping_without_preset(self):
"""adding a mapping if not preset is loaded
should raise an DataManagementError"""
prepare_presets()
self.assertRaises(DataManagementError, self.data_manager.create_mapping)
self.data_manager.load_group(group_key="Foo Device 2")
self.assertRaises(DataManagementError, self.data_manager.create_mapping)
def test_delete_mapping(self):
"""should be able to delete a mapping"""
prepare_presets()
old_preset = Preset(get_preset_path("Foo Device", "preset2"))
old_preset.load()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.data_manager.load_mapping(combination=EventCombination("1,3,1"))
listener = Listener()
self.message_broker.subscribe(MessageType.preset, listener)
self.message_broker.subscribe(MessageType.mapping, listener)
self.data_manager.delete_mapping() # emits preset
self.data_manager.save()
deleted_mapping = old_preset.get_mapping(EventCombination("1,3,1"))
mappings = listener.calls[0].mappings
preset_name = listener.calls[0].name
expected_preset = Preset(get_preset_path("Foo Device", "preset2"))
expected_preset.load()
expected_mappings = [
(mapping.name, mapping.event_combination) for mapping in expected_preset
]
self.assertEqual(preset_name, "preset2")
for mapping in expected_mappings:
self.assertIn(mapping, mappings)
self.assertNotIn(
(deleted_mapping.name, deleted_mapping.event_combination), mappings
)
def test_cannot_delete_mapping(self):
"""deleting a mapping should not be possible if the mapping was not loaded"""
prepare_presets()
self.assertRaises(DataManagementError, self.data_manager.delete_mapping)
self.data_manager.load_group(group_key="Foo Device 2")
self.assertRaises(DataManagementError, self.data_manager.delete_mapping)
self.data_manager.load_preset(name="preset2")
self.assertRaises(DataManagementError, self.data_manager.delete_mapping)
def test_set_autoload(self):
"""should be able to set the autoload status"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device")
listener = Listener()
self.message_broker.subscribe(MessageType.preset, listener)
self.data_manager.load_preset(name="preset1") # sends updated preset data
self.data_manager.set_autoload(True) # sends updated preset data
self.data_manager.set_autoload(False) # sends updated preset data
self.assertFalse(listener.calls[0].autoload)
self.assertTrue(listener.calls[1].autoload)
self.assertFalse(listener.calls[2].autoload)
def test_each_device_can_have_autoload(self):
prepare_presets()
self.data_manager.load_group("Foo Device 2")
self.data_manager.load_preset("preset1")
self.data_manager.set_autoload(True)
# switch to another device
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.data_manager.set_autoload(True)
# now check that both are set to autoload
self.data_manager.load_group("Foo Device 2")
self.data_manager.load_preset("preset1")
self.assertTrue(self.data_manager.get_autoload())
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.assertTrue(self.data_manager.get_autoload())
def test_cannot_set_autoload_without_preset(self):
prepare_presets()
self.assertRaises(
DataManagementError,
self.data_manager.set_autoload,
True,
)
self.data_manager.load_group(group_key="Foo Device 2")
self.assertRaises(
DataManagementError,
self.data_manager.set_autoload,
True,
)
def test_finds_newest_group(self):
Preset(get_preset_path("Foo Device", "preset 1")).save()
time.sleep(0.01)
Preset(get_preset_path("Bar Device", "preset 2")).save()
self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device")
def test_finds_newest_preset(self):
Preset(get_preset_path("Foo Device", "preset 1")).save()
time.sleep(0.01)
Preset(get_preset_path("Foo Device", "preset 2")).save()
self.data_manager.load_group("Foo Device")
self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 2")
def test_newest_group_ignores_unknown_filetypes(self):
Preset(get_preset_path("Foo Device", "preset 1")).save()
time.sleep(0.01)
Preset(get_preset_path("Bar Device", "preset 2")).save()
# not a preset, ignore
time.sleep(0.01)
path = os.path.join(get_preset_path("Foo Device"), "picture.png")
os.mknod(path)
self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device")
def test_newest_preset_ignores_unknown_filetypes(self):
Preset(get_preset_path("Bar Device", "preset 1")).save()
time.sleep(0.01)
Preset(get_preset_path("Bar Device", "preset 2")).save()
time.sleep(0.01)
Preset(get_preset_path("Bar Device", "preset 3")).save()
# not a preset, ignore
time.sleep(0.01)
path = os.path.join(get_preset_path("Bar Device"), "picture.png")
os.mknod(path)
self.data_manager.load_group("Bar Device")
self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3")
def test_newest_group_ignores_unknon_groups(self):
Preset(get_preset_path("Bar Device", "preset 1")).save()
time.sleep(0.01)
Preset(get_preset_path("unknown_group", "preset 2")).save() # not a known group
self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device")
def test_newest_group_and_preset_raises_file_not_found(self):
"""should raise file not found error when all preset folders are empty"""
self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key)
os.makedirs(get_preset_path("Bar Device"))
self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key)
self.data_manager.load_group("Bar Device")
self.assertRaises(FileNotFoundError, self.data_manager.get_newest_preset_name)
def test_newest_preset_raises_data_management_error(self):
"""should raise data management error without a active group"""
self.assertRaises(DataManagementError, self.data_manager.get_newest_preset_name)
def test_newest_preset_only_searches_active_group(self):
Preset(get_preset_path("Foo Device", "preset 1")).save()
time.sleep(0.01)
Preset(get_preset_path("Foo Device", "preset 3")).save()
time.sleep(0.01)
Preset(get_preset_path("Bar Device", "preset 2")).save()
self.data_manager.load_group("Foo Device")
self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3")
def test_available_preset_name_default(self):
self.data_manager.load_group("Foo Device")
self.assertEqual(
self.data_manager.get_available_preset_name(), DEFAULT_PRESET_NAME
)
def test_available_preset_name_adds_number_to_default(self):
Preset(get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save()
self.data_manager.load_group("Foo Device")
self.assertEqual(
self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 2"
)
def test_available_preset_name_returns_provided_name(self):
self.data_manager.load_group("Foo Device")
self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar")
def test_available_preset_name__adds_number_to_provided_name(self):
Preset(get_preset_path("Foo Device", "bar")).save()
self.data_manager.load_group("Foo Device")
self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar 2")
def test_available_preset_name_raises_data_management_error(self):
"""should raise DataManagementError when group is not set"""
self.assertRaises(
DataManagementError, self.data_manager.get_available_preset_name
)
def test_available_preset_name_increments_default(self):
Preset(get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save()
Preset(get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 2")).save()
Preset(get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 3")).save()
self.data_manager.load_group("Foo Device")
self.assertEqual(
self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 4"
)
def test_available_preset_name_increments_provided_name(self):
Preset(get_preset_path("Foo Device", "foo")).save()
Preset(get_preset_path("Foo Device", "foo 1")).save()
Preset(get_preset_path("Foo Device", "foo 2")).save()
self.data_manager.load_group("Foo Device")
self.assertEqual(self.data_manager.get_available_preset_name("foo 1"), "foo 3")
def test_should_send_groups(self):
listener = Listener()
self.message_broker.subscribe(MessageType.groups, listener)
self.data_manager.send_groups()
data = listener.calls[0]
# we expect a list of tuples with the group key and their device types
self.assertEqual(
data.groups,
{
"Foo Device": ["keyboard"],
"Foo Device 2": ["gamepad", "keyboard", "mouse"],
"Bar Device": ["keyboard"],
"gamepad": ["gamepad"],
},
)
def test_should_load_group(self):
prepare_presets()
listener = Listener()
self.message_broker.subscribe(MessageType.group, listener)
self.data_manager.load_group("Foo Device 2")
self.assertEqual(self.data_manager.active_group.key, "Foo Device 2")
data = (
GroupData("Foo Device 2", (p1, p2, p3))
for p1, p2, p3 in permutations(("preset3", "preset2", "preset1"))
)
self.assertIn(listener.calls[0], data)
def test_should_start_reading_active_group(self):
def f(*_):
raise AssertionError()
self.reader.set_group = f
self.assertRaises(AssertionError, self.data_manager.load_group, "Foo Device")
def test_should_send_uinputs(self):
listener = Listener()
self.message_broker.subscribe(MessageType.uinputs, listener)
self.data_manager.send_uinputs()
data = listener.calls[0]
# we expect a list of tuples with the group key and their device types
self.assertEqual(
data.uinputs,
{
"gamepad": self.uinputs.get_uinput("gamepad").capabilities(),
"keyboard": self.uinputs.get_uinput("keyboard").capabilities(),
"mouse": self.uinputs.get_uinput("mouse").capabilities(),
"keyboard + mouse": self.uinputs.get_uinput(
"keyboard + mouse"
).capabilities(),
},
)
def test_cannot_stop_injecting_without_group(self):
self.assertRaises(DataManagementError, self.data_manager.stop_injecting)
def test_cannot_start_injecting_without_preset(self):
self.data_manager.load_group("Foo Device")
self.assertRaises(DataManagementError, self.data_manager.start_injecting)
def test_cannot_get_injector_state_without_group(self):
self.assertRaises(DataManagementError, self.data_manager.get_state)

@ -21,7 +21,24 @@
import unittest import unittest
from evdev.ecodes import KEY_LEFTSHIFT, KEY_RIGHTALT, KEY_LEFTCTRL from evdev.ecodes import (
EV_KEY,
EV_ABS,
EV_REL,
BTN_C,
BTN_B,
BTN_A,
REL_WHEEL,
REL_HWHEEL,
ABS_RY,
ABS_X,
ABS_HAT0Y,
ABS_HAT0X,
KEY_A,
KEY_LEFTSHIFT,
KEY_RIGHTALT,
KEY_LEFTCTRL,
)
from inputremapper.event_combination import EventCombination from inputremapper.event_combination import EventCombination
from inputremapper.input_event import InputEvent from inputremapper.input_event import InputEvent
@ -120,6 +137,66 @@ class TestKey(unittest.TestCase):
self.assertEqual(c1.json_str(), "1,2,3") self.assertEqual(c1.json_str(), "1,2,3")
self.assertEqual(c2.json_str(), "1,2,3+4,5,6") self.assertEqual(c2.json_str(), "1,2,3+4,5,6")
def test_beautify(self):
# not an integration test, but I have all the selection_label tests here already
self.assertEqual(
EventCombination((EV_KEY, KEY_A, 1)).beautify(),
"a",
)
self.assertEqual(
EventCombination([EV_KEY, KEY_A, 1]).beautify(),
"a",
)
self.assertEqual(
EventCombination((EV_ABS, ABS_HAT0Y, -1)).beautify(),
"DPad-Y Up",
)
self.assertEqual(
EventCombination((EV_KEY, BTN_A, 1)).beautify(),
"Button A",
)
self.assertEqual(EventCombination((EV_KEY, 1234, 1)).beautify(), "unknown")
self.assertEqual(
EventCombination([EV_ABS, ABS_HAT0X, -1]).beautify(),
"DPad-X Left",
)
self.assertEqual(
EventCombination([EV_ABS, ABS_HAT0Y, -1]).beautify(),
"DPad-Y Up",
)
self.assertEqual(
EventCombination([EV_KEY, BTN_A, 1]).beautify(),
"Button A",
)
self.assertEqual(
EventCombination([EV_ABS, ABS_X, 1]).beautify(),
"Joystick-X Right",
)
self.assertEqual(
EventCombination([EV_ABS, ABS_RY, 1]).beautify(),
"Joystick-RY Down",
)
self.assertEqual(
EventCombination([EV_REL, REL_HWHEEL, 1]).beautify(),
"Wheel Right",
)
self.assertEqual(
EventCombination([EV_REL, REL_WHEEL, -1]).beautify(),
"Wheel Down",
)
# combinations
self.assertEqual(
EventCombination(
(
(EV_KEY, BTN_A, 1),
(EV_KEY, BTN_B, 1),
(EV_KEY, BTN_C, 1),
),
).beautify(),
"Button A + Button B + Button C",
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

@ -192,3 +192,15 @@ class TestAxisTransformation(unittest.TestCase):
places=5, places=5,
msg=f"test continuity at {- init_args.deadzone} for {init_args}", msg=f"test continuity at {- init_args.deadzone} for {init_args}",
) )
def test_expo_out_of_range(self):
f = Transformation(deadzone=0.1, min_=-20, max_=5, expo=1.3)
self.assertRaises(ValueError, f, 0)
f = Transformation(deadzone=0.1, min_=-20, max_=5, expo=-1.3)
self.assertRaises(ValueError, f, 0)
def test_returns_one_for_range_between_minus_and_plus_one(self):
for init_args in self.get_init_args(max_=(1,), min_=(-1,), gain=(1,)):
f = Transformation(*init_args.values())
self.assertEqual(f(1), 1)
self.assertEqual(f(-1), -1)

@ -307,8 +307,8 @@ class TestEventPipeline(unittest.IsolatedAsyncioTestCase):
) )
# each axis writes speed*gain*rate*sleep=1*0.5*60 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.assertGreater(len(history), speed * gain * rate * sleep * 0.8 * 2)
self.assertLess(len(history), speed * gain * rate * sleep * 1.1 * 2) self.assertLess(len(history), speed * gain * rate * sleep * 1.2 * 2)
# those may be in arbitrary order # those may be in arbitrary order
count_x = history.count((EV_REL, REL_X, -1)) count_x = history.count((EV_REL, REL_X, -1))
@ -362,7 +362,7 @@ class TestEventPipeline(unittest.IsolatedAsyncioTestCase):
event_reader, event_reader,
) )
# wait a bit more for it to sum up # wait a bit more for it to sum up
sleep = 0.5 sleep = 0.8
await asyncio.sleep(sleep) await asyncio.sleep(sleep)
# stop it # stop it
await self.send_events( await self.send_events(

@ -0,0 +1,261 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import asyncio
import unittest
from typing import Iterable
from unittest.mock import MagicMock
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_RIGHT,
BTN_B,
KEY_A,
ABS_HAT0Y,
KEY_B,
KEY_C,
BTN_TL,
)
from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler
from inputremapper.injection.mapping_handlers.axis_switch_handler import (
AxisSwitchHandler,
)
from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler
from inputremapper.injection.mapping_handlers.key_handler import KeyHandler
from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler
from inputremapper.injection.mapping_handlers.mapping_handler import MappingHandler
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,
InputDevice,
cleanup,
convert_to_internal_events,
MAX_ABS,
MIN_ABS,
)
from inputremapper.input_event import InputEvent, EventActions
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 BaseTests:
"""implements test that should pass on most mapping handlers
in special cases override specific tests.
"""
handler: MappingHandler
def setUp(self):
raise NotImplementedError
def tearDown(self) -> None:
cleanup()
def test_reset(self):
mock = MagicMock()
self.handler.set_sub_handler(mock)
self.handler.reset()
mock.reset.assert_called()
class TestAxisSwitchHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.handler = AxisSwitchHandler(
EventCombination.from_string("2,5,0+1,3,1"),
Mapping(
event_combination="2,5,0+1,3,1",
target_uinput="mouse",
output_type=2,
output_code=1,
),
)
class TestAbsToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.handler = AbsToBtnHandler(
EventCombination.from_string("3,5,10"),
Mapping(
event_combination="3,5,10",
target_uinput="mouse",
output_symbol="BTN_LEFT",
),
)
class TestAbsToRelHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.handler = AbsToRelHandler(
EventCombination((EV_ABS, ABS_X, 0)),
Mapping(
event_combination=f"{EV_ABS},{ABS_X},0",
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_X,
),
)
async def test_reset(self):
self.handler.notify(
InputEvent(0, 0, EV_ABS, ABS_X, MAX_ABS),
source=InputDevice("/dev/input/event15"),
forward=evdev.UInput(),
)
await asyncio.sleep(0.2)
self.handler.reset()
await asyncio.sleep(0.05)
count = global_uinputs.get_uinput("mouse").write_count
self.assertGreater(count, 6) # count should be 60*0.2 = 12
await asyncio.sleep(0.2)
self.assertEqual(count, global_uinputs.get_uinput("mouse").write_count)
class TestCombinationHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.handler = AxisSwitchHandler(
EventCombination.from_string("2,0,10+1,3,1"),
Mapping(
event_combination="2,0,10+1,3,1",
target_uinput="mouse",
output_symbol="BTN_LEFT",
),
)
class TestHierarchyHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.mock1 = MagicMock()
self.mock2 = MagicMock()
self.mock3 = MagicMock()
self.handler = HierarchyHandler(
[self.mock1, self.mock2, self.mock3],
InputEvent.from_tuple((EV_KEY, KEY_A, 1)),
)
def test_reset(self):
self.handler.reset()
self.mock1.reset.assert_called()
self.mock2.reset.assert_called()
self.mock3.reset.assert_called()
class TestKeyHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.handler = KeyHandler(
EventCombination.from_string("2,0,10+1,3,1"),
Mapping(
event_combination="2,0,10+1,3,1",
target_uinput="mouse",
output_symbol="BTN_LEFT",
),
)
def test_reset(self):
self.handler.notify(
InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)),
source=InputDevice("/dev/input/event11"),
forward=evdev.UInput(),
)
history = convert_to_internal_events(
global_uinputs.get_uinput("mouse").write_history
)
self.assertEqual(history[0], InputEvent.from_tuple((EV_KEY, BTN_LEFT, 1)))
self.assertEqual(len(history), 1)
self.handler.reset()
history = convert_to_internal_events(
global_uinputs.get_uinput("mouse").write_history
)
self.assertEqual(history[1], InputEvent.from_tuple((EV_KEY, BTN_LEFT, 0)))
self.assertEqual(len(history), 2)
class TestMacroHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.context_mock = MagicMock()
self.handler = MacroHandler(
EventCombination.from_string("2,0,10+1,3,1"),
Mapping(
event_combination="2,0,10+1,3,1",
target_uinput="mouse",
output_symbol="hold_keys(BTN_LEFT, BTN_RIGHT)",
),
context=self.context_mock,
)
async def test_reset(self):
self.handler.notify(
InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)),
source=InputDevice("/dev/input/event11"),
forward=evdev.UInput(),
)
await asyncio.sleep(0.1)
history = convert_to_internal_events(
global_uinputs.get_uinput("mouse").write_history
)
self.assertIn(InputEvent.from_tuple((EV_KEY, BTN_LEFT, 1)), history)
self.assertIn(InputEvent.from_tuple((EV_KEY, BTN_RIGHT, 1)), history)
self.assertEqual(len(history), 2)
self.handler.reset()
await asyncio.sleep(0.1)
history = convert_to_internal_events(
global_uinputs.get_uinput("mouse").write_history
)
self.assertIn(InputEvent.from_tuple((EV_KEY, BTN_LEFT, 0)), history[-2:])
self.assertIn(InputEvent.from_tuple((EV_KEY, BTN_RIGHT, 0)), history[-2:])
self.assertEqual(len(history), 4)
class TestRelToBtnHanlder(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.handler = AxisSwitchHandler(
EventCombination.from_string("2,0,10+1,3,1"),
Mapping(
event_combination="2,0,10+1,3,1",
target_uinput="mouse",
output_symbol="BTN_LEFT",
),
)

@ -58,16 +58,15 @@ class TestEventReader(unittest.IsolatedAsyncioTestCase):
def tearDown(self): def tearDown(self):
quick_cleanup() quick_cleanup()
def setup(self, source, mapping): async def setup(self, source, mapping):
"""Set a a EventReader up for the test and run it in the background.""" """Set a EventReader up for the test and run it in the background."""
forward_to = evdev.UInput() forward_to = evdev.UInput()
context = Context(mapping) context = Context(mapping)
context.uinput = evdev.UInput() context.uinput = evdev.UInput()
consumer_control = EventReader(context, source, forward_to, self.stop_event) event_reader = EventReader(context, source, forward_to, self.stop_event)
# for consumer in consumer_control._consumers: asyncio.ensure_future(event_reader.run())
# consumer._abs_range = (-10, 10) await asyncio.sleep(0.1)
asyncio.ensure_future(consumer_control.run()) return context, event_reader
return context, consumer_control
async def test_if_single_joystick_then(self): async def test_if_single_joystick_then(self):
# TODO: Move this somewhere more sensible # TODO: Move this somewhere more sensible
@ -112,7 +111,7 @@ class TestEventReader(unittest.IsolatedAsyncioTestCase):
cfg["output_code"] = REL_WHEEL_HI_RES cfg["output_code"] = REL_WHEEL_HI_RES
self.preset.add(Mapping(**cfg)) self.preset.add(Mapping(**cfg))
context, _ = self.setup(self.gamepad_source, self.preset) context, _ = await self.setup(self.gamepad_source, self.preset)
self.gamepad_source.push_events( self.gamepad_source.push_events(
[ [
@ -125,7 +124,9 @@ class TestEventReader(unittest.IsolatedAsyncioTestCase):
new_event(EV_KEY, trigger, 0), new_event(EV_KEY, trigger, 0),
] ]
) )
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
self.stop_event.set() # stop the reader
self.assertEqual(len(context.listeners), 0) self.assertEqual(len(context.listeners), 0)
history = [a.t for a in global_uinputs.get_uinput("keyboard").write_history] 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, 1), history)
@ -151,7 +152,7 @@ class TestEventReader(unittest.IsolatedAsyncioTestCase):
# self.preset.set("gamepad.joystick.left_purpose", BUTTONS) # self.preset.set("gamepad.joystick.left_purpose", BUTTONS)
# self.preset.set("gamepad.joystick.right_purpose", BUTTONS) # self.preset.set("gamepad.joystick.right_purpose", BUTTONS)
context, _ = self.setup(self.gamepad_source, self.preset) context, _ = await self.setup(self.gamepad_source, self.preset)
self.gamepad_source.push_events( self.gamepad_source.push_events(
[ [

@ -33,12 +33,7 @@ from inputremapper.groups import (
_FindGroups, _FindGroups,
groups, groups,
classify, classify,
GAMEPAD, DeviceType,
MOUSE,
UNKNOWN,
GRAPHICS_TABLET,
TOUCHPAD,
KEYBOARD,
_Group, _Group,
) )
@ -58,7 +53,7 @@ class TestGroups(unittest.TestCase):
group = _Group( group = _Group(
paths=["/dev/a", "/dev/b", "/dev/c"], paths=["/dev/a", "/dev/b", "/dev/c"],
names=["name_bar", "name_a", "name_foo"], names=["name_bar", "name_a", "name_foo"],
types=[MOUSE, KEYBOARD, UNKNOWN], types=[DeviceType.MOUSE, DeviceType.KEYBOARD, DeviceType.UNKNOWN],
key="key", key="key",
) )
self.assertEqual(group.name, "name_a") self.assertEqual(group.name, "name_a")
@ -85,7 +80,7 @@ class TestGroups(unittest.TestCase):
"/dev/input/event1", "/dev/input/event1",
], ],
"names": ["Foo Device"], "names": ["Foo Device"],
"types": [KEYBOARD], "types": [DeviceType.KEYBOARD],
"key": "Foo Device", "key": "Foo Device",
} }
), ),
@ -95,9 +90,19 @@ class TestGroups(unittest.TestCase):
"/dev/input/event11", "/dev/input/event11",
"/dev/input/event10", "/dev/input/event10",
"/dev/input/event13", "/dev/input/event13",
"/dev/input/event15",
],
"names": [
"Foo Device foo",
"Foo Device",
"Foo Device",
"Foo Device bar",
],
"types": [
DeviceType.GAMEPAD,
DeviceType.KEYBOARD,
DeviceType.MOUSE,
], ],
"names": ["Foo Device foo", "Foo Device", "Foo Device"],
"types": [KEYBOARD, MOUSE],
"key": "Foo Device 2", "key": "Foo Device 2",
} }
), ),
@ -105,7 +110,7 @@ class TestGroups(unittest.TestCase):
{ {
"paths": ["/dev/input/event20"], "paths": ["/dev/input/event20"],
"names": ["Bar Device"], "names": ["Bar Device"],
"types": [KEYBOARD], "types": [DeviceType.KEYBOARD],
"key": "Bar Device", "key": "Bar Device",
} }
), ),
@ -113,7 +118,7 @@ class TestGroups(unittest.TestCase):
{ {
"paths": ["/dev/input/event30"], "paths": ["/dev/input/event30"],
"names": ["gamepad"], "names": ["gamepad"],
"types": [GAMEPAD], "types": [DeviceType.GAMEPAD],
"key": "gamepad", "key": "gamepad",
} }
), ),
@ -121,7 +126,7 @@ class TestGroups(unittest.TestCase):
{ {
"paths": ["/dev/input/event40"], "paths": ["/dev/input/event40"],
"names": ["input-remapper Bar Device"], "names": ["input-remapper Bar Device"],
"types": [KEYBOARD], "types": [DeviceType.KEYBOARD],
"key": "input-remapper Bar Device", "key": "input-remapper Bar Device",
} }
), ),
@ -229,7 +234,7 @@ class TestGroups(unittest.TestCase):
} }
) )
), ),
GAMEPAD, DeviceType.GAMEPAD,
) )
"""Mice""" """Mice"""
@ -247,12 +252,14 @@ class TestGroups(unittest.TestCase):
} }
) )
), ),
MOUSE, DeviceType.MOUSE,
) )
"""Keyboard""" """Keyboard"""
self.assertEqual(classify(FakeDevice({EV_KEY: [evdev.ecodes.KEY_A]})), KEYBOARD) self.assertEqual(
classify(FakeDevice({EV_KEY: [evdev.ecodes.KEY_A]})), DeviceType.KEYBOARD
)
"""Touchpads""" """Touchpads"""
@ -265,7 +272,7 @@ class TestGroups(unittest.TestCase):
} }
) )
), ),
TOUCHPAD, DeviceType.TOUCHPAD,
) )
"""Graphics tablets""" """Graphics tablets"""
@ -279,7 +286,7 @@ class TestGroups(unittest.TestCase):
} }
) )
), ),
GRAPHICS_TABLET, DeviceType.GRAPHICS_TABLET,
) )
"""Weird combos""" """Weird combos"""
@ -293,19 +300,23 @@ class TestGroups(unittest.TestCase):
} }
) )
), ),
UNKNOWN, DeviceType.UNKNOWN,
) )
self.assertEqual( self.assertEqual(
classify( classify(
FakeDevice({EV_ABS: [evdev.ecodes.ABS_X], EV_KEY: [evdev.ecodes.BTN_A]}) FakeDevice({EV_ABS: [evdev.ecodes.ABS_X], EV_KEY: [evdev.ecodes.BTN_A]})
), ),
UNKNOWN, DeviceType.UNKNOWN,
) )
self.assertEqual(classify(FakeDevice({EV_KEY: [evdev.ecodes.BTN_A]})), UNKNOWN) self.assertEqual(
classify(FakeDevice({EV_KEY: [evdev.ecodes.BTN_A]})), DeviceType.UNKNOWN
)
self.assertEqual(classify(FakeDevice({EV_ABS: [evdev.ecodes.ABS_X]})), UNKNOWN) self.assertEqual(
classify(FakeDevice({EV_ABS: [evdev.ecodes.ABS_X]})), DeviceType.UNKNOWN
)
if __name__ == "__main__": if __name__ == "__main__":

@ -58,6 +58,7 @@ from inputremapper.injection.injector import (
NO_GRAB, NO_GRAB,
UNKNOWN, UNKNOWN,
get_udev_name, get_udev_name,
FAILED,
) )
from inputremapper.injection.numlock import is_numlock_on from inputremapper.injection.numlock import is_numlock_on
from inputremapper.configs.system_mapping import ( from inputremapper.configs.system_mapping import (
@ -65,12 +66,11 @@ from inputremapper.configs.system_mapping import (
DISABLE_CODE, DISABLE_CODE,
DISABLE_NAME, DISABLE_NAME,
) )
from inputremapper.gui.active_preset import active_preset
from inputremapper.configs.preset import Preset from inputremapper.configs.preset import Preset
from inputremapper.event_combination import EventCombination from inputremapper.event_combination import EventCombination
from inputremapper.injection.macros.parse import parse from inputremapper.injection.macros.parse import parse
from inputremapper.injection.context import Context from inputremapper.injection.context import Context
from inputremapper.groups import groups, classify, GAMEPAD from inputremapper.groups import groups, classify, DeviceType
def wait_for_uinput_write(): def wait_for_uinput_write():
@ -101,9 +101,10 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
evdev.InputDevice.grab = grab_fail_twice evdev.InputDevice.grab = grab_fail_twice
def tearDown(self): def tearDown(self):
if self.injector is not None: if self.injector is not None and self.injector.is_alive():
self.injector.stop_injecting() self.injector.stop_injecting()
self.assertEqual(self.injector.get_state(), STOPPED) time.sleep(0.2)
self.assertIn(self.injector.get_state(), (STOPPED, FAILED, NO_GRAB))
self.injector = None self.injector = None
evdev.InputDevice.grab = self.grab evdev.InputDevice.grab = self.grab
@ -119,8 +120,8 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
# this test needs to pass around all other constraints of # this test needs to pass around all other constraints of
# _grab_device # _grab_device
self.injector.context = Context(preset) self.injector.context = Context(preset)
device = self.injector._grab_device(path) device = self.injector._grab_device(evdev.InputDevice(path))
gamepad = classify(device) == GAMEPAD gamepad = classify(device) == DeviceType.GAMEPAD
self.assertFalse(gamepad) self.assertFalse(gamepad)
self.assertEqual(self.failed, 2) self.assertEqual(self.failed, 2)
# success on the third try # success on the third try
@ -134,7 +135,7 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
self.injector = Injector(groups.find(key="Foo Device 2"), preset) self.injector = Injector(groups.find(key="Foo Device 2"), preset)
path = "/dev/input/event10" path = "/dev/input/event10"
self.injector.context = Context(preset) self.injector.context = Context(preset)
device = self.injector._grab_device(path) device = self.injector._grab_device(evdev.InputDevice(path))
self.assertIsNone(device) self.assertIsNone(device)
self.assertGreaterEqual(self.failed, 1) self.assertGreaterEqual(self.failed, 1)
@ -154,14 +155,15 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
) )
self.injector = Injector(groups.find(name="gamepad"), preset) self.injector = Injector(groups.find(name="gamepad"), preset)
self.injector.context = Context(preset) self.injector.context = Context(preset)
self.injector.group.paths = [
"/dev/input/event10",
"/dev/input/event30",
"/dev/input/event1234",
]
_grab_device = self.injector._grab_device grabbed = self.injector._grab_devices()
# doesn't have the required capability self.assertEqual(len(grabbed), 1)
self.assertIsNone(_grab_device("/dev/input/event10")) self.assertEqual(grabbed[0].path, "/dev/input/event30")
# according to the fixtures, /dev/input/event30 can do ABS_HAT0X
self.assertIsNotNone(_grab_device("/dev/input/event30"))
# this doesn't exist
self.assertIsNone(_grab_device("/dev/input/event1234"))
def test_forward_gamepad_events(self): def test_forward_gamepad_events(self):
# forward abs joystick events # forward abs joystick events
@ -170,15 +172,16 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
self.injector.context = Context(preset) self.injector.context = Context(preset)
path = "/dev/input/event30" path = "/dev/input/event30"
device = self.injector._grab_device(path) device = self.injector._grab_devices()
self.assertIsNone(device) # no capability is used, so it won't grab self.assertEqual(device, []) # no capability is used, so it won't grab
preset.add( preset.add(
get_key_mapping(EventCombination([EV_KEY, BTN_A, 1]), "keyboard", "a"), get_key_mapping(EventCombination([EV_KEY, BTN_A, 1]), "keyboard", "a"),
) )
device = self.injector._grab_device(path) devices = self.injector._grab_devices()
self.assertIsNotNone(device) self.assertEqual(len(devices), 1)
gamepad = classify(device) == GAMEPAD self.assertEqual(devices[0].path, path)
gamepad = classify(devices[0]) == DeviceType.GAMEPAD
self.assertTrue(gamepad) self.assertTrue(gamepad)
def test_skip_unused_device(self): def test_skip_unused_device(self):
@ -188,8 +191,9 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
self.injector = Injector(groups.find(key="Foo Device 2"), preset) self.injector = Injector(groups.find(key="Foo Device 2"), preset)
self.injector.context = Context(preset) self.injector.context = Context(preset)
path = "/dev/input/event11" path = "/dev/input/event11"
device = self.injector._grab_device(path) self.injector.group.paths = [path]
self.assertIsNone(device) devices = self.injector._grab_devices()
self.assertEqual(devices, [])
self.assertEqual(self.failed, 0) self.assertEqual(self.failed, 0)
def test_skip_unknown_device(self): def test_skip_unknown_device(self):
@ -199,15 +203,17 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
# skips a device because its capabilities are not used in the preset # skips a device because its capabilities are not used in the preset
self.injector = Injector(groups.find(key="Foo Device 2"), preset) self.injector = Injector(groups.find(key="Foo Device 2"), preset)
self.injector.context = Context(preset) self.injector.context = Context(preset)
path = "/dev/input/event11" path = "/dev/input/event11"
device = self.injector._grab_device(path) self.injector.group.paths = [path]
devices = self.injector._grab_devices()
# skips the device alltogether, so no grab attempts fail # skips the device alltogether, so no grab attempts fail
self.assertEqual(self.failed, 0) self.assertEqual(self.failed, 0)
self.assertIsNone(device) self.assertEqual(devices, [])
def test_get_udev_name(self): def test_get_udev_name(self):
self.injector = Injector(groups.find(key="Foo Device 2"), active_preset) self.injector = Injector(groups.find(key="Foo Device 2"), Preset())
suffix = "mapped" suffix = "mapped"
prefix = "input-remapper" prefix = "input-remapper"
expected = f'{prefix} {"a" * (80 - len(suffix) - len(prefix) - 2)} {suffix}' expected = f'{prefix} {"a" * (80 - len(suffix) - len(prefix) - 2)} {suffix}'
@ -236,15 +242,11 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
self.injector.run() self.injector.run()
self.assertEqual( self.assertEqual(
self.injector.context.preset.get_mapping( self.injector.preset.get_mapping(EventCombination([EV_KEY, KEY_A, 1])),
EventCombination([EV_KEY, KEY_A, 1])
),
m1, m1,
) )
self.assertEqual( self.assertEqual(
self.injector.context.preset.get_mapping( self.injector.preset.get_mapping(EventCombination([EV_REL, REL_HWHEEL, 1])),
EventCombination([EV_REL, REL_HWHEEL, 1])
),
m2, m2,
) )
@ -516,7 +518,7 @@ class TestModifyCapabilities(unittest.TestCase):
# I don't know what ABS_VOLUME is, for now I would like to just always # 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 # remove it until somebody complains, since its presence broke stuff
self.injector = Injector(None, self.preset) self.injector = Injector(mock.Mock(), self.preset)
self.fake_device._capabilities = { self.fake_device._capabilities = {
EV_ABS: [ABS_VOLUME, (ABS_X, evdev.AbsInfo(0, 0, 500, 0, 0, 0))], EV_ABS: [ABS_VOLUME, (ABS_X, evdev.AbsInfo(0, 0, 500, 0, 0, 0))],
EV_KEY: [1, 2, 3], EV_KEY: [1, 2, 3],

@ -17,7 +17,8 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import asyncio
import multiprocessing
from tests.test import quick_cleanup, tmp from tests.test import quick_cleanup, tmp
@ -125,7 +126,7 @@ class TestSocket(unittest.TestCase):
self.assertRaises(NotImplementedError, lambda: Base.fileno(None)) self.assertRaises(NotImplementedError, lambda: Base.fileno(None))
class TestPipe(unittest.TestCase): class TestPipe(unittest.IsolatedAsyncioTestCase):
def test_pipe_single(self): def test_pipe_single(self):
p1 = Pipe(os.path.join(tmp, "pipe")) p1 = Pipe(os.path.join(tmp, "pipe"))
self.assertEqual(p1.recv(), None) self.assertEqual(p1.recv(), None)
@ -161,6 +162,47 @@ class TestPipe(unittest.TestCase):
self.assertEqual(p2.recv(), 3) self.assertEqual(p2.recv(), 3)
self.assertEqual(p2.recv(), None) self.assertEqual(p2.recv(), None)
async def test_async_for_loop(self):
p1 = Pipe(os.path.join(tmp, "pipe"))
iterator = p1.__aiter__()
p1.send(1)
self.assertEqual(await iterator.__anext__(), 1)
read_task = asyncio.Task(iterator.__anext__())
timeout_task = asyncio.Task(asyncio.sleep(1))
done, pending = await asyncio.wait(
(read_task, timeout_task), return_when=asyncio.FIRST_COMPLETED
)
self.assertIn(timeout_task, done)
self.assertIn(read_task, pending)
read_task.cancel()
async def test_async_for_loop_duo(self):
def writer():
p = Pipe(os.path.join(tmp, "pipe"))
for i in range(3):
p.send(i)
time.sleep(0.5)
for i in range(3):
p.send(i)
time.sleep(0.1)
p.send("stop now")
p1 = Pipe(os.path.join(tmp, "pipe"))
w_process = multiprocessing.Process(target=writer)
w_process.start()
messages = []
async for msg in p1:
messages.append(msg)
if msg == "stop now":
break
self.assertEqual(messages, [0, 1, 2, 0, 1, 2, "stop now"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

@ -792,7 +792,7 @@ class TestMacros(MacroTestBase):
keystroke_sleep = DummyMapping.macro_key_sleep_ms keystroke_sleep = DummyMapping.macro_key_sleep_ms
sleep_time = 2 * repeats * keystroke_sleep / 1000 sleep_time = 2 * repeats * keystroke_sleep / 1000
self.assertGreater(time.time() - start, sleep_time * 0.9) self.assertGreater(time.time() - start, sleep_time * 0.9)
self.assertLess(time.time() - start, sleep_time * 1.2) self.assertLess(time.time() - start, sleep_time * 1.3)
self.assertListEqual( self.assertListEqual(
self.result, self.result,

@ -24,8 +24,9 @@ from functools import partial
from evdev.ecodes import EV_KEY from evdev.ecodes import EV_KEY
from pydantic import ValidationError from pydantic import ValidationError
from inputremapper.configs.mapping import Mapping from inputremapper.configs.mapping import Mapping, UIMapping
from inputremapper.configs.system_mapping import system_mapping from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.message_broker import MessageType
from inputremapper.input_event import EventActions from inputremapper.input_event import EventActions
from inputremapper.event_combination import EventCombination from inputremapper.event_combination import EventCombination
@ -84,20 +85,20 @@ class TestMapping(unittest.IsolatedAsyncioTestCase):
"output_code": 3, "output_code": 3,
} }
m = Mapping(**cfg) m = Mapping(**cfg)
expected_actions = [EventActions.as_key, EventActions.as_key, EventActions.none] expected_actions = [(EventActions.as_key,), (EventActions.as_key,), ()]
actions = [event.action for event in m.event_combination] actions = [event.actions for event in m.event_combination]
self.assertEqual(expected_actions, actions) self.assertEqual(expected_actions, actions)
# copy keeps the event action # copy keeps the event actions
m2 = m.copy() m2 = m.copy()
actions = [event.action for event in m2.event_combination] actions = [event.actions for event in m2.event_combination]
self.assertEqual(expected_actions, actions) self.assertEqual(expected_actions, actions)
# changing the combination sets the action # changing the combination sets the actions
m3 = m.copy() m3 = m.copy()
m3.event_combination = "1,2,1+2,1,0+3,1,10" m3.event_combination = "1,2,1+2,1,0+3,1,10"
expected_actions = [EventActions.as_key, EventActions.none, EventActions.as_key] expected_actions = [(EventActions.as_key,), (), (EventActions.as_key,)]
actions = [event.action for event in m3.event_combination] actions = [event.actions for event in m3.event_combination]
self.assertEqual(expected_actions, actions) self.assertEqual(expected_actions, actions)
def test_combination_changed_callback(self): def test_combination_changed_callback(self):
@ -331,5 +332,57 @@ class TestMapping(unittest.IsolatedAsyncioTestCase):
self.assertTrue(m.is_valid()) self.assertTrue(m.is_valid())
class TestUIMapping(unittest.IsolatedAsyncioTestCase):
def test_init(self):
"""should be able to initialize without an error"""
UIMapping()
def test_is_valid(self):
"""should be invalid at first
and become valid once all data is provided"""
m = UIMapping()
self.assertFalse(m.is_valid())
m.event_combination = "1,2,3"
m.output_symbol = "a"
self.assertFalse(m.is_valid())
m.target_uinput = "keyboard"
self.assertTrue(m.is_valid())
def test_updates_validation_error(self):
m = UIMapping()
self.assertIn("2 validation errors for UIMapping", str(m.get_error()))
m.event_combination = "1,2,3"
m.output_symbol = "a"
self.assertIn(
"1 validation error for UIMapping\ntarget_uinput", str(m.get_error())
)
m.target_uinput = "keyboard"
self.assertTrue(m.is_valid())
self.assertIsNone(m.get_error())
def test_copy_returns_ui_mapping(self):
"""copy should also be a UIMapping with all the invalid data"""
m = UIMapping()
m2 = m.copy()
self.assertIsInstance(m2, UIMapping)
self.assertEqual(m2.event_combination, EventCombination.empty_combination())
self.assertIsNone(m2.target_uinput)
def test_get_bus_massage(self):
m = UIMapping()
m2 = m.get_bus_message()
self.assertEqual(m2.message_type, MessageType.mapping)
with self.assertRaises(TypeError):
# the massage should be immutable
m2.output_symbol = "a"
self.assertIsNone(m2.output_symbol)
# the original should be not immutable
m.output_symbol = "a"
self.assertEqual(m.output_symbol, "a")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

@ -0,0 +1,79 @@
import unittest
from dataclasses import dataclass
from inputremapper.gui.message_broker import MessageBroker, MessageType
class Listener:
def __init__(self):
self.calls = []
def __call__(self, data):
self.calls.append(data)
@dataclass
class Message:
message_type: MessageType
msg: str
class TestMessageBroker(unittest.TestCase):
def test_calls_listeners(self):
"""The correct Listeners get called"""
message_broker = MessageBroker()
listener = Listener()
message_broker.subscribe(MessageType.test1, listener)
message_broker.send(Message(MessageType.test1, "foo"))
message_broker.send(Message(MessageType.test2, "bar"))
self.assertEqual(listener.calls[0], Message(MessageType.test1, "foo"))
def test_unsubscribe(self):
message_broker = MessageBroker()
listener = Listener()
message_broker.subscribe(MessageType.test1, listener)
message_broker.send(Message(MessageType.test1, "a"))
message_broker.unsubscribe(listener)
message_broker.send(Message(MessageType.test1, "b"))
self.assertEqual(len(listener.calls), 1)
self.assertEqual(listener.calls[0], Message(MessageType.test1, "a"))
def test_unsubscribe_unknown_listener(self):
"""nothing happens if we unsubscribe an unknown listener"""
message_broker = MessageBroker()
listener1 = Listener()
listener2 = Listener()
message_broker.subscribe(MessageType.test1, listener1)
message_broker.unsubscribe(listener2)
message_broker.send(Message(MessageType.test1, "a"))
self.assertEqual(listener1.calls[0], Message(MessageType.test1, "a"))
def test_preserves_order(self):
message_broker = MessageBroker()
calls = []
def listener1(_):
message_broker.send(Message(MessageType.test2, "f"))
calls.append(1)
def listener2(_):
message_broker.send(Message(MessageType.test2, "f"))
calls.append(2)
def listener3(_):
message_broker.send(Message(MessageType.test2, "f"))
calls.append(3)
def listener4(_):
calls.append(4)
message_broker.subscribe(MessageType.test1, listener1)
message_broker.subscribe(MessageType.test1, listener2)
message_broker.subscribe(MessageType.test1, listener3)
message_broker.subscribe(MessageType.test2, listener4)
message_broker.send(Message(MessageType.test1, ""))
first = calls[:3]
first.sort()
self.assertEqual([1, 2, 3], first)
self.assertEqual([4, 4, 4], calls[3:])

@ -481,14 +481,11 @@ class TestPreset(unittest.TestCase):
def test_save_load_with_invalid_mappings(self): def test_save_load_with_invalid_mappings(self):
ui_preset = Preset(get_config_path("test.json"), mapping_factory=UIMapping) ui_preset = Preset(get_config_path("test.json"), mapping_factory=UIMapping)
# cannot add a mapping without a valid combination ui_preset.add(UIMapping())
self.assertRaises(Exception, ui_preset.add, UIMapping())
ui_preset.add(UIMapping(event_combination="1,1,1"))
self.assertFalse(ui_preset.is_valid()) self.assertFalse(ui_preset.is_valid())
# make the mapping valid # make the mapping valid
m = ui_preset.get_mapping(EventCombination.from_string("1,1,1")) m = ui_preset.get_mapping(EventCombination.empty_combination())
m.output_symbol = "a" m.output_symbol = "a"
m.target_uinput = "keyboard" m.target_uinput = "keyboard"
self.assertTrue(ui_preset.is_valid()) self.assertTrue(ui_preset.is_valid())

@ -1,218 +0,0 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from tests.test import tmp
import os
import unittest
import shutil
import time
from inputremapper.configs.preset import (
find_newest_preset,
rename_preset,
get_any_preset,
delete_preset,
get_available_preset_name,
get_presets,
)
from inputremapper.configs.paths import CONFIG_PATH, get_preset_path, touch
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.clear()
active_preset.path = get_preset_path(group_name, name)
active_preset.save()
PRESETS = os.path.join(CONFIG_PATH, "presets")
class TestPresets(unittest.TestCase):
def test_get_available_preset_name(self):
# no filename conflict
self.assertEqual(get_available_preset_name("_", "qux 2"), "qux 2")
touch(get_preset_path("_", "qux 5"))
self.assertEqual(get_available_preset_name("_", "qux 5"), "qux 6")
touch(get_preset_path("_", "qux"))
self.assertEqual(get_available_preset_name("_", "qux"), "qux 2")
touch(get_preset_path("_", "qux1"))
self.assertEqual(get_available_preset_name("_", "qux1"), "qux1 2")
touch(get_preset_path("_", "qux 2 3"))
self.assertEqual(get_available_preset_name("_", "qux 2 3"), "qux 2 4")
touch(get_preset_path("_", "qux 5"))
self.assertEqual(get_available_preset_name("_", "qux 5", True), "qux 5 copy")
touch(get_preset_path("_", "qux 5 copy"))
self.assertEqual(get_available_preset_name("_", "qux 5", True), "qux 5 copy 2")
touch(get_preset_path("_", "qux 5 copy 2"))
self.assertEqual(get_available_preset_name("_", "qux 5", True), "qux 5 copy 3")
touch(get_preset_path("_", "qux 5copy"))
self.assertEqual(
get_available_preset_name("_", "qux 5copy", True),
"qux 5copy copy",
)
touch(get_preset_path("_", "qux 5copy 2"))
self.assertEqual(
get_available_preset_name("_", "qux 5copy 2", True),
"qux 5copy 2 copy",
)
touch(get_preset_path("_", "qux 5copy 2 copy"))
self.assertEqual(
get_available_preset_name("_", "qux 5copy 2 copy", True),
"qux 5copy 2 copy 2",
)
class TestCreatePreset(unittest.TestCase):
def tearDown(self):
if os.path.exists(tmp):
shutil.rmtree(tmp)
def test_create_preset_1(self):
self.assertEqual(get_any_preset(), ("Foo Device", None))
create_preset("Foo Device")
self.assertEqual(get_any_preset(), ("Foo Device", "new preset"))
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset.json"))
def test_create_preset_2(self):
create_preset("Foo Device")
create_preset("Foo Device")
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset.json"))
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset 2.json"))
def test_create_preset_3(self):
create_preset("Foo Device", "pre set")
create_preset("Foo Device", "pre set")
create_preset("Foo Device", "pre set")
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/pre set.json"))
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/pre set 2.json"))
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/pre set 3.json"))
class TestDeletePreset(unittest.TestCase):
def tearDown(self):
if os.path.exists(tmp):
shutil.rmtree(tmp)
def test_delete_preset(self):
create_preset("Foo Device")
create_preset("Foo Device")
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset.json"))
delete_preset("Foo Device", "new preset")
self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/new preset.json"))
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device"))
delete_preset("Foo Device", "new preset 2")
self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/new preset.json"))
self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/new preset 2.json"))
# if no preset in the directory, remove the directory
self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device"))
class TestRenamePreset(unittest.TestCase):
def tearDown(self):
if os.path.exists(tmp):
shutil.rmtree(tmp)
def test_rename_preset(self):
create_preset("Foo Device", "preset 1")
create_preset("Foo Device", "preset 2")
create_preset("Foo Device", "foobar")
rename_preset("Foo Device", "preset 1", "foobar")
rename_preset("Foo Device", "preset 2", "foobar")
self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/preset 1.json"))
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/foobar.json"))
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/foobar 2.json"))
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/foobar 3.json"))
class TestFindPresets(unittest.TestCase):
def tearDown(self):
if os.path.exists(tmp):
shutil.rmtree(tmp)
def test_get_presets(self):
os.makedirs(os.path.join(PRESETS, "1234"))
os.mknod(os.path.join(PRESETS, "1234", "picture.png"))
self.assertEqual(len(get_presets("1234")), 0)
os.mknod(os.path.join(PRESETS, "1234", "foo bar 1.json"))
time.sleep(0.01)
os.mknod(os.path.join(PRESETS, "1234", "foo bar 2.json"))
# the newest to the front
self.assertListEqual(get_presets("1234"), ["foo bar 2", "foo bar 1"])
def test_find_newest_preset_1(self):
create_preset("Foo Device", "preset 1")
time.sleep(0.01)
create_preset("Bar Device", "preset 2")
# not a preset, ignore
time.sleep(0.01)
path = os.path.join(PRESETS, "Bar Device", "picture.png")
os.mknod(path)
self.assertEqual(find_newest_preset(), ("Bar Device", "preset 2"))
def test_find_newest_preset_2(self):
os.makedirs(f"{PRESETS}/Foo Device")
time.sleep(0.01)
os.makedirs(f"{PRESETS}/device_2")
# takes the first one that the test-fake returns
self.assertEqual(find_newest_preset(), ("Foo Device", None))
def test_find_newest_preset_3(self):
os.makedirs(f"{PRESETS}/Foo Device")
self.assertEqual(find_newest_preset(), ("Foo Device", None))
def test_find_newest_preset_4(self):
create_preset("Foo Device", "preset 1")
self.assertEqual(find_newest_preset(), ("Foo Device", "preset 1"))
def test_find_newest_preset_5(self):
create_preset("Foo Device", "preset 1")
time.sleep(0.01)
create_preset("unknown device 3", "preset 3")
self.assertEqual(find_newest_preset(), ("Foo Device", "preset 1"))
def test_find_newest_preset_6(self):
# takes the first one that the test-fake returns
self.assertEqual(find_newest_preset(), ("Foo Device", None))
def test_find_newest_preset_7(self):
self.assertEqual(find_newest_preset("Foo Device"), ("Foo Device", None))
def test_find_newest_preset_8(self):
create_preset("Foo Device", "preset 1")
time.sleep(0.01)
create_preset("Foo Device", "preset 3")
time.sleep(0.01)
create_preset("Bar Device", "preset 2")
self.assertEqual(find_newest_preset("Foo Device"), ("Foo Device", "preset 3"))
if __name__ == "__main__":
unittest.main()

@ -17,20 +17,26 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import json
from typing import List
from inputremapper.gui.message_broker import (
MessageBroker,
MessageType,
CombinationRecorded,
Signal,
)
from tests.test import ( from tests.test import (
new_event, new_event,
push_events, push_events,
send_event_to_reader,
EVENT_READ_TIMEOUT, EVENT_READ_TIMEOUT,
START_READING_DELAY, START_READING_DELAY,
quick_cleanup, quick_cleanup,
MAX_ABS, MAX_ABS,
MIN_ABS,
) )
import unittest import unittest
from unittest import mock
import time import time
import multiprocessing import multiprocessing
@ -48,21 +54,27 @@ from evdev.ecodes import (
REL_X, REL_X,
ABS_X, ABS_X,
ABS_RZ, ABS_RZ,
REL_HWHEEL,
) )
from inputremapper.gui.reader import reader, will_report_up from inputremapper.gui.reader import Reader
from inputremapper.gui.active_preset import active_preset
from inputremapper.configs.global_config import BUTTONS, MOUSE
from inputremapper.event_combination import EventCombination from inputremapper.event_combination import EventCombination
from inputremapper.gui.helper import RootHelper from inputremapper.gui.helper import RootHelper
from inputremapper.groups import groups from inputremapper.groups import _Groups, DeviceType
CODE_1 = 100 CODE_1 = 100
CODE_2 = 101 CODE_2 = 101
CODE_3 = 102 CODE_3 = 102
class Listener:
def __init__(self):
self.calls: List = []
def __call__(self, data):
self.calls.append(data)
def wait(func, timeout=1.0): def wait(func, timeout=1.0):
"""Wait for func to return True.""" """Wait for func to return True."""
iterations = 0 iterations = 0
@ -77,189 +89,257 @@ def wait(func, timeout=1.0):
class TestReader(unittest.TestCase): class TestReader(unittest.TestCase):
def setUp(self): def setUp(self):
self.helper = None self.helper = None
self.groups = _Groups()
self.message_broker = MessageBroker()
self.reader = Reader(self.message_broker, self.groups)
def tearDown(self): def tearDown(self):
quick_cleanup() quick_cleanup()
try:
self.reader.terminate()
except (BrokenPipeError, OSError):
pass
if self.helper is not None: if self.helper is not None:
self.helper.join() self.helper.join()
groups.refresh()
def create_helper(self): def create_helper(self, groups: _Groups = None):
# this will cause pending events to be copied over to the helper # this will cause pending events to be copied over to the helper
# process # process
if not groups:
groups = self.groups
def start_helper(): def start_helper():
helper = RootHelper() helper = RootHelper(groups)
helper.run() helper.run()
self.helper = multiprocessing.Process(target=start_helper) self.helper = multiprocessing.Process(target=start_helper)
self.helper.start() self.helper.start()
time.sleep(0.1) time.sleep(0.1)
def test_will_report_up(self): def test_reading(self):
self.assertFalse(will_report_up(EV_REL)) l1 = Listener()
self.assertTrue(will_report_up(EV_ABS)) l2 = Listener()
self.assertTrue(will_report_up(EV_KEY)) self.message_broker.subscribe(MessageType.combination_recorded, l1)
self.message_broker.subscribe(MessageType.recording_finished, l2)
self.create_helper()
self.reader.set_group(self.groups.find(key="Foo Device 2"))
self.reader.start_recorder()
def test_reading_1(self):
# a single event
push_events("Foo Device 2", [new_event(EV_ABS, ABS_HAT0X, 1)]) push_events("Foo Device 2", [new_event(EV_ABS, ABS_HAT0X, 1)])
# relative axis events should be released automagically after 0.3s
push_events("Foo Device 2", [new_event(EV_REL, REL_X, 5)])
time.sleep(0.2)
# read all pending events. Having a glib mainloop would be better,
# as it would call read automatically periodically
self.reader._read()
self.assertEqual(
[
CombinationRecorded(EventCombination.from_string("3,16,1")),
CombinationRecorded(EventCombination.from_string("3,16,1+2,0,1")),
],
l1.calls,
)
# release the hat switch should emit the recording finished event
# as both the hat and relative axis are released by now
push_events("Foo Device 2", [new_event(EV_ABS, ABS_HAT0X, 0)])
time.sleep(0.3)
self.reader._read()
self.assertEqual([Signal(MessageType.recording_finished)], l2.calls)
def test_should_release_relative_axis(self):
# the timeout is set to 0.3s
l1 = Listener()
l2 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
self.message_broker.subscribe(MessageType.recording_finished, l2)
self.create_helper()
self.reader.set_group(self.groups.find(key="Foo Device 2"))
self.reader.start_recorder()
push_events("Foo Device 2", [new_event(EV_REL, REL_X, -5)])
time.sleep(0.1)
self.reader._read()
self.assertEqual(
[CombinationRecorded(EventCombination.from_string("2,0,-1"))],
l1.calls,
)
self.assertEqual([], l2.calls) # no stop recording yet
time.sleep(0.3)
self.reader._read()
self.assertEqual([Signal(MessageType.recording_finished)], l2.calls)
def test_should_not_trigger_at_low_speed_for_rel_axis(self):
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
self.create_helper()
self.reader.set_group(self.groups.find(key="Foo Device 2"))
self.reader.start_recorder()
push_events("Foo Device 2", [new_event(EV_REL, REL_X, -1)])
time.sleep(0.1)
self.reader._read()
self.assertEqual(0, len(l1.calls))
def test_should_trigger_wheel_at_low_speed(self):
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
self.create_helper()
self.reader.set_group(self.groups.find(key="Foo Device 2"))
self.reader.start_recorder()
push_events( push_events(
"Foo Device 2", "Foo Device 2",
[new_event(EV_ABS, REL_X, 1)], [new_event(EV_REL, REL_WHEEL, -1), new_event(EV_REL, REL_HWHEEL, 1)],
) # mouse movements are ignored )
self.create_helper() time.sleep(0.1)
reader.start_reading(groups.find(key="Foo Device 2")) self.reader._read()
time.sleep(0.2)
self.assertEqual(reader.read(), EventCombination((EV_ABS, ABS_HAT0X, 1)))
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 1)
def test_reading_wheel(self): self.assertEqual(
# will be treated as released automatically at some point [
CombinationRecorded(EventCombination.from_string("2,8,-1")),
CombinationRecorded(EventCombination.from_string("2,8,-1+2,6,1")),
],
l1.calls,
)
def test_wont_emit_the_same_combination_twice(self):
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
self.create_helper() self.create_helper()
reader.start_reading(groups.find(key="Foo Device 2")) self.reader.set_group(self.groups.find(key="Foo Device 2"))
self.reader.start_recorder()
send_event_to_reader(new_event(EV_REL, REL_WHEEL, 0))
self.assertIsNone(reader.read()) push_events("Foo Device 2", [new_event(EV_KEY, KEY_A, 1)])
time.sleep(0.1)
send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1)) self.reader._read()
result = reader.read() # the duplicate event should be ignored
self.assertIsInstance(result, EventCombination) push_events("Foo Device 2", [new_event(EV_KEY, KEY_A, 1)])
self.assertIsInstance(result, tuple) time.sleep(0.1)
self.assertEqual(result, EventCombination((EV_REL, REL_WHEEL, 1))) self.reader._read()
self.assertEqual(result, ((EV_REL, REL_WHEEL, 1),))
self.assertNotEqual( self.assertEqual(
result, [CombinationRecorded(EventCombination.from_string("1,30,1"))],
EventCombination(((EV_REL, REL_WHEEL, 1), (1, 1, 1))), l1.calls,
) )
# it won't return the same event twice def test_should_read_absolut_axis(self):
self.assertEqual(reader.read(), None) l1 = Listener()
l2 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
self.message_broker.subscribe(MessageType.recording_finished, l2)
self.create_helper()
self.reader.set_group(self.groups.find(key="Foo Device 2"))
self.reader.start_recorder()
# but it is still remembered unreleased # over 30% should trigger
self.assertEqual(len(reader._unreleased), 1) push_events("Foo Device 2", [new_event(EV_ABS, ABS_X, int(MAX_ABS * 0.4))])
time.sleep(0.1)
self.reader._read()
self.assertEqual( self.assertEqual(
reader.get_unreleased_keys(), [CombinationRecorded(EventCombination.from_string("3,0,1"))],
EventCombination((EV_REL, REL_WHEEL, 1)), l1.calls,
) )
self.assertIsInstance(reader.get_unreleased_keys(), EventCombination) self.assertEqual([], l2.calls) # no stop recording yet
# as long as new wheel events arrive, it is considered unreleased # less the 30% should release
for _ in range(10): push_events("Foo Device 2", [new_event(EV_ABS, ABS_X, int(MAX_ABS * 0.2))])
send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1)) time.sleep(0.1)
self.assertEqual(reader.read(), None) self.reader._read()
self.assertEqual(len(reader._unreleased), 1) self.assertEqual(
[CombinationRecorded(EventCombination.from_string("3,0,1"))],
# read a few more times, at some point it is treated as unreleased l1.calls,
for _ in range(4): )
self.assertEqual(reader.read(), None) self.assertEqual([Signal(MessageType.recording_finished)], l2.calls)
self.assertEqual(len(reader._unreleased), 0)
self.assertIsNone(reader.get_unreleased_keys()) def test_should_change_direction(self):
l1 = Listener()
"""Combinations""" self.message_broker.subscribe(MessageType.combination_recorded, l1)
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)))
read = reader.read()
self.assertEqual(read, combi_1)
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 2)
self.assertEqual(reader.get_unreleased_keys(), combi_1)
# don't send new wheel down events, it should get released again
i = 0
while len(reader._unreleased) == 2:
read = reader.read()
if i == 100:
raise AssertionError("Did not release the wheel")
i += 1
# and only the comma remains. However, a changed combination is
# only returned when a new key is pressed. Only then the pressed
# down keys are collected in a new Key object.
self.assertEqual(read, None)
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 1)
self.assertEqual(reader.get_unreleased_keys(), EventCombination(combi_1[1]))
# press down a new key, now it will return a different combination
send_event_to_reader(new_event(EV_KEY, KEY_A, 1, 1002))
self.assertEqual(reader.read(), combi_2)
self.assertEqual(len(reader._unreleased), 2)
# release all of them
send_event_to_reader(new_event(EV_KEY, KEY_COMMA, 0))
send_event_to_reader(new_event(EV_KEY, KEY_A, 0))
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 0)
self.assertEqual(reader.get_unreleased_keys(), None)
def test_change_wheel_direction(self):
# not just wheel, anything that suddenly reports a different value.
# as long as type and code are equal its the same key, so there is no
# way both directions can be held down.
self.assertEqual(reader.read(), None)
self.create_helper() self.create_helper()
self.assertEqual(reader.read(), None) self.reader.set_group(self.groups.find(key="Foo Device 2"))
reader.start_reading(groups.find(key="Foo Device 2")) self.reader.start_recorder()
self.assertEqual(reader.read(), None)
push_events(
send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1)) "Foo Device 2",
self.assertEqual(reader.read(), EventCombination((EV_REL, REL_WHEEL, 1))) [
self.assertEqual(len(reader._unreleased), 1) new_event(EV_KEY, KEY_A, 1),
self.assertEqual(reader.read(), None) new_event(EV_ABS, ABS_X, int(MAX_ABS * 0.4)),
new_event(EV_KEY, KEY_COMMA, 1),
send_event_to_reader(new_event(EV_REL, REL_WHEEL, -1)) new_event(EV_ABS, ABS_X, int(MAX_ABS * 0.1)),
self.assertEqual(reader.read(), EventCombination((EV_REL, REL_WHEEL, -1))) new_event(EV_ABS, ABS_X, int(MIN_ABS * 0.4)),
# notice that this is no combination of two sides, the previous ],
# entry in unreleased has to get overwritten. So there is still only )
# one element in it. time.sleep(0.1)
self.assertEqual(len(reader._unreleased), 1) self.reader._read()
self.assertEqual(reader.read(), None) self.assertEqual(
[
CombinationRecorded(EventCombination.from_string("1,30,1")),
CombinationRecorded(EventCombination.from_string("1,30,1+3,0,1")),
CombinationRecorded(
EventCombination.from_string("1,30,1+3,0,1+1,51,1")
),
CombinationRecorded(
EventCombination.from_string("1,30,1+3,0,-1+1,51,1")
),
],
l1.calls,
)
def test_change_device(self): def test_change_device(self):
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
push_events( push_events(
"Foo Device 2", "Foo Device 2",
[ [
new_event(EV_KEY, 1, 1), new_event(EV_KEY, 1, 1),
] ]
* 100, * 10,
) )
push_events( push_events(
"Bar Device", "Bar Device",
[ [
new_event(EV_KEY, 2, 1), new_event(EV_KEY, 2, 1),
new_event(EV_KEY, 2, 0),
] ]
* 100, * 3,
) )
self.create_helper() self.create_helper()
self.reader.set_group(self.groups.find(key="Foo Device 2"))
reader.start_reading(groups.find(key="Foo Device 2")) self.reader.start_recorder()
time.sleep(0.1) time.sleep(0.1)
self.assertEqual(reader.read(), EventCombination((EV_KEY, 1, 1))) self.reader._read()
self.assertEqual(l1.calls[0].combination, EventCombination((EV_KEY, 1, 1)))
reader.start_reading(groups.find(name="Bar Device"))
# it's plausible that right after sending the new read command more self.reader.set_group(self.groups.find(name="Bar Device"))
# events from the old device might still appear. Give the helper
# some time to handle the new command.
time.sleep(0.1) time.sleep(0.1)
reader.clear() self.reader._read()
# we did not get the event from the "Bar Device" because the group change
# stopped the recording
self.assertEqual(len(l1.calls), 1)
self.reader.start_recorder()
push_events("Bar Device", [new_event(EV_KEY, 2, 1)])
time.sleep(0.1) time.sleep(0.1)
self.assertEqual(reader.read(), EventCombination((EV_KEY, 2, 1))) self.reader._read()
self.assertEqual(l1.calls[1].combination, EventCombination((EV_KEY, 2, 1)))
def test_reading_2(self): def test_reading_2(self):
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
# a combination of events # a combination of events
push_events( push_events(
"Foo Device 2", "Foo Device 2",
[ [
new_event(EV_KEY, CODE_1, 1, 10000.1234), new_event(EV_KEY, CODE_1, 1, 10000.1234),
new_event(EV_KEY, CODE_3, 1, 10001.1234), new_event(EV_KEY, CODE_3, 1, 10001.1234),
new_event(EV_ABS, ABS_HAT0X, -1, 10002.1234),
], ],
) )
@ -270,131 +350,33 @@ class TestReader(unittest.TestCase):
# refresh was called as expected # refresh was called as expected
pipe[1].send("refreshed") pipe[1].send("refreshed")
with mock.patch.object(groups, "refresh", refresh): groups = _Groups()
self.create_helper() groups.refresh = refresh
self.create_helper(groups)
reader.start_reading(groups.find(key="Foo Device 2")) self.reader.set_group(self.groups.find(key="Foo Device 2"))
self.reader.start_recorder()
# sending anything arbitrary does not stop the helper # sending anything arbitrary does not stop the helper
reader._commands.send(856794) self.reader._commands.send(856794)
time.sleep(0.2) time.sleep(0.2)
push_events("Foo Device 2", [new_event(EV_ABS, ABS_HAT0X, -1, 10002.1234)])
time.sleep(0.1)
# but it makes it look for new devices because maybe its list of # but it makes it look for new devices because maybe its list of
# groups is not up-to-date # self.groups is not up-to-date
self.assertTrue(pipe[0].poll()) self.assertTrue(pipe[0].poll())
self.assertEqual(pipe[0].recv(), "refreshed") self.assertEqual(pipe[0].recv(), "refreshed")
self.reader._read()
self.assertEqual( self.assertEqual(
reader.read(), l1.calls[-1].combination,
((EV_KEY, CODE_1, 1), (EV_KEY, CODE_3, 1), (EV_ABS, ABS_HAT0X, -1)), ((EV_KEY, CODE_1, 1), (EV_KEY, CODE_3, 1), (EV_ABS, ABS_HAT0X, -1)),
) )
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 3)
def test_reading_3(self):
self.create_helper()
# a combination of events via Socket with reads inbetween
reader.start_reading(groups.find(name="gamepad"))
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)
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))),
)
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)),
),
)
# adding duplicate down events won't report a different combination.
# import for triggers, as they keep reporting more down-events before
# they are released
send_event_to_reader(new_event(EV_ABS, ABS_Y, 1, 1005))
self.assertEqual(reader.read(), None)
send_event_to_reader(new_event(EV_ABS, ABS_HAT0X, -1, 1006))
self.assertEqual(reader.read(), None)
send_event_to_reader(new_event(EV_KEY, CODE_1, 0, 1004))
read = reader.read()
self.assertEqual(read, None)
send_event_to_reader(new_event(EV_ABS, ABS_Y, 0, 1007))
self.assertEqual(reader.read(), None)
send_event_to_reader(new_event(EV_KEY, ABS_HAT0X, 0, 1008))
self.assertEqual(reader.read(), None)
def test_reads_joysticks(self):
# if their purpose is "buttons"
# active_preset.set("gamepad.joystick.left_purpose", BUTTONS)
push_events(
"gamepad",
[
new_event(EV_ABS, ABS_Y, MAX_ABS),
# the value of that one is interpreted as release, because
# it is too small
new_event(EV_ABS, ABS_X, MAX_ABS // 10),
],
)
self.create_helper()
reader.start_reading(groups.find(name="gamepad"))
time.sleep(0.2)
self.assertEqual(reader.read(), EventCombination((EV_ABS, ABS_Y, 1)))
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 1)
reader._unreleased = {}
# active_preset.set("gamepad.joystick.left_purpose", MOUSE)
push_events("gamepad", [new_event(EV_ABS, ABS_Y, MAX_ABS)])
self.create_helper()
reader.start_reading(groups.find(name="gamepad"))
time.sleep(0.1)
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 0)
def test_combine_triggers(self):
reader.start_reading(groups.find(key="Foo Device 2"))
i = 0
def next_timestamp():
nonlocal i
i += 1
return time.time() + i
# based on an observed bug
send_event_to_reader(new_event(3, 1, 0, next_timestamp()))
send_event_to_reader(new_event(3, 0, 0, next_timestamp()))
send_event_to_reader(new_event(3, 2, 1, next_timestamp()))
self.assertEqual(reader.read(), EventCombination((EV_ABS, ABS_Z, 1)))
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))),
)
send_event_to_reader(new_event(3, 5, 0, next_timestamp()))
send_event_to_reader(new_event(3, 0, 0, next_timestamp()))
send_event_to_reader(new_event(3, 1, 0, next_timestamp()))
self.assertEqual(reader.read(), None)
send_event_to_reader(new_event(3, 2, 1, next_timestamp()))
send_event_to_reader(new_event(3, 1, 0, next_timestamp()))
send_event_to_reader(new_event(3, 0, 0, next_timestamp()))
# due to not properly handling the duplicate down event it cleared
# the combination and returned it. Instead it should report None
# and by doing that keep the previous combination.
self.assertEqual(reader.read(), None)
def test_blacklisted_events(self): def test_blacklisted_events(self):
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
push_events( push_events(
"Foo Device 2", "Foo Device 2",
[ [
@ -404,26 +386,34 @@ class TestReader(unittest.TestCase):
], ],
) )
self.create_helper() self.create_helper()
reader.start_reading(groups.find(key="Foo Device 2")) self.reader.set_group(self.groups.find(key="Foo Device 2"))
self.reader.start_recorder()
time.sleep(0.1) time.sleep(0.1)
self.assertEqual(reader.read(), EventCombination((EV_KEY, CODE_2, 1))) self.reader._read()
self.assertEqual(reader.read(), None) self.assertEqual(
self.assertEqual(len(reader._unreleased), 1) l1.calls[-1].combination, EventCombination((EV_KEY, CODE_2, 1))
)
def test_ignore_value_2(self): def test_ignore_value_2(self):
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
# this is not a combination, because (EV_KEY CODE_3, 2) is ignored # this is not a combination, because (EV_KEY CODE_3, 2) is ignored
push_events( push_events(
"Foo Device 2", "Foo Device 2",
[new_event(EV_ABS, ABS_HAT0X, 1), new_event(EV_KEY, CODE_3, 2)], [new_event(EV_ABS, ABS_HAT0X, 1), new_event(EV_KEY, CODE_3, 2)],
) )
self.create_helper() self.create_helper()
reader.start_reading(groups.find(key="Foo Device 2")) self.reader.set_group(self.groups.find(key="Foo Device 2"))
self.reader.start_recorder()
time.sleep(0.2) time.sleep(0.2)
self.assertEqual(reader.read(), EventCombination((EV_ABS, ABS_HAT0X, 1))) self.reader._read()
self.assertEqual(reader.read(), None) self.assertEqual(
self.assertEqual(len(reader._unreleased), 1) l1.calls[-1].combination, EventCombination((EV_ABS, ABS_HAT0X, 1))
)
def test_reading_ignore_up(self): def test_reading_ignore_up(self):
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
push_events( push_events(
"Foo Device 2", "Foo Device 2",
[ [
@ -433,32 +423,18 @@ class TestReader(unittest.TestCase):
], ],
) )
self.create_helper() self.create_helper()
reader.start_reading(groups.find(key="Foo Device 2")) self.reader.set_group(self.groups.find(key="Foo Device 2"))
self.reader.start_recorder()
time.sleep(0.1) time.sleep(0.1)
self.assertEqual(reader.read(), EventCombination((EV_KEY, CODE_2, 1))) self.reader._read()
self.assertEqual(reader.read(), None) self.assertEqual(
self.assertEqual(len(reader._unreleased), 1) l1.calls[-1].combination, EventCombination((EV_KEY, CODE_2, 1))
)
def test_reading_ignore_duplicate_down(self):
send_event_to_reader(new_event(EV_ABS, ABS_Z, 1, 10))
self.assertEqual(reader.read(), EventCombination((EV_ABS, ABS_Z, 1)))
self.assertEqual(reader.read(), None)
# duplicate
send_event_to_reader(new_event(EV_ABS, ABS_Z, 1, 10))
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 1)
self.assertEqual(len(reader.get_unreleased_keys()), 1)
self.assertIsInstance(reader.get_unreleased_keys(), EventCombination)
# release
send_event_to_reader(new_event(EV_ABS, ABS_Z, 0, 10))
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 0)
self.assertIsNone(reader.get_unreleased_keys())
def test_wrong_device(self): def test_wrong_device(self):
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
push_events( push_events(
"Foo Device 2", "Foo Device 2",
[ [
@ -468,16 +444,19 @@ class TestReader(unittest.TestCase):
], ],
) )
self.create_helper() self.create_helper()
reader.start_reading(groups.find(name="Bar Device")) self.reader.set_group(self.groups.find(name="Bar Device"))
self.reader.start_recorder()
time.sleep(EVENT_READ_TIMEOUT * 5) time.sleep(EVENT_READ_TIMEOUT * 5)
self.assertEqual(reader.read(), None) self.reader._read()
self.assertEqual(len(reader._unreleased), 0) self.assertEqual(len(l1.calls), 0)
def test_inputremapper_devices(self): def test_inputremapper_devices(self):
# Don't read from inputremapper devices, their keycodes are not # Don't read from inputremapper devices, their keycodes are not
# representative for the original key. As long as this is not # representative for the original key. As long as this is not
# intentionally programmed it won't even do that. But it was at some # intentionally programmed it won't even do that. But it was at some
# point. # point.
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
push_events( push_events(
"input-remapper Bar Device", "input-remapper Bar Device",
[ [
@ -487,106 +466,105 @@ class TestReader(unittest.TestCase):
], ],
) )
self.create_helper() self.create_helper()
reader.start_reading(groups.find(name="Bar Device")) self.reader.set_group(self.groups.find(name="Bar Device"))
self.reader.start_recorder()
time.sleep(EVENT_READ_TIMEOUT * 5) time.sleep(EVENT_READ_TIMEOUT * 5)
self.assertEqual(reader.read(), None) self.reader._read()
self.assertEqual(len(reader._unreleased), 0) self.assertEqual(len(l1.calls), 0)
def test_clear(self):
push_events(
"Foo Device 2",
[
new_event(EV_KEY, CODE_1, 1),
new_event(EV_KEY, CODE_2, 1),
new_event(EV_KEY, CODE_3, 1),
]
* 15,
)
self.create_helper()
reader.start_reading(groups.find(key="Foo Device 2"))
time.sleep(START_READING_DELAY + EVENT_READ_TIMEOUT * 3)
reader.read()
self.assertEqual(len(reader._unreleased), 3)
self.assertIsNotNone(reader.previous_event)
self.assertIsNotNone(reader.previous_result)
# make the helper send more events to the reader
time.sleep(EVENT_READ_TIMEOUT * 2)
self.assertTrue(reader._results.poll())
reader.clear()
self.assertFalse(reader._results.poll())
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 0)
self.assertIsNone(reader.get_unreleased_keys())
self.assertIsNone(reader.previous_event)
self.assertIsNone(reader.previous_result)
self.tearDown()
def test_switch_device(self):
push_events("Bar Device", [new_event(EV_KEY, CODE_1, 1)])
push_events("Foo Device 2", [new_event(EV_KEY, CODE_3, 1)])
self.create_helper()
reader.start_reading(groups.find(name="Bar Device"))
self.assertFalse(reader._results.poll())
self.assertEqual(reader.group.name, "Bar Device")
time.sleep(EVENT_READ_TIMEOUT * 5)
self.assertTrue(reader._results.poll())
reader.start_reading(groups.find(key="Foo Device 2"))
self.assertEqual(reader.group.name, "Foo Device")
self.assertFalse(reader._results.poll()) # pipe resets
time.sleep(EVENT_READ_TIMEOUT * 5)
self.assertTrue(reader._results.poll())
self.assertEqual(reader.read(), EventCombination((EV_KEY, CODE_3, 1)))
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 1)
def test_terminate(self): def test_terminate(self):
self.create_helper() self.create_helper()
reader.start_reading(groups.find(key="Foo Device 2")) self.reader.set_group(self.groups.find(key="Foo Device 2"))
push_events("Foo Device 2", [new_event(EV_KEY, CODE_3, 1)]) push_events("Foo Device 2", [new_event(EV_KEY, CODE_3, 1)])
time.sleep(START_READING_DELAY + EVENT_READ_TIMEOUT) time.sleep(START_READING_DELAY + EVENT_READ_TIMEOUT)
self.assertTrue(reader._results.poll()) self.assertTrue(self.reader._results.poll())
reader.terminate() self.reader.terminate()
reader.clear()
time.sleep(EVENT_READ_TIMEOUT) time.sleep(EVENT_READ_TIMEOUT)
self.assertFalse(self.reader._results.poll())
# no new events arrive after terminating # no new events arrive after terminating
push_events("Foo Device 2", [new_event(EV_KEY, CODE_3, 1)]) push_events("Foo Device 2", [new_event(EV_KEY, CODE_3, 1)])
time.sleep(EVENT_READ_TIMEOUT * 3) time.sleep(EVENT_READ_TIMEOUT * 3)
self.assertFalse(reader._results.poll()) self.assertFalse(self.reader._results.poll())
def test_are_new_groups_available(self): def test_are_new_groups_available(self):
l1 = Listener()
self.message_broker.subscribe(MessageType.groups, l1)
self.create_helper() self.create_helper()
groups.set_groups({}) self.reader.groups.set_groups({})
time.sleep(0.1) # let the helper send the groups
# read stuff from the helper, which includes the devices # read stuff from the helper, which includes the devices
self.assertFalse(reader.are_new_groups_available()) self.assertEqual("[]", self.reader.groups.dumps())
reader.read() self.reader._read()
self.assertTrue(reader.are_new_groups_available()) self.assertEqual(
# a bit weird, but it assumes the gui handled that and returns self.reader.groups.dumps(),
# false afterwards json.dumps(
self.assertFalse(reader.are_new_groups_available()) [
json.dumps(
# send the same devices again {
reader._get_event({"type": "groups", "message": groups.dumps()}) "paths": [
self.assertFalse(reader.are_new_groups_available()) "/dev/input/event1",
],
# send changed devices "names": ["Foo Device"],
message = groups.dumps() "types": [DeviceType.KEYBOARD],
message = message.replace("Foo Device", "foo_device") "key": "Foo Device",
reader._get_event({"type": "groups", "message": message}) }
self.assertTrue(reader.are_new_groups_available()) ),
self.assertFalse(reader.are_new_groups_available()) json.dumps(
{
"paths": [
"/dev/input/event11",
"/dev/input/event10",
"/dev/input/event13",
"/dev/input/event15",
],
"names": [
"Foo Device foo",
"Foo Device",
"Foo Device",
"Foo Device bar",
],
"types": [
DeviceType.GAMEPAD,
DeviceType.KEYBOARD,
DeviceType.MOUSE,
],
"key": "Foo Device 2",
}
),
json.dumps(
{
"paths": ["/dev/input/event20"],
"names": ["Bar Device"],
"types": [DeviceType.KEYBOARD],
"key": "Bar Device",
}
),
json.dumps(
{
"paths": ["/dev/input/event30"],
"names": ["gamepad"],
"types": [DeviceType.GAMEPAD],
"key": "gamepad",
}
),
json.dumps(
{
"paths": ["/dev/input/event40"],
"names": ["input-remapper Bar Device"],
"types": [DeviceType.KEYBOARD],
"key": "input-remapper Bar Device",
}
),
]
),
)
self.assertEqual(len(l1.calls), 1) # ensure we got the event
if __name__ == "__main__": if __name__ == "__main__":

@ -17,8 +17,7 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>. # along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from inputremapper.gui.message_broker import MessageBroker
from tests.test import ( from tests.test import (
InputDevice, InputDevice,
quick_cleanup, quick_cleanup,
@ -38,8 +37,8 @@ import multiprocessing
import evdev import evdev
from evdev.ecodes import EV_ABS, EV_KEY from evdev.ecodes import EV_ABS, EV_KEY
from inputremapper.groups import groups from inputremapper.groups import groups, _Groups
from inputremapper.gui.reader import reader from inputremapper.gui.reader import Reader
from inputremapper.gui.helper import RootHelper from inputremapper.gui.helper import RootHelper
@ -89,12 +88,15 @@ class TestTest(unittest.TestCase):
Using push_events after the helper is already forked should work, Using push_events after the helper is already forked should work,
as well as using push_event twice as well as using push_event twice
""" """
reader = Reader(MessageBroker(), groups)
def create_helper(): def create_helper():
# this will cause pending events to be copied over to the helper # this will cause pending events to be copied over to the helper
# process # process
def start_helper(): def start_helper():
helper = RootHelper() # there is no point in using the global groups object
# because the helper runs in a different process
helper = RootHelper(_Groups())
helper.run() helper.run()
self.helper = multiprocessing.Process(target=start_helper) self.helper = multiprocessing.Process(target=start_helper)
@ -108,24 +110,27 @@ class TestTest(unittest.TestCase):
if reader._results.poll(): if reader._results.poll():
break break
event = new_event(EV_KEY, 102, 1)
create_helper() create_helper()
reader.start_reading(groups.find(key="Foo Device 2")) reader.set_group(groups.find(key="Foo Device 2"))
time.sleep(START_READING_DELAY) time.sleep(START_READING_DELAY)
event = new_event(EV_KEY, 102, 1)
push_events("Foo Device 2", [event]) push_events("Foo Device 2", [event])
wait_for_results() wait_for_results()
self.assertTrue(reader._results.poll()) self.assertTrue(reader._results.poll())
reader.clear() reader._read()
self.assertFalse(reader._results.poll()) self.assertFalse(reader._results.poll())
# can push more events to the helper that is inside a separate # can push more events to the helper that is inside a separate
# process, which end up being sent to the reader # process, which end up being sent to the reader
event = new_event(EV_KEY, 102, 0)
push_events("Foo Device 2", [event]) push_events("Foo Device 2", [event])
wait_for_results() wait_for_results()
self.assertTrue(reader._results.poll()) self.assertTrue(reader._results.poll())
reader.terminate()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

Loading…
Cancel
Save