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 =
# not used currently due to problems
/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: |
# Install deps as root since we will run tests as root
sudo scripts/ci-install-deps.sh
sudo pip install .
sudo pip install --no-binary :all: .
- name: Run tests
run: |
# FIXME: Had some permissions issues, currently worked around by running tests as root

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

@ -18,13 +18,15 @@
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Starts the user interface."""
from __future__ import annotations
import os
import sys
import atexit
import logging
from argparse import ArgumentParser
from inputremapper.gui.gettext import _, LOCALE_DIR
import gi
@ -38,7 +40,24 @@ from gi.repository import Gtk
Gtk.init()
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__':
@ -55,21 +74,42 @@ if __name__ == '__main__':
logger.debug('Using locale directory: {}'.format(LOCALE_DIR))
# 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.daemon import Daemon
from inputremapper.configs.global_config import global_config
from inputremapper.gui.controller import Controller
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()
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():
if isinstance(user_interface.dbus, Daemon):
if isinstance(daemon, Daemon):
# 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)

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

File diff suppressed because it is too large Load Diff

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

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

@ -19,13 +19,13 @@
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Store which presets should be enabled for which device on login."""
import os
import json
import copy
import json
import os
from inputremapper.configs.base_config import ConfigBase, INITIAL_CONFIG
from inputremapper.configs.paths import CONFIG_PATH, USER, touch
from inputremapper.logger import logger
from inputremapper.configs.base_config import ConfigBase, INITIAL_CONFIG
MOUSE = "mouse"
WHEEL = "wheel"
@ -45,6 +45,10 @@ class GlobalConfig(ConfigBase):
self.path = os.path.join(CONFIG_PATH, "config.json")
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):
"""Set a preset to be automatically applied on start.
Parameters

@ -18,45 +18,62 @@
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import enum
from typing import Optional, Callable, Tuple, Dict, Any, TypeVar
import pkg_resources
from evdev.ecodes import EV_KEY, EV_ABS, EV_REL
from pydantic import (
BaseModel,
PositiveInt,
confloat,
conint,
root_validator,
validator,
ValidationError,
PositiveFloat,
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.event_combination import EventCombination
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.input_event import EventActions
# TODO: remove pydantic VERSION check as soon as we no longer support
# Ubuntu 20.04 and with it the ainchant pydantic 1.2
needs_workaround = pkg_resources.parse_version(
str(VERSION)
) < pkg_resources.parse_version("1.7.1")
# TODO: in python 3.11 inherit enum.StrEnum
class KnownUinput(str, enum.Enum):
keyboard = "keyboard"
mouse = "mouse"
gamepad = "gamepad"
keyboard_mouse = "keyboard + mouse"
CombinationChangedCallback = Optional[
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):
@ -75,12 +92,14 @@ class Mapping(BaseModel):
output_type: Optional[int] = None # The event type of the mapped event
output_code: Optional[int] = None # The event code of the mapped event
name: Optional[str] = None
# if release events will be sent to the forwarded device as soon as a combination
# triggers see also #229
release_combination_keys: bool = True
# macro settings
macro_key_sleep_ms: PositiveInt = 0
macro_key_sleep_ms: conint(ge=0) = 0 # type: ignore
# Optional attributes for mapping Axis to Axis
# The deadzone of the input axis
@ -99,6 +118,13 @@ class Mapping(BaseModel):
rel_input_cutoff: PositiveInt = 100
# the time until a relative axis is considered stationary if no new events arrive
release_timeout: PositiveFloat = 0.05
# don't release immediately when a relative axis drops below the speed threshold
# instead wait until it dropped for loger than release_timeout below the threshold
force_release_timeout: bool = False
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
if not needs_workaround:
@ -142,8 +168,9 @@ class Mapping(BaseModel):
if needs_workaround:
# https://github.com/samuelcolvin/pydantic/issues/1383
def copy(self, *args, **kwargs) -> Mapping:
copy = super(Mapping, self).copy(*args, deep=True, **kwargs)
def copy(self: MappingModel, *args, **kwargs) -> MappingModel:
kwargs["deep"] = True
copy = super(Mapping, self).copy(*args, **kwargs)
object.__setattr__(copy, "_combination_changed", self._combination_changed)
return copy
@ -218,11 +245,11 @@ class Mapping(BaseModel):
@validator("event_combination")
def set_event_actions(cls, combination):
"""Sets the correct action for each event."""
"""Sets the correct actions for each event."""
new_combination = []
for event in combination:
if event.value != 0:
event = event.modify(action=EventActions.as_key)
event = event.modify(actions=(EventActions.as_key,))
new_combination.append(event)
return EventCombination(new_combination)
@ -267,7 +294,7 @@ class Mapping(BaseModel):
@root_validator
def output_axis_given(cls, values):
"""Validate that an output type is an axis if we have an input axis."""
combination = values.get("event_combination")
combination: EventCombination = values.get("event_combination")
output_type = values.get("output_type")
event_values = [event.value for event in combination]
if 0 not in event_values:
@ -275,18 +302,14 @@ class Mapping(BaseModel):
if output_type not in (EV_ABS, EV_REL):
raise ValueError(
f"missing output axis: "
f"the {combination = } specifies a input axis, "
f"but the {output_type = } is not an axis "
)
return values
class Config:
validate_assignment = True
use_enum_values = True
underscore_attrs_are_private = True
json_encoders = {EventCombination: lambda v: v.json_str()}
Config = Cfg
class UIMapping(Mapping):
@ -308,12 +331,11 @@ class UIMapping(Mapping):
def __init__(self, **data): # type: ignore
object.__setattr__(self, "_last_error", None)
super().__init__(
event_combination="99,99,99",
event_combination=EventCombination.empty_combination(),
target_uinput="keyboard",
output_symbol="KEY_A",
)
cache = {
"event_combination": None,
"target_uinput": None,
"output_symbol": None,
}
@ -330,6 +352,7 @@ class UIMapping(Mapping):
super(UIMapping, self).__setattr__(key, value)
if key in self._cache:
del self._cache[key]
self._last_error = None
except ValidationError as error:
# cache the value
@ -369,10 +392,23 @@ class UIMapping(Mapping):
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]:
"""The validation error or None."""
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:
"""Try to validate the mapping."""
if self.is_valid():
@ -397,3 +433,18 @@ class UIMapping(Mapping):
self._cache["event_combination"] = EventCombination.validate(
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"""
import copy
import json
import os
import re
import json
import copy
import shutil
import pkg_resources
from typing import List
from pathlib import Path
from typing import Iterator, Tuple, Dict
import pkg_resources
from evdev.ecodes import (
EV_KEY,
EV_ABS,
@ -44,24 +44,23 @@ from evdev.ecodes import (
REL_HWHEEL_HI_RES,
)
from inputremapper.configs.preset import Preset
from inputremapper.configs.mapping import Mapping, UIMapping
from inputremapper.event_combination import EventCombination
from inputremapper.logger import logger, VERSION, IS_BETA
from inputremapper.user import HOME
from inputremapper.configs.paths import get_preset_path, mkdir, CONFIG_PATH, remove
from inputremapper.configs.preset import Preset
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.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."""
if not os.path.exists(get_preset_path()):
return []
return
preset_path = Path(get_preset_path())
presets = []
for folder in preset_path.iterdir():
if not folder.is_dir():
continue
@ -78,8 +77,6 @@ def all_presets() -> List[os.PathLike]:
logger.warning('Invalid json format in preset "%s"', preset)
continue
return presets
def config_version():
"""Get the version string in config.json as packaging.Version object."""

@ -25,6 +25,7 @@
import os
import shutil
from typing import List, Union
from inputremapper.logger import logger, VERSION, IS_BETA
from inputremapper.user import USER, HOME
@ -44,9 +45,9 @@ def chown(path):
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."""
if path.endswith("/"):
if str(path).endswith("/"):
raise ValueError(f"Expected path to not end with a slash: {path}")
if os.path.exists(path):
@ -81,6 +82,23 @@ def mkdir(path, log=True):
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):
"""Remove whatever is at the 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.
# if a .json extension arrives this place, it has not been
# stripped away properly prior to this.
assert not preset.endswith(".json")
preset = f"{preset}.json"
if not preset.endswith(".json"):
preset = f"{preset}.json"
if preset is None:
return os.path.join(presets_base, group_name)

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

@ -19,13 +19,15 @@
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Make the systems/environments mapping of keys and codes accessible."""
import re
import json
import re
import subprocess
from typing import Optional, List, Iterable
import evdev
from inputremapper.logger import logger
from inputremapper.configs.paths import get_config_path, touch
from inputremapper.logger import logger
from inputremapper.utils import is_service
DISABLE_NAME = "disable"
@ -64,7 +66,7 @@ class SystemMapping:
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.
Parameters

@ -25,14 +25,16 @@ https://github.com/LEW21/pydbus/tree/cc407c8b1d25b7e28a6d661a29f9e661b1c9b964/ex
"""
import atexit
import json
import os
import sys
import json
import time
import atexit
from pathlib import PurePath
from typing import Protocol, Dict
from pydbus import SystemBus
import gi
from pydbus import SystemBus
gi.require_version("GLib", "2.0")
from gi.repository import GLib
@ -116,6 +118,34 @@ def remove_timeout(func):
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:
"""Starts injecting keycodes based on the configuration.
@ -164,7 +194,7 @@ class Daemon:
def __init__(self):
"""Constructs the daemon."""
logger.debug("Creating daemon")
self.injectors = {}
self.injectors: Dict[str, Injector] = {}
self.config_dir = None
@ -184,7 +214,7 @@ class Daemon:
macro_variables.start()
@classmethod
def connect(cls, fallback=True):
def connect(cls, fallback=True) -> DaemonProxy:
"""Get an interface to start and stop injecting keystrokes.
Parameters
@ -193,8 +223,8 @@ class Daemon:
If true, returns an instance of the daemon instead if it cannot
connect
"""
bus = SystemBus()
try:
bus = SystemBus()
interface = bus.get(BUS_NAME, timeout=BUS_TIMEOUT)
logger.info("Connected to the service")
except GLib.GError as error:
@ -306,7 +336,7 @@ class Daemon:
This path contains config.json, xmodmap.json and the
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):
logger.error('"%s" does not exist', config_path)
return
@ -405,7 +435,7 @@ class Daemon:
for group_key, _ in autoload_presets:
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.
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)
return False
preset_path = os.path.join(
preset_path = PurePath(
self.config_dir,
"presets",
group.name,
@ -453,6 +483,13 @@ class Daemon:
# date when the system layout changes.
xmodmap = json.load(file)
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)
# the service now has process wide knowledge of xmodmap
# keys of the users session

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

@ -57,3 +57,8 @@ class MappingParsingError(Error):
class InputEventCreationError(Error):
def __init__(self, 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
and the injector.
"""
import re
import multiprocessing
import threading
from __future__ import annotations
import asyncio
import enum
import json
from typing import List
import multiprocessing
import os
import re
import threading
from typing import List, Optional
import evdev
from evdev.ecodes import (
@ -53,9 +54,8 @@ from evdev.ecodes import (
REL_WHEEL,
)
from inputremapper.logger import logger
from inputremapper.configs.paths import get_preset_path
from inputremapper.logger import logger
TABLET_KEYS = [
evdev.ecodes.BTN_STYLUS,
@ -64,13 +64,15 @@ TABLET_KEYS = [
evdev.ecodes.BTN_TOOL_RUBBER,
]
GAMEPAD = "gamepad"
KEYBOARD = "keyboard"
MOUSE = "mouse"
TOUCHPAD = "touchpad"
GRAPHICS_TABLET = "graphics-tablet"
CAMERA = "camera"
UNKNOWN = "unknown"
class DeviceType(str, enum.Enum):
GAMEPAD = "gamepad"
KEYBOARD = "keyboard"
MOUSE = "mouse"
TOUCHPAD = "touchpad"
GRAPHICS_TABLET = "graphics-tablet"
CAMERA = "camera"
UNKNOWN = "unknown"
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
def classify(device):
def classify(device) -> DeviceType:
"""Figure out what kind of device this is.
Use this instead of functions like _is_keyboard to avoid getting false
@ -167,26 +169,26 @@ def classify(device):
if _is_graphics_tablet(capabilities):
# check this before is_gamepad to avoid classifying abs_x
# as joysticks when they are actually stylus positions
return GRAPHICS_TABLET
return DeviceType.GRAPHICS_TABLET
if _is_touchpad(capabilities):
return TOUCHPAD
return DeviceType.TOUCHPAD
if _is_gamepad(capabilities):
return GAMEPAD
return DeviceType.GAMEPAD
if _is_mouse(capabilities):
return MOUSE
return DeviceType.MOUSE
if _is_camera(capabilities):
return CAMERA
return DeviceType.CAMERA
if _is_keyboard(capabilities):
# very low in the chain to avoid classifying most devices
# 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"]
@ -253,7 +255,13 @@ class _Group:
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
Parameters
@ -262,7 +270,7 @@ class _Group:
Paths in /dev/input of the grouped devices
names : str[]
Names of the grouped devices
types : str[]
types : list[DeviceType]
Types of the grouped devices
key : str
Unique identifier of the group.
@ -283,7 +291,7 @@ class _Group:
self.paths = paths
self.names = names
self.types = types
self.types = [DeviceType(type_) for type_ in types]
def get_preset_path(self, preset=None):
"""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)
if device_type == CAMERA:
if device_type == DeviceType.CAMERA:
continue
# 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)
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
continue
@ -405,14 +413,17 @@ class _FindGroups(threading.Thread):
key=key,
paths=devs,
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())
self.pipe.send(json.dumps(result))
loop.close() # avoid resource allocation warnings
# 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
@ -429,7 +440,7 @@ class _Groups:
need it the information.
"""
if key == "_groups" and object.__getattribute__(self, "_groups") is None:
object.__setattr__(self, "_groups", {})
object.__setattr__(self, "_groups", [])
object.__getattribute__(self, "refresh")()
return object.__getattribute__(self, key)
@ -452,7 +463,7 @@ class _Groups:
keys = [f'"{group.key}"' for group in self._groups]
logger.info("Found %s", ", ".join(keys))
def filter(self, include_inputremapper=False):
def filter(self, include_inputremapper=False) -> List[_Group]:
"""Filter groups."""
result = []
for group in self._groups:
@ -466,6 +477,7 @@ class _Groups:
def set_groups(self, new_groups):
"""Overwrite all groups."""
logger.debug("overwriting groups with %s", new_groups)
self._groups = new_groups
def list_group_names(self) -> List[str]:
@ -496,7 +508,7 @@ class _Groups:
key: str = None,
path: str = None,
include_inputremapper: bool = False,
) -> _Group:
) -> Optional[_Group]:
"""Find a group that matches the provided 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
from typing import Dict, Optional, List, Tuple
from gi.repository import Gdk, Gtk, GLib, GObject
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.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 (
FUNCTIONS,
get_macro_argument_names,
remove_comments,
)
from inputremapper.injection.global_uinputs import global_uinputs
from inputremapper.logger import logger
from inputremapper.gui.utils import debounce
# no deprecated shorthand function-names
FUNCTION_NAMES = [name for name in FUNCTIONS.keys() if len(name) > 1]
# no deprecated functions
FUNCTION_NAMES.remove("ifeq")
Capabilities = Dict[int, List]
def _get_left_text(iter):
buffer = iter.get_buffer()
result = buffer.get_text(buffer.get_start_iter(), iter, True)
def _get_left_text(iter_: Gtk.TextIter) -> str:
buffer = iter_.get_buffer()
result = buffer.get_text(buffer.get_start_iter(), iter_, True)
result = remove_comments(result)
result = result.replace("\n", " ")
return result.lower()
@ -57,9 +61,9 @@ PARAMETER = r".*?[(,=+]\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."""
left_text = _get_left_text(iter)
left_text = _get_left_text(iter_)
# match foo in:
# bar().foo
@ -77,9 +81,9 @@ def get_incomplete_function_name(iter):
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."""
left_text = _get_left_text(iter)
left_text = _get_left_text(iter_)
# match foo in:
# bar(foo
@ -96,7 +100,7 @@ def get_incomplete_parameter(iter):
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."""
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."""
incomplete_name = get_incomplete_function_name(text_iter)
@ -146,7 +150,7 @@ class Autocompletion(Gtk.Popover):
__gtype_name__ = "Autocompletion"
def __init__(self, text_input, target_selector):
def __init__(self, message_broker: MessageBroker, code_editor: CodeEditor):
"""Create an autocompletion popover.
It will remain hidden until there is something to autocomplete.
@ -164,10 +168,10 @@ class Autocompletion(Gtk.Popover):
constrain_to=Gtk.PopoverConstraint.NONE,
)
self.text_input = text_input
self.target_selector = target_selector
self._target_key_capabilities = []
target_selector.connect("changed", self._update_target_key_capabilities)
self.code_editor = code_editor
self.message_broker = message_broker
self._uinputs: Optional[Dict[str, Capabilities]] = None
self._target_key_capabilities: List[int] = []
self.scrolled_window = Gtk.ScrolledWindow(
min_content_width=200,
@ -192,22 +196,27 @@ class Autocompletion(Gtk.Popover):
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
# 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.visible = False
self.attach_to_events()
self.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."""
GLib.timeout_add(100, self.popdown)
# "(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):
"""Get Gtk.TextIter at the current text cursor location."""
cursor = self.text_input.get_cursor_locations()[0]
return self.text_input.get_iter_at_location(cursor.x, cursor.y)[1]
cursor = self.code_editor.gui.get_cursor_locations()[0]
return self.code_editor.gui.get_iter_at_location(cursor.x, cursor.y)[1]
def popup(self):
self.visible = True
@ -314,24 +323,24 @@ class Autocompletion(Gtk.Popover):
@debounce(100)
def update(self, *_):
"""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()
return
self.list_box.forall(self.list_box.remove)
# 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
# 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
)
cursor.x = window_coords.window_x
cursor.y = window_coords.window_y
cursor.y += 12
if self.text_input.get_show_line_numbers():
if self.code_editor.gui.get_show_line_numbers():
cursor.x += 25
self.set_pointing_to(cursor)
@ -352,17 +361,19 @@ class Autocompletion(Gtk.Popover):
self.list_box.insert(label, -1)
label.show_all()
def _update_target_key_capabilities(self, *_):
target = self.target_selector.get_active_id()
self._target_key_capabilities = global_uinputs.get_uinput(
target
).capabilities()[EV_KEY]
def _on_mapping_loaded(self, mapping: MappingData):
if mapping and self._uinputs:
target = mapping.target_uinput or "keyboard"
self._target_key_capabilities = self._uinputs[target][EV_KEY]
def _on_uinputs_changed(self, data: UInputsData):
self._uinputs = data.uinputs
def _on_suggestion_clicked(self, _, selected_row):
"""An autocompletion suggestion was selected and should be inserted."""
selected_label = selected_row.get_children()[0]
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
# remove whatever there is
@ -371,7 +382,7 @@ class Autocompletion(Gtk.Popover):
match = re.match(r"^(\w+)", right)
right = match[1] if match else ""
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
@ -380,11 +391,11 @@ class Autocompletion(Gtk.Popover):
match = re.match(r".*?(\w+)$", re.sub("\n", " ", left))
left = match[1] if match else ""
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
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")

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
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import os.path
import gettext
import locale
import os.path
from inputremapper.configs.data import get_data_path
from argparse import ArgumentParser
APP_NAME = "input-remapper"
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,
whereas for the helper to start a password is needed and it stops when the ui
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 select
import asyncio
import multiprocessing
import subprocess
import time
import sys
from collections import defaultdict
from typing import Set, List
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.logger import logger
from inputremapper.groups import groups
from inputremapper import utils
from inputremapper.user import USER
# received by the helper
CMD_TERMINATE = "terminate"
CMD_REFRESH_GROUPS = "refresh_groups"
@ -76,160 +85,226 @@ class RootHelper:
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."""
self.groups = groups
self._results = Pipe(f"/tmp/input-remapper-{USER}/results")
self._commands = Pipe(f"/tmp/input-remapper-{USER}/commands")
self._send_groups()
self.group = None
self._pipe = multiprocessing.Pipe()
self._tasks: Set[asyncio.Task] = set()
self._stop_event = asyncio.Event()
def run(self):
"""Start doing stuff. Blocks."""
logger.debug("Waiting for the first command")
# the reader will check for new commands later, once it is running
# it keeps running for one device or another.
select.select([self._commands], [], [])
# possibly an alternative to select:
"""while True:
if self._commands.poll():
break
time.sleep(0.1)"""
logger.debug("Starting mainloop")
while True:
self._read_commands()
self._start_reading()
loop = asyncio.get_event_loop()
logger.debug("Discovering initial groups")
self.groups.refresh()
self._send_groups()
logger.debug("Waiting commands")
loop.run_until_complete(self._read_commands())
logger.debug("Helper terminates")
sys.exit(0)
def _send_groups(self):
"""Send the groups to the gui."""
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):
"""Handle all unread commands."""
while self._commands.poll():
cmd = self._commands.recv()
async def _read_commands(self):
"""Handle all unread commands.
this will run until it receives CMD_TERMINATE
"""
async for cmd in self._commands:
logger.debug('Received command "%s"', cmd)
if cmd == CMD_TERMINATE:
logger.debug("Helper terminates")
sys.exit(0)
await self._stop_reading()
return
if cmd == CMD_REFRESH_GROUPS:
groups.refresh()
self.groups.refresh()
self._send_groups()
continue
group = groups.find(key=cmd)
group = self.groups.find(key=cmd)
if group is None:
groups.refresh()
group = groups.find(key=cmd)
# this will block for a bit maybe we want to do this async?
self.groups.refresh()
group = self.groups.find(key=cmd)
if group is not None:
self.group = group
await self._stop_reading()
self._start_reading(group)
continue
logger.error('Received unknown command "%s"', cmd)
logger.debug("No more commands in pipe")
def _start_reading(self):
"""Tell the evdev lib to start looking for keycodes.
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:
def _start_reading(self, group: _Group):
"""find all devices of that group, filter interesting ones and send the events
to the gui"""
sources = []
for path in group.paths:
try:
device = evdev.InputDevice(path)
except FileNotFoundError:
continue
if evdev.ecodes.EV_KEY in device.capabilities():
virtual_devices.append(device)
if len(virtual_devices) == 0:
logger.debug('No interesting device for "%s"', self.group.key)
return
for device in virtual_devices:
rlist[device.fd] = device
logger.debug(
'Starting reading keycodes from "%s"',
'", "'.join([device.name for device in virtual_devices]),
)
rlist[self._commands] = self._commands
while True:
ready_fds = select.select(rlist, [], [])
if len(ready_fds[0]) == 0:
# happens with sockets sometimes. Sockets are not stable and
# not used, so nothing to worry about now.
continue
for fd in ready_fds[0]:
if rlist[fd] == self._commands:
# all commands will cause the reader to start over
# (possibly for a different device).
# _read_commands will check what is going on
logger.debug("Stops reading due to new command")
return
device = rlist[fd]
try:
event = device.read_one()
if event:
self._send_event(event, device)
except OSError:
logger.debug('Device "%s" disappeared', device.path)
return
def _send_event(self, event, device):
"""Write the event into the pipe to the main process.
Parameters
----------
event : evdev.InputEvent
device : evdev.InputDevice
"""
# value: 1 for down, 0 for up, 2 for hold.
if event.type == EV_KEY and event.value == 2:
# ignore hold-down events
return
blacklisted_keys = [evdev.ecodes.BTN_TOOL_DOUBLETAP]
if event.type == EV_KEY and event.code in blacklisted_keys:
return
if event.type == EV_ABS:
abs_range = utils.get_abs_range(device, event.code)
event.value = utils.classify_action(event, abs_range)
else:
event.value = utils.classify_action(event)
self._results.send(
{
"type": MSG_EVENT,
"message": (event.sec, event.usec, event.type, event.code, event.value),
}
)
except (FileNotFoundError, OSError):
logger.error('Could not find "%s"', path)
return None
capabilities = device.capabilities(absinfo=False)
if (
EV_KEY in capabilities
or EV_ABS in capabilities
or EV_REL in capabilities
):
sources.append(device)
context = self._create_event_pipeline(sources)
# create the event reader and start it
for device in sources:
reader = EventReader(context, device, ForwardDummy, self._stop_event)
self._tasks.add(asyncio.create_task(reader.run()))
async def _stop_reading(self):
"""stop the running event_reader"""
self._stop_event.set()
if self._tasks:
await asyncio.gather(*self._tasks)
self._tasks = set()
self._stop_event.clear()
def _create_event_pipeline(self, sources: List[evdev.InputDevice]) -> ContextDummy:
"""create a custom event pipeline for each event code in the
device capabilities.
Instead of sending the events to a uinput they will be sent to the frontend"""
context = ContextDummy()
# create a context for each source
for device in sources:
capabilities = device.capabilities(absinfo=False)
for ev_code in capabilities.get(EV_KEY) or ():
context.notify_callbacks[(EV_KEY, ev_code)].append(
ForwardToUIHandler(self._results).notify
)
for ev_code in capabilities.get(EV_ABS) or ():
# positive direction
mapping = UIMapping(
event_combination=EventCombination((EV_ABS, ev_code, 30)),
target_uinput="keyboard",
)
handler: MappingHandler = AbsToBtnHandler(
EventCombination((EV_ABS, ev_code, 30)), mapping
)
handler.set_sub_handler(ForwardToUIHandler(self._results))
context.notify_callbacks[(EV_ABS, ev_code)].append(handler.notify)
# negative direction
mapping = UIMapping(
event_combination=EventCombination((EV_ABS, ev_code, -30)),
target_uinput="keyboard",
)
handler = AbsToBtnHandler(
EventCombination((EV_ABS, ev_code, -30)), mapping
)
handler.set_sub_handler(ForwardToUIHandler(self._results))
context.notify_callbacks[(EV_ABS, ev_code)].append(handler.notify)
for ev_code in capabilities.get(EV_REL) or ():
# positive 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)
# 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
"""
from typing import Optional, List, Generator, Dict, Tuple, Set
from typing import Optional
from evdev.ecodes import EV_REL
from inputremapper.input_event import InputEvent
import evdev
from gi.repository import GLib
from inputremapper.logger import logger
from inputremapper.event_combination import EventCombination
from inputremapper.groups import groups, GAMEPAD
from inputremapper.ipc.pipe import Pipe
from inputremapper.groups import _Groups, _Group
from inputremapper.gui.helper import (
MSG_EVENT,
MSG_GROUPS,
CMD_TERMINATE,
CMD_REFRESH_GROUPS,
)
from inputremapper import utils
from inputremapper.gui.active_preset import active_preset
from inputremapper.gui.message_broker import (
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
DEBOUNCE_TICKS = 3
def will_report_up(ev_type):
"""Check if this event will ever report a key up (wheels)."""
return ev_type != EV_REL
BLACKLISTED_EVENTS = [(1, evdev.ecodes.BTN_TOOL_DOUBLETAP)]
RecordingGenerator = Generator[None, InputEvent, None]
class Reader:
@ -61,192 +61,150 @@ class Reader:
has knowledge of buttons like the middle-mouse button.
"""
def __init__(self):
self.previous_event = None
self.previous_result = None
self._unreleased = {}
self._debounce_remove = {}
self._groups_updated = False
self._cleared_at = 0
self.group = None
def __init__(self, message_broker: MessageBroker, groups: _Groups):
self.groups = groups
self.message_broker = message_broker
self.group: Optional[_Group] = None
self.read_timeout: Optional[int] = None
self._recording_generator: Optional[RecordingGenerator] = None
self._results = None
self._commands = None
self.connect()
self.attach_to_events()
self._read_continuously()
def connect(self):
"""Connect to the helper."""
self._results = Pipe(f"/tmp/input-remapper-{USER}/results")
self._commands = Pipe(f"/tmp/input-remapper-{USER}/commands")
def are_new_groups_available(self):
"""Check if groups contains new devices.
def attach_to_events(self):
"""connect listeners to event_reader"""
self.message_broker.subscribe(MessageType.terminate, lambda _: self.terminate())
The ui should then update its list.
"""
outdated = self._groups_updated
self._groups_updated = False # assume the ui will react accordingly
return outdated
def _read_continuously(self):
"""poll the result pipe in regular intervals"""
self.read_timeout = GLib.timeout_add(30, self._read)
def _get_event(self, message) -> Optional[InputEvent]:
"""Return an InputEvent if the message contains one. None otherwise."""
message_type = message["type"]
message_body = message["message"]
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.
def _read(self):
"""Read the messages from the helper and handle them"""
while self._results.poll():
message = self._results.recv()
On key-down events the pipe returns changed combinations. Release
events won't cause that and the reader will return None as in
"nothing new to report". So In order to change a combination, one
of its keys has to be released and then a different one pressed.
message_type = message["type"]
message_body = message["message"]
if message_type == MSG_GROUPS:
self._update_groups(message_body)
continue
Otherwise making combinations wouldn't be possible. Because at
some point the keys have to be released, and that shouldn't cause
the combination to get trimmed.
if message_type == MSG_EVENT:
if not self._recording_generator:
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
# joystick_to_mouse, but its much simpler because it doesn't
# have to trigger anything, manage any macros and only
# reports key-down events. This function is called periodically
# 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
if self._recording_generator:
self._recording_generator.close()
self._recording_generator = None
self.message_broker.signal(MessageType.recording_finished)
self._debounce_tick()
while self._results.poll():
message = self._results.recv()
event = self._get_event(message)
if event is None:
continue
def _recorder(self) -> RecordingGenerator:
"""Generator which receives InputEvents.
gamepad = GAMEPAD in self.group.types
if not utils.should_map_as_btn(event, active_preset, gamepad):
it accumulates them into EventCombinations and sends those on the message_broker.
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
if event.value == 0:
logger.debug_key(event, "release")
self._release(event.type_and_code)
continue
if self._unreleased.get(event.type_and_code) == event:
logger.debug_key(event, "duplicate key down")
self._debounce_start(event.event_tuple)
try:
active.remove((event.type, event.code))
except KeyError:
# we haven't seen this before probably a key got released which
# was pressed before we started recording. ignore it.
continue
if not active:
# all previously recorded events are released
return
continue
# to keep track of combinations.
# "I have got this release event, what was this for?" A release
# event for a D-Pad axis might be any direction, hence this maps
# from release to input in order to remember it. Since all release
# events have value 0, the value is not used in the combination.
key_down_received = True
logger.debug_key(event, "down")
self._unreleased[event.type_and_code] = event
self._debounce_start(event.event_tuple)
previous_event = event
if not key_down_received:
# This prevents writing a subset of the combination into
# result after keys were released. In order to control the gui,
# they have to be released.
return None
self.previous_event = previous_event
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):
active.add(event.type_and_code)
accu_type_code = [e.type_and_code for e in accumulator]
if event.type_and_code in accu_type_code and event not in accumulator:
# the value has changed but the event is already in the accumulator
# update the event
i = accu_type_code.index(event.type_and_code)
accumulator[i] = event
self.message_broker.send(
CombinationRecorded(EventCombination(accumulator))
)
if event not in accumulator:
accumulator.append(event)
self.message_broker.send(
CombinationRecorded(EventCombination(accumulator))
)
def set_group(self, group):
"""Start reading keycodes for a device."""
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.group = group
self.clear()
def terminate(self):
"""Stop reading keycodes for good."""
logger.debug("Sending close msg to helper")
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):
"""Ask the helper for new device groups."""
self._commands.send(CMD_REFRESH_GROUPS)
def clear(self):
"""Next time when reading don't return the previous keycode."""
logger.debug("Clearing reader")
while self._results.poll():
# clear the results pipe and handle any non-event messages,
# otherwise a 'groups' message might get lost
message = self._results.recv()
self._get_event(message)
self._unreleased = {}
self.previous_event = None
self.previous_result = None
def get_unreleased_keys(self):
"""Get a EventCombination object of the current keyboard state."""
unreleased = list(self._unreleased.values())
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()
def send_groups(self):
"""announce all known groups"""
groups: Dict[str, List[str]] = {
group.key: group.types or []
for group in self.groups.filter(include_inputremapper=False)
}
self.message_broker.send(GroupsData(groups))
def _update_groups(self, dump):
if dump != self.groups.dumps():
self.groups.loads(dump)
logger.debug("Received %d devices", len(self.groups))
self._groups_updated = True
# send this even if the groups did not change, as the user expects the ui
# to respond in some form
self.send_groups()

@ -20,59 +20,42 @@
"""User Interface."""
from typing import Dict, Callable
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 gi.repository import Gtk, GtkSource, Gdk, GObject
from inputremapper.configs.data import get_data_path
from inputremapper.exceptions import MacroParsingError
from inputremapper.configs.paths import get_config_path, get_preset_path
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.active_preset import active_preset
from inputremapper.gui.utils import HandlerDisabled
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.configs.mapping import MappingData
from inputremapper.event_combination import EventCombination
from inputremapper.gui.reader import reader
from inputremapper.gui.helper import is_helper_running
from inputremapper.injection.injector import RUNNING, FAILED, NO_GRAB, UPGRADE_EVDEV
from inputremapper.daemon import Daemon
from inputremapper.configs.global_config import global_config
from inputremapper.injection.macros.parse import is_this_a_macro, parse
from inputremapper.injection.global_uinputs import global_uinputs
from inputremapper.gui.autocompletion import Autocompletion
from inputremapper.gui.components import (
DeviceSelection,
PresetSelection,
MappingListBox,
TargetSelection,
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 (
CTX_ERROR,
CTX_MAPPING,
CTX_APPLY,
CTX_WARNING,
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
# 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
CONTINUE = True
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):
def on_close_about(about, _):
"""Hide the about dialog without destroying it."""
about.hide()
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:
"""The input-remapper gtk window."""
def __init__(self):
self.dbus = None
self.start_processes()
def __init__(
self,
message_broker: MessageBroker,
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
self.preset_name = None
# now show the proper finished content of the window
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()
with open(get_data_path("style.css"), "r") as file:
css_provider.load_from_data(bytes(file.read(), encoding="UTF-8"))
@ -162,34 +129,68 @@ class UserInterface:
)
gladefile = get_data_path("input-remapper.glade")
builder = Gtk.Builder()
builder.add_from_file(gladefile)
builder.connect_signals(self)
self.builder = builder
self.editor = Editor(self)
# set up the device selection
# https://python-gtk-3-tutorial.readthedocs.io/en/latest/treeview.html#the-view
combobox: Gtk.ComboBox = self.get("device_selection")
self.device_store = Gtk.ListStore(str, str, str)
combobox.set_model(self.device_store)
renderer_icon = Gtk.CellRendererPixbuf()
renderer_text = Gtk.CellRendererText()
renderer_text.set_padding(5, 0)
combobox.pack_start(renderer_icon, False)
combobox.pack_start(renderer_text, False)
combobox.add_attribute(renderer_icon, "icon-name", 1)
combobox.add_attribute(renderer_text, "text", 2)
combobox.set_id_column(0)
self.confirm_delete = builder.get_object("confirm-delete")
self.about = builder.get_object("about-dialog")
self.builder.add_from_file(gladefile)
self.builder.connect_signals(self)
def _create_components(self):
"""setup all objects which manage individual components of the ui"""
message_broker = self.message_broker
controller = self.controller
DeviceSelection(message_broker, controller, self.get("device_selection"))
PresetSelection(message_broker, controller, self.get("preset_selection"))
MappingListBox(message_broker, controller, self.get("selection_label_listbox"))
TargetSelection(message_broker, controller, self.get("target-selector"))
RecordingToggle(message_broker, controller, self.get("key_recording_toggle"))
StatusBar(
message_broker,
controller,
self.get("status_bar"),
self.get("error_status_icon"),
self.get("warning_status_icon"),
)
AutoloadSwitch(message_broker, controller, self.get("preset_autoload_switch"))
ReleaseCombinationSwitch(
message_broker, controller, self.get("release-combination-switch")
)
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)
# set_position needs to be done once initially, otherwise the
# dialog is not centered when it is opened for the first time
self.about.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
self.get("version-label").set_text(
f"input-remapper {VERSION} {COMMIT_HASH[:7]}"
f"\npython-evdev {EVDEV_VERSION}"
@ -197,537 +198,132 @@ class UserInterface:
else ""
)
window = self.get("window")
window.show()
# hide everything until stuff is populated
self.get("vertical-wrapper").set_opacity(0)
self.window = window
source_view = self.get("code_editor")
source_view.get_buffer().connect("changed", self.check_on_typing)
# 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.populate_devices()
self.timeouts = []
self.setup_timeouts()
# now show the proper finished content of the window
self.get("vertical-wrapper").set_opacity(1)
self.ctrl = False
self.unreleased_warn = False
self.button_left_warn = False
if not is_helper_running():
self.show_status(CTX_ERROR, _("The helper did not start"))
def setup_timeouts(self):
"""Setup all GLib timeouts."""
self.timeouts = [
GLib.timeout_add(1000 / 30, self.consume_newest_keycode),
]
def start_processes(self):
"""Start helper and daemon via pkexec to run in the background."""
# this function is overwritten in tests
self.dbus = Daemon.connect()
debug = " -d" if is_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)
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()
def _connect_gtk_signals(self):
self.get("delete_preset").connect(
"clicked", lambda *_: self.controller.delete_preset()
)
self.get("copy_preset").connect(
"clicked", lambda *_: self.controller.copy_preset()
)
self.get("create_preset").connect(
"clicked", lambda *_: self.controller.add_preset()
)
self.get("apply_preset").connect(
"clicked", lambda *_: self.controller.start_injecting()
)
self.get("apply_system_layout").connect(
"clicked", lambda *_: self.controller.stop_injecting()
)
self.get("rename-button").connect("clicked", self.on_gtk_rename_clicked)
self.get("preset_name_input").connect(
"key-release-event", self.on_gtk_preset_name_input_return
)
self.get("create_mapping_button").connect(
"clicked", lambda *_: self.controller.create_mapping()
)
self.get("delete-mapping").connect(
"clicked", lambda *_: self.controller.delete_mapping()
)
self.combination_editor.connect(
# it only takes self as argument, but delete-events provides more
# probably a gtk bug
"delete-event",
lambda dialog, *_: Gtk.Widget.hide_on_delete(dialog),
)
self.get("edit-combination-btn").connect(
"clicked", lambda *_: self.combination_editor.show()
)
self.get("remove-event-btn").connect(
"clicked", lambda *_: self.controller.remove_event()
)
self.connect_shortcuts()
if gdk_keycode == Gdk.KEY_r:
reader.refresh_groups()
def _connect_message_listener(self):
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:
self.on_stop_injecting_clicked()
def on_injector_state_msg(self, msg: InjectorState):
"""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):
"""To execute shortcuts.
def disconnect_shortcuts(self):
"""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]
if gdk_keycode in [Gdk.KEY_Control_L, Gdk.KEY_Control_R]:
self.ctrl = False
try:
self.window.disconnect(self.gtk_listeners.pop(self.on_gtk_shortcut))
except KeyError:
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):
"""Get a widget from the window"""
return self.builder.get_object(name)
@ensure_everything_saved
def on_close(self, *args):
"""Safely close the application."""
def close(self):
"""Close the window"""
logger.debug("Closing window")
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
logger.info('Applying preset "%s" for "%s"', preset, self.group.key)
if not self.button_left_warn:
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:
def update_combination_label(self, mapping: MappingData):
"""listens for mapping and updates the combination label"""
label: Gtk.Label = self.get("combination-label")
if mapping.event_combination.beautify() == label.get_label():
return
group_key = dropdown.get_active_id()
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")
if mapping.event_combination == EventCombination.empty_combination():
label.set_opacity(0.4)
label.set_label(_("no input configured"))
return
try:
assert self.preset_name is not None
active_preset.save()
label.set_opacity(1)
label.set_label(mapping.event_combination.beautify())
# after saving the preset, its modification date will be the
# newest, so populate_presets will automatically select the
# right one again.
self.populate_presets()
except PermissionError as error:
error = str(error)
self.show_status(CTX_ERROR, _("Permission denied!"), error)
logger.error(error)
def on_gtk_shortcut(self, _, event: Gdk.EventKey):
"""execute shortcuts"""
if event.state & Gdk.ModifierType.CONTROL_MASK:
try:
self.shortcuts[event.keyval]()
except KeyError:
pass
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."""
self.about.show()
def on_about_key_press(self, window, event):
def on_gtk_about_key_press(self, _, event):
"""Hide the about/help dialog."""
gdk_keycode = event.get_keyval()[1]
if gdk_keycode == Gdk.KEY_Escape:
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
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import time
from gi.repository import Gtk, GLib
# status ctx ids
CTX_SAVE = 0
CTX_APPLY = 1
CTX_KEYCODE = 2
@ -78,7 +79,11 @@ class HandlerDisabled:
self.widget.handler_unblock_by_func(self.handler)
def gtk_iteration():
def gtk_iteration(iterations=0):
"""Iterate while events are pending."""
while Gtk.events_pending():
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."""
import asyncio
from typing import Awaitable, List, Dict, Tuple, Protocol, Set
import evdev
from collections import defaultdict
from typing import List, Dict, Tuple, Set
from inputremapper.configs.preset import Preset
from inputremapper.input_event import InputEvent
from inputremapper.injection.mapping_handlers.mapping_parser import parse_mappings
from inputremapper.injection.mapping_handlers.mapping_handler import (
InputEventHandler,
EventListener,
NotifyCallback,
)
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:
...
from inputremapper.injection.mapping_handlers.mapping_parser import parse_mappings
from inputremapper.input_event import InputEvent
class Context:
@ -78,15 +61,13 @@ class Context:
all entry points to the event pipeline sorted by InputEvent.type_and_code
"""
preset: Preset
listeners: Set[EventListener]
callbacks: Dict[Tuple[int, int], List[NotifyCallback]]
_handlers: Dict[InputEvent, List[InputEventHandler]]
notify_callbacks: Dict[Tuple[int, int], List[NotifyCallback]]
_handlers: Dict[InputEvent, Set[InputEventHandler]]
def __init__(self, preset: Preset):
self.preset = preset
self.listeners = set()
self.callbacks = {}
self.notify_callbacks = defaultdict(list)
self._handlers = parse_mappings(preset, self)
self._create_callbacks()
@ -94,12 +75,12 @@ class Context:
def reset(self) -> None:
"""Call the reset method for each handler in the context."""
for handlers in self._handlers.values():
[handler.reset() for handler in handlers]
for handler in handlers:
handler.reset()
def _create_callbacks(self) -> None:
"""Add the notify method from all _handlers to self.callbacks."""
for event, handler_list in self._handlers.items():
if event.type_and_code not in self.callbacks.keys():
self.callbacks[event.type_and_code] = []
for handler in handler_list:
self.callbacks[event.type_and_code].append(handler.notify)
self.notify_callbacks[event.type_and_code].extend(
handler.notify for handler in handler_list
)

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

@ -17,15 +17,14 @@
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Union
import evdev
import inputremapper.utils
import inputremapper.exceptions
import inputremapper.utils
from inputremapper.logger import logger
DEV_NAME = "input-remapper"
DEFAULT_UINPUTS = {
# 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."""
def __init__(self):
self.devices = {}
self.devices: Dict[str, Union[UInput, FrontendUInput]] = {}
self._uinput_factory = None
self.is_service = inputremapper.utils.is_service()

@ -20,26 +20,29 @@
"""Keeps injecting keycodes in the background based on the preset."""
import os
import sys
from __future__ import annotations
import asyncio
import time
import multiprocessing
import sys
import time
from dataclasses import dataclass
from multiprocessing.connection import Connection
from typing import Dict, List, Optional, Tuple
import evdev
from typing import Dict, List, Optional
from inputremapper.configs.preset import Preset
from inputremapper.logger import logger
from inputremapper.groups import classify, GAMEPAD, _Group
from inputremapper.event_combination import EventCombination
from inputremapper.groups import (
_Group,
classify,
DeviceType,
)
from inputremapper.gui.message_broker import MessageType
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.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]]
GroupSources = List[evdev.InputDevice]
@ -81,6 +84,15 @@ def get_udev_name(name: str, suffix: str) -> str:
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):
"""Initializes, starts and stops injections.
@ -93,7 +105,7 @@ class Injector(multiprocessing.Process):
preset: Preset
context: Optional[Context]
_state: int
_msg_pipe: multiprocessing.Pipe
_msg_pipe: Tuple[Connection, Connection]
_consumer_controls: List[EventReader]
_stop_event: asyncio.Event
@ -119,9 +131,8 @@ class Injector(multiprocessing.Process):
self.context = None # only needed inside the injection process
self._consumer_controls = []
self._stop_event = None
super().__init__(name=group)
super().__init__(name=group.key)
"""Functions to interact with the running process"""
@ -130,29 +141,30 @@ class Injector(multiprocessing.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
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._state = 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.
self._state = STARTING
state = STARTING
if self._state == STARTING and self._msg_pipe[1].poll():
# 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:
if state in (STARTING, RUNNING) and not alive:
# we thought it is running (maybe it was when get_state was previously),
# but the process is not alive. It probably crashed
self._state = FAILED
state = FAILED
logger.error("Injector was unexpectedly found stopped")
self._state = state
return self._state
@ensure_numlock
@ -163,75 +175,80 @@ class Injector(multiprocessing.Process):
"""
logger.info('Stopping injecting keycodes for group "%s"', self.group.key)
self._msg_pipe[1].send(CLOSE)
self._state = STOPPED
"""Process internal stuff"""
def _grab_devices(self) -> GroupSources:
"""Grab all devices that are needed for the injection."""
sources = []
ranking = [
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:
source = self._grab_device(path)
if source is None:
# this path doesn't need to be grabbed for injection, because
# it doesn't provide the events needed to execute the preset
try:
devices.append(evdev.InputDevice(path))
except (FileNotFoundError, OSError):
logger.error('Could not find "%s"', path)
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]:
"""Try to grab the device, return None if not needed/possible.
grabbed_devices = []
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
even though they are mapped.
"""
try:
device = evdev.InputDevice(path)
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:
error = None
for attempt in range(10):
try:
device.grab()
logger.debug("Grab %s", path)
break
except IOError as error:
attempts += 1
logger.debug("Grab %s", device.path)
return device
except IOError as err:
# it might take a little time until the device is free if
# it was previously grabbed.
logger.debug("Failed attempts to grab %s: %d", path, attempts)
if attempts >= 10:
logger.error("Cannot grab %s, it is possibly in use", path)
logger.error(str(error))
return None
time.sleep(self.regrab_timeout)
error = err
logger.debug("Failed attempts to grab %s: %d", device.path, attempt + 1)
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:
"""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
# cleanly. Using .terminate prevents coverage from working.
loop.stop()
self._msg_pipe[0].send(STOPPED)
return
def run(self) -> None:

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

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

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

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

@ -21,16 +21,15 @@ from typing import Dict, Tuple
import evdev
from inputremapper.logger import logger
from inputremapper.configs.mapping import Mapping
from inputremapper.event_combination import EventCombination
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
ContextProtocol,
HandlerEnums,
InputEventHandler,
)
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.logger import logger
class AxisSwitchHandler(MappingHandler):
@ -97,7 +96,7 @@ class AxisSwitchHandler(MappingHandler):
return False
self._active = bool(event.value)
if not self._active:
if not self._active and self._axis_source:
# recenter the axis
logger.debug_key(self.mapping.event_combination, "stopping axis")
event = InputEvent(
@ -105,10 +104,10 @@ class AxisSwitchHandler(MappingHandler):
0,
*self._map_axis,
0,
action=EventActions.recenter,
actions=(EventActions.recenter,),
)
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
# is at the correct position
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
# 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
from evdev.ecodes import EV_ABS, EV_REL, EV_KEY
import evdev
from evdev.ecodes import EV_ABS, EV_REL
from inputremapper.configs.mapping import Mapping
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.event_combination import EventCombination
from inputremapper.logger import logger
from inputremapper.injection.mapping_handlers.mapping_handler import (
ContextProtocol,
MappingHandler,
InputEventHandler,
HandlerEnums,
)
from inputremapper.input_event import InputEvent
from inputremapper.logger import logger
class CombinationHandler(MappingHandler):
@ -100,10 +98,13 @@ class CombinationHandler(MappingHandler):
self.forward_release(forward)
event = event.modify(value=1)
else:
if self._output_state:
if self._output_state or self.mapping.is_axis_mapping():
# we ignore the supress argument for release events
# otherwise we might end up with stuck keys
# (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
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
"""
if (
len(self.mapping.event_combination) == 1
or not self.mapping.release_combination_keys
):
if len(self._pressed_keys) == 1 or not self.mapping.release_combination_keys:
return
for event in self.mapping.event_combination:
forward.write(*event.type_and_code, 0)
for type_and_code in self._pressed_keys:
forward.write(*type_and_code, 0)
forward.syn()
def needs_ranking(self) -> bool:

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

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

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

@ -19,7 +19,6 @@
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Provides protocols for mapping handlers
*** The architecture behind mapping handlers ***
Handling an InputEvent is done in 3 steps:
@ -53,6 +52,7 @@ Step 1 and 2:
Step 1, 2 and 3:
- AbsToRelHandler
- NullHandler
Step 2 and 3:
- KeyHandler
@ -61,15 +61,14 @@ Step 2 and 3:
from __future__ import annotations
import enum
from typing import Dict, Protocol, Set, Optional, List
import evdev
from typing import Dict, Protocol, Set, Optional, List
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.input_event import InputEvent, EventActions
from inputremapper.event_combination import EventCombination
from inputremapper.logger import logger
@ -81,10 +80,25 @@ class EventListener(Protocol):
class ContextProtocol(Protocol):
"""The parts from context needed for macros."""
preset: Preset
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):
"""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()
class MappingHandler(InputEventHandler):
class MappingHandler:
"""The protocol an InputEventHandler must follow if it should be
dynamically integrated in an event-pipeline by the mapping parser
"""
@ -155,13 +169,27 @@ class MappingHandler(InputEventHandler):
new_combination = []
for event in combination:
if event.value != 0:
event = event.modify(action=EventActions.as_key)
event = event.modify(actions=(EventActions.as_key,))
new_combination.append(event)
self.mapping = mapping
self.input_events = new_combination
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:
"""If this handler needs to be wrapped in another MappingHandler."""
return len(self.wrap_with()) > 0
@ -175,10 +203,10 @@ class MappingHandler(InputEventHandler):
pass
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
"""A dict of EventCombination -> HandlerEnums."""
# this handler should be wrapped with the MappingHandler corresponding
# to the HandlerEnums, and the EventCombination as first argument
# TODO: better explanation
"""A dict of EventCombination -> HandlerEnums.
for each EventCombination this handler should be wrapped
with the given MappingHandler"""
return {}
def set_sub_handler(self, handler: InputEventHandler) -> None:

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

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

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

@ -20,14 +20,16 @@
from __future__ import annotations
import enum
from dataclasses import dataclass
from typing import Tuple, Union, Sequence, Callable, Optional
import evdev
from evdev import ecodes
from dataclasses import dataclass
from typing import Tuple, Union, Sequence, Callable
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.exceptions import InputEventCreationError
from inputremapper.gui.message_broker import MessageType
from inputremapper.logger import logger
InputEventValidationType = Union[
str,
@ -39,10 +41,14 @@ InputEventValidationType = Union[
class EventActions(enum.Enum):
"""Additional information a InputEvent can send through the event pipeline"""
as_key = enum.auto()
recenter = enum.auto()
as_key = enum.auto() # treat this event as a key event
recenter = enum.auto() # recenter the axis when receiving this
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
@dataclass(frozen=True)
@ -52,12 +58,14 @@ class InputEvent:
as a drop in replacement for evdev.InputEvent
"""
message_type = MessageType.selected_event
sec: int
usec: int
type: int
code: int
value: int
action: EventActions = EventActions.none
actions: Tuple[EventActions, ...] = ()
def __hash__(self):
return hash((self.type, self.code, self.value))
@ -161,15 +169,18 @@ class InputEvent:
@property
def is_key_event(self) -> bool:
"""Whether this is interpreted as a key event."""
return self.type == evdev.ecodes.EV_KEY or self.action == EventActions.as_key
return self.type == evdev.ecodes.EV_KEY or EventActions.as_key in self.actions
def __str__(self):
if self.type == evdev.ecodes.EV_KEY:
key_name = evdev.ecodes.bytype[self.type].get(self.code, "unknown")
action = "down" if self.value == 1 else "up"
return f"<InputEvent {key_name} ({self.code}) {action}>"
return f"<InputEvent {self.event_tuple}>"
return f"InputEvent{self.event_tuple}"
def description(self, exclude_threshold=False, exclude_direction=False) -> str:
"""get a human-readable description of the event"""
return (
f"{self.get_name()} "
f"{self.get_direction() if not exclude_direction else ''} "
f"{self.get_threshold() if not exclude_threshold else ''}".strip()
)
def timestamp(self):
"""Return the unix timestamp of when the event was seen."""
@ -182,7 +193,7 @@ class InputEvent:
type: int = None,
code: int = None,
value: int = None,
action: EventActions = EventActions.none,
actions: Tuple[EventActions, ...] = None,
) -> InputEvent:
"""Return a new modified event."""
return InputEvent(
@ -191,8 +202,106 @@ class InputEvent:
type if type is not None else self.type,
code if code is not None else self.code,
value if value is not None else self.value,
action if action is not EventActions.none else self.action,
actions if actions is not None else self.actions,
)
def json_str(self) -> str:
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,
even those written by themselves.
"""
import asyncio
import json
import os
import time
import json
from typing import Optional, AsyncIterator
from inputremapper.logger import logger
from inputremapper.configs.paths import mkdir, chown
from inputremapper.logger import logger
class Pipe:
@ -54,6 +54,9 @@ class Pipe:
self._unread = []
self._created_at = time.time()
self._transport: Optional[asyncio.ReadTransport] = None
self._async_iterator: Optional[AsyncIterator] = None
paths = (f"{path}r", f"{path}w")
mkdir(os.path.dirname(path))
@ -93,6 +96,13 @@ class Pipe:
leftover = self.recv()
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):
"""Read an object from the pipe or None if nothing available.
@ -107,6 +117,9 @@ class Pipe:
if len(line) == 0:
return None
return self._get_msg(line)
def _get_msg(self, line):
parsed = json.loads(line)
if parsed[0] < self._created_at and os.environ.get("UNITTEST"):
# important to avoid race conditions between multiple unittests,
@ -143,3 +156,23 @@ class Pipe:
def fileno(self):
"""Compatibility to select.select."""
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."""
import multiprocessing
import atexit
import multiprocessing
import select
from inputremapper.logger import logger

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

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

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

@ -39,8 +39,6 @@ from evdev.ecodes import (
)
from inputremapper.logger import logger
from inputremapper.configs.global_config import BUTTONS
# other events for ABS include buttons
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"/>
</p>
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.
First, select your device (like your keyboard) from the large dropdown on the top,
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.
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
the application can read the original keycode. It would otherwise be
invisible since the daemon maps it independently of the GUI.
If you later want to modify the Input of your mapping you need to use the
"Stop Injection" button, so that the application can read your original input.
It would otherwise be invisible since the daemon maps it independently of the GUI.
## Troubleshooting
@ -35,31 +37,22 @@ No injection should be running anymore.
## Combinations
Change the key of your mapping (`Change Key` - Button) and hold a few of your
device keys down. Releasing them will make your text cursor jump into the
mapping column to type in what you want to map it to.
You can use combinations of different inputs to trigger a mapping: While you recorde
the input (`Recorde Input` - Button) press multiple keys and/or move axis at once.
The mapping will be triggered as soon as all the recorded inputs are pressed.
Combinations involving Modifiers might not work. Configuring a combination
of two keys to output a single key will require you to push down the first
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.
If you use an axis an input you can modify the threshold at which the mapping is
activated in the `Advanced Input Configuration`.
For example a combination of `LEFTSHIFT + a` for `b` would write "B" instead,
because shift will be activated before you hit the "a". Therefore the
environment will see shift and a "b", which will then be capitalized.
Consider using a different key for the combination than shift. You could use
`KP1 + a` and map `KP1` to `disable`.
The second option is to release the modifier in your combination by writing
the modifier one more time. This will write lowercase "b" characters. To make
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`
A mapping with an input combination is only injected once all combination keys
are pressed. This means all the input keys you press before the combination is complete
will be injected unmodified. In some cases this can be desirable, in others not.
In the `Advanced Input Configuration` is the `Release Input` toggle.
This will release all inputs which are part of the combination before the mapping is
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
is dependent on keys (are modifiers involved?), the order in which they are pressed and
on your environment (X11/Wayland). By default the toggle is on.
## 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
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
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`,
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:
```json
@ -139,7 +144,7 @@ looks like, with an example autoload entry:
"autoload": {
"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
# 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
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 json
import os
import sys
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():
@ -132,7 +151,7 @@ tmp = temporary_directory.name
uinput_write_history = []
# for tests that makes the injector create its processes
uinput_write_history_pipe = multiprocessing.Pipe()
pending_events = {}
pending_events: Dict[str, Tuple[Connection, Connection]] = {}
def read_write_history_pipe():
@ -166,7 +185,10 @@ fixtures = {
# see if the groups correct attribute is used in functions and paths.
"/dev/input/event11": {
"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.REL_X,
evdev.ecodes.REL_Y,
@ -200,6 +222,26 @@ fixtures = {
"name": "Foo Device qux",
"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
"/dev/input/event20": {
"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
"""
if pending_events.get(group_key) is None:
logger.info("creating Pipe for %s", group_key)
pending_events[group_key] = multiprocessing.Pipe()
@ -284,6 +327,7 @@ def push_event(group_key, event):
event : InputEvent
"""
setup_pipe(group_key)
logger.info("Simulating %s for %s", event, group_key)
pending_events[group_key][0].send(event)
@ -355,19 +399,21 @@ class InputDevice:
logger.info("ungrab %s %s", self.name, self.path)
async def async_read_loop(self):
if pending_events.get(self.group_key) is None:
self.log("no events to read", self.group_key)
return
# consume all of them
while pending_events[self.group_key][1].poll():
result = pending_events[self.group_key][1].recv()
self.log(result, "async_read_loop")
yield result
await asyncio.sleep(0.01)
logger.info("starting read loop for %s", self.path)
new_frame = asyncio.Event()
asyncio.get_running_loop().add_reader(self.fd, new_frame.set)
while True:
await new_frame.wait()
new_frame.clear()
if not pending_events[self.group_key][1].poll():
# todo: why? why do we need this?
# sometimes this happens, as if a other process calls recv on
# the pipe
continue
# doesn't loop endlessly in order to run tests for the injector in
# the main process
event = pending_events[self.group_key][1].recv()
logger.info("got %s at %s", event, self.path)
yield event
def read(self):
# the patched fake InputDevice objects read anything pending from
@ -396,13 +442,12 @@ class InputDevice:
def read_one(self):
"""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
if len(pending_events[self.group_key]) == 0:
if not pending_events[self.group_key][1].poll():
return None
time.sleep(EVENT_READ_TIMEOUT)
try:
event = pending_events[self.group_key][1].recv()
except (UnpicklingError, EOFError):
@ -541,6 +586,19 @@ def clear_write_history():
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
# the original versions
patch_paths()
@ -548,23 +606,26 @@ patch_evdev()
patch_events()
patch_os_system()
patch_check_output()
# patch_warnings()
from inputremapper.logger import update_verbosity
update_verbosity(True)
from inputremapper.daemon import DaemonProxy
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.mapping import Mapping, UIMapping
from inputremapper.gui.reader import reader
from inputremapper.groups import groups
from inputremapper.groups import groups, _Groups
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.active_preset import active_preset
from inputremapper.configs.paths import get_config_path
from inputremapper.injection.macros.macro import macro_variables
from inputremapper.gui.message_broker import MessageBroker
from inputremapper.gui.reader import Reader
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
# 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):
"""Reset the applications state."""
if log:
@ -628,11 +679,6 @@ def quick_cleanup(log=True):
pending_events[device] = None
setup_pipe(device)
try:
reader.terminate()
except (BrokenPipeError, OSError):
pass
try:
if asyncio.get_event_loop().is_running():
for task in asyncio.all_tasks():
@ -662,7 +708,6 @@ def quick_cleanup(log=True):
global_config._save_config()
system_mapping.populate()
active_preset.empty()
clear_write_history()
@ -685,8 +730,6 @@ def quick_cleanup(log=True):
if device not in environ_copy:
del os.environ[device]
reader.clear()
for _, pipe in pending_events.values():
assert not pipe.poll()
@ -725,6 +768,75 @@ def spy(obj, 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()

@ -85,9 +85,9 @@ class TestContext(unittest.TestCase):
(1, 33): 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():
self.assertEqual(val, len(context.callbacks[key]))
self.assertEqual(val, len(context.notify_callbacks[key]))
self.assertEqual(
7, len(context._handlers)

@ -32,7 +32,6 @@ import collections
from importlib.util import spec_from_loader, module_from_spec
from importlib.machinery import SourceFileLoader
from inputremapper.gui.active_preset import active_preset
from inputremapper.configs.global_config import global_config
from inputremapper.daemon import Daemon
from inputremapper.configs.preset import Preset
@ -42,7 +41,6 @@ from inputremapper.groups import groups
def import_control():
"""Import the core function of the input-remapper-control command."""
active_preset.empty()
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.daemon.stop_injecting(group.key)
time.sleep(0.2)
self.assertEqual(self.daemon.get_state(group.key), STOPPED)
time.sleep(0.1)
try:
self.assertFalse(uinput_write_history_pipe[0].poll())
except AssertionError:
@ -171,13 +171,13 @@ class TestDaemon(unittest.TestCase):
raise
"""Injection 2"""
self.daemon.start_injecting(group.key, preset_name)
time.sleep(0.1)
# -1234 will be classified as -1 by the injector
push_events(group.key, [new_event(*ev_2, -1234)])
self.daemon.start_injecting(group.key, preset_name)
time.sleep(0.1)
self.assertTrue(uinput_write_history_pipe[0].poll())
# 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.daemon.stop_injecting(group_key)
time.sleep(0.2)
self.assertEqual(self.daemon.get_state(group_key), STOPPED)
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.assertTrue(daemon.autoload_history.may_autoload(group.key, preset_name))
self.assertIn(group.key, daemon.injectors)
time.sleep(0.2)
self.assertEqual(previous_injector.get_state(), STOPPED)
# a different injetor is now running
self.assertNotEqual(previous_injector, daemon.injectors[group.key])
@ -377,6 +379,7 @@ class TestDaemon(unittest.TestCase):
# stop
daemon.stop_injecting(group.key)
time.sleep(0.2)
self.assertNotIn(group.key, daemon.autoload_history._autoload_history)
self.assertEqual(daemon.injectors[group.key].get_state(), STOPPED)
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset_name))
@ -409,7 +412,7 @@ class TestDaemon(unittest.TestCase):
injector = daemon.injectors[group.key]
self.assertEqual(len_before + 1, len_after)
# calling duplicate _autoload does nothing
# calling duplicate get_autoload does nothing
self.daemon._autoload(group.key)
self.assertEqual(
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
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.input_event import InputEvent
@ -120,6 +137,66 @@ class TestKey(unittest.TestCase):
self.assertEqual(c1.json_str(), "1,2,3")
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__":
unittest.main()

@ -192,3 +192,15 @@ class TestAxisTransformation(unittest.TestCase):
places=5,
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
self.assertGreater(len(history), speed * gain * rate * sleep * 0.9 * 2)
self.assertLess(len(history), speed * gain * rate * sleep * 1.1 * 2)
self.assertGreater(len(history), speed * gain * rate * sleep * 0.8 * 2)
self.assertLess(len(history), speed * gain * rate * sleep * 1.2 * 2)
# those may be in arbitrary order
count_x = history.count((EV_REL, REL_X, -1))
@ -362,7 +362,7 @@ class TestEventPipeline(unittest.IsolatedAsyncioTestCase):
event_reader,
)
# wait a bit more for it to sum up
sleep = 0.5
sleep = 0.8
await asyncio.sleep(sleep)
# stop it
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):
quick_cleanup()
def setup(self, source, mapping):
"""Set a a EventReader up for the test and run it in the background."""
async def setup(self, source, mapping):
"""Set a EventReader up for the test and run it in the background."""
forward_to = evdev.UInput()
context = Context(mapping)
context.uinput = evdev.UInput()
consumer_control = EventReader(context, source, forward_to, self.stop_event)
# for consumer in consumer_control._consumers:
# consumer._abs_range = (-10, 10)
asyncio.ensure_future(consumer_control.run())
return context, consumer_control
event_reader = EventReader(context, source, forward_to, self.stop_event)
asyncio.ensure_future(event_reader.run())
await asyncio.sleep(0.1)
return context, event_reader
async def test_if_single_joystick_then(self):
# TODO: Move this somewhere more sensible
@ -112,7 +111,7 @@ class TestEventReader(unittest.IsolatedAsyncioTestCase):
cfg["output_code"] = REL_WHEEL_HI_RES
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(
[
@ -125,7 +124,9 @@ class TestEventReader(unittest.IsolatedAsyncioTestCase):
new_event(EV_KEY, trigger, 0),
]
)
await asyncio.sleep(0.1)
self.stop_event.set() # stop the reader
self.assertEqual(len(context.listeners), 0)
history = [a.t for a in global_uinputs.get_uinput("keyboard").write_history]
self.assertIn((EV_KEY, code_a, 1), history)
@ -151,7 +152,7 @@ class TestEventReader(unittest.IsolatedAsyncioTestCase):
# self.preset.set("gamepad.joystick.left_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(
[

@ -33,12 +33,7 @@ from inputremapper.groups import (
_FindGroups,
groups,
classify,
GAMEPAD,
MOUSE,
UNKNOWN,
GRAPHICS_TABLET,
TOUCHPAD,
KEYBOARD,
DeviceType,
_Group,
)
@ -58,7 +53,7 @@ class TestGroups(unittest.TestCase):
group = _Group(
paths=["/dev/a", "/dev/b", "/dev/c"],
names=["name_bar", "name_a", "name_foo"],
types=[MOUSE, KEYBOARD, UNKNOWN],
types=[DeviceType.MOUSE, DeviceType.KEYBOARD, DeviceType.UNKNOWN],
key="key",
)
self.assertEqual(group.name, "name_a")
@ -85,7 +80,7 @@ class TestGroups(unittest.TestCase):
"/dev/input/event1",
],
"names": ["Foo Device"],
"types": [KEYBOARD],
"types": [DeviceType.KEYBOARD],
"key": "Foo Device",
}
),
@ -95,9 +90,19 @@ class TestGroups(unittest.TestCase):
"/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,
],
"names": ["Foo Device foo", "Foo Device", "Foo Device"],
"types": [KEYBOARD, MOUSE],
"key": "Foo Device 2",
}
),
@ -105,7 +110,7 @@ class TestGroups(unittest.TestCase):
{
"paths": ["/dev/input/event20"],
"names": ["Bar Device"],
"types": [KEYBOARD],
"types": [DeviceType.KEYBOARD],
"key": "Bar Device",
}
),
@ -113,7 +118,7 @@ class TestGroups(unittest.TestCase):
{
"paths": ["/dev/input/event30"],
"names": ["gamepad"],
"types": [GAMEPAD],
"types": [DeviceType.GAMEPAD],
"key": "gamepad",
}
),
@ -121,7 +126,7 @@ class TestGroups(unittest.TestCase):
{
"paths": ["/dev/input/event40"],
"names": ["input-remapper Bar Device"],
"types": [KEYBOARD],
"types": [DeviceType.KEYBOARD],
"key": "input-remapper Bar Device",
}
),
@ -229,7 +234,7 @@ class TestGroups(unittest.TestCase):
}
)
),
GAMEPAD,
DeviceType.GAMEPAD,
)
"""Mice"""
@ -247,12 +252,14 @@ class TestGroups(unittest.TestCase):
}
)
),
MOUSE,
DeviceType.MOUSE,
)
"""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"""
@ -265,7 +272,7 @@ class TestGroups(unittest.TestCase):
}
)
),
TOUCHPAD,
DeviceType.TOUCHPAD,
)
"""Graphics tablets"""
@ -279,7 +286,7 @@ class TestGroups(unittest.TestCase):
}
)
),
GRAPHICS_TABLET,
DeviceType.GRAPHICS_TABLET,
)
"""Weird combos"""
@ -293,19 +300,23 @@ class TestGroups(unittest.TestCase):
}
)
),
UNKNOWN,
DeviceType.UNKNOWN,
)
self.assertEqual(
classify(
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__":

@ -58,6 +58,7 @@ from inputremapper.injection.injector import (
NO_GRAB,
UNKNOWN,
get_udev_name,
FAILED,
)
from inputremapper.injection.numlock import is_numlock_on
from inputremapper.configs.system_mapping import (
@ -65,12 +66,11 @@ from inputremapper.configs.system_mapping import (
DISABLE_CODE,
DISABLE_NAME,
)
from inputremapper.gui.active_preset import active_preset
from inputremapper.configs.preset import Preset
from inputremapper.event_combination import EventCombination
from inputremapper.injection.macros.parse import parse
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():
@ -101,9 +101,10 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
evdev.InputDevice.grab = grab_fail_twice
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.assertEqual(self.injector.get_state(), STOPPED)
time.sleep(0.2)
self.assertIn(self.injector.get_state(), (STOPPED, FAILED, NO_GRAB))
self.injector = None
evdev.InputDevice.grab = self.grab
@ -119,8 +120,8 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
# this test needs to pass around all other constraints of
# _grab_device
self.injector.context = Context(preset)
device = self.injector._grab_device(path)
gamepad = classify(device) == GAMEPAD
device = self.injector._grab_device(evdev.InputDevice(path))
gamepad = classify(device) == DeviceType.GAMEPAD
self.assertFalse(gamepad)
self.assertEqual(self.failed, 2)
# success on the third try
@ -134,7 +135,7 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
self.injector = Injector(groups.find(key="Foo Device 2"), preset)
path = "/dev/input/event10"
self.injector.context = Context(preset)
device = self.injector._grab_device(path)
device = self.injector._grab_device(evdev.InputDevice(path))
self.assertIsNone(device)
self.assertGreaterEqual(self.failed, 1)
@ -154,14 +155,15 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
)
self.injector = Injector(groups.find(name="gamepad"), 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
# doesn't have the required capability
self.assertIsNone(_grab_device("/dev/input/event10"))
# 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"))
grabbed = self.injector._grab_devices()
self.assertEqual(len(grabbed), 1)
self.assertEqual(grabbed[0].path, "/dev/input/event30")
def test_forward_gamepad_events(self):
# forward abs joystick events
@ -170,15 +172,16 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
self.injector.context = Context(preset)
path = "/dev/input/event30"
device = self.injector._grab_device(path)
self.assertIsNone(device) # no capability is used, so it won't grab
device = self.injector._grab_devices()
self.assertEqual(device, []) # no capability is used, so it won't grab
preset.add(
get_key_mapping(EventCombination([EV_KEY, BTN_A, 1]), "keyboard", "a"),
)
device = self.injector._grab_device(path)
self.assertIsNotNone(device)
gamepad = classify(device) == GAMEPAD
devices = self.injector._grab_devices()
self.assertEqual(len(devices), 1)
self.assertEqual(devices[0].path, path)
gamepad = classify(devices[0]) == DeviceType.GAMEPAD
self.assertTrue(gamepad)
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.context = Context(preset)
path = "/dev/input/event11"
device = self.injector._grab_device(path)
self.assertIsNone(device)
self.injector.group.paths = [path]
devices = self.injector._grab_devices()
self.assertEqual(devices, [])
self.assertEqual(self.failed, 0)
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
self.injector = Injector(groups.find(key="Foo Device 2"), preset)
self.injector.context = Context(preset)
path = "/dev/input/event11"
device = self.injector._grab_device(path)
self.injector.group.paths = [path]
devices = self.injector._grab_devices()
# skips the device alltogether, so no grab attempts fail
self.assertEqual(self.failed, 0)
self.assertIsNone(device)
self.assertEqual(devices, [])
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"
prefix = "input-remapper"
expected = f'{prefix} {"a" * (80 - len(suffix) - len(prefix) - 2)} {suffix}'
@ -236,15 +242,11 @@ class TestInjector(unittest.IsolatedAsyncioTestCase):
self.injector.run()
self.assertEqual(
self.injector.context.preset.get_mapping(
EventCombination([EV_KEY, KEY_A, 1])
),
self.injector.preset.get_mapping(EventCombination([EV_KEY, KEY_A, 1])),
m1,
)
self.assertEqual(
self.injector.context.preset.get_mapping(
EventCombination([EV_REL, REL_HWHEEL, 1])
),
self.injector.preset.get_mapping(EventCombination([EV_REL, REL_HWHEEL, 1])),
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
# 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 = {
EV_ABS: [ABS_VOLUME, (ABS_X, evdev.AbsInfo(0, 0, 500, 0, 0, 0))],
EV_KEY: [1, 2, 3],

@ -17,7 +17,8 @@
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import asyncio
import multiprocessing
from tests.test import quick_cleanup, tmp
@ -125,7 +126,7 @@ class TestSocket(unittest.TestCase):
self.assertRaises(NotImplementedError, lambda: Base.fileno(None))
class TestPipe(unittest.TestCase):
class TestPipe(unittest.IsolatedAsyncioTestCase):
def test_pipe_single(self):
p1 = Pipe(os.path.join(tmp, "pipe"))
self.assertEqual(p1.recv(), None)
@ -161,6 +162,47 @@ class TestPipe(unittest.TestCase):
self.assertEqual(p2.recv(), 3)
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__":
unittest.main()

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

@ -24,8 +24,9 @@ from functools import partial
from evdev.ecodes import EV_KEY
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.gui.message_broker import MessageType
from inputremapper.input_event import EventActions
from inputremapper.event_combination import EventCombination
@ -84,20 +85,20 @@ class TestMapping(unittest.IsolatedAsyncioTestCase):
"output_code": 3,
}
m = Mapping(**cfg)
expected_actions = [EventActions.as_key, EventActions.as_key, EventActions.none]
actions = [event.action for event in m.event_combination]
expected_actions = [(EventActions.as_key,), (EventActions.as_key,), ()]
actions = [event.actions for event in m.event_combination]
self.assertEqual(expected_actions, actions)
# copy keeps the event action
# copy keeps the event actions
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)
# changing the combination sets the action
# changing the combination sets the actions
m3 = m.copy()
m3.event_combination = "1,2,1+2,1,0+3,1,10"
expected_actions = [EventActions.as_key, EventActions.none, EventActions.as_key]
actions = [event.action for event in m3.event_combination]
expected_actions = [(EventActions.as_key,), (), (EventActions.as_key,)]
actions = [event.actions for event in m3.event_combination]
self.assertEqual(expected_actions, actions)
def test_combination_changed_callback(self):
@ -331,5 +332,57 @@ class TestMapping(unittest.IsolatedAsyncioTestCase):
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__":
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):
ui_preset = Preset(get_config_path("test.json"), mapping_factory=UIMapping)
# cannot add a mapping without a valid combination
self.assertRaises(Exception, ui_preset.add, UIMapping())
ui_preset.add(UIMapping(event_combination="1,1,1"))
ui_preset.add(UIMapping())
self.assertFalse(ui_preset.is_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.target_uinput = "keyboard"
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
# 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 (
new_event,
push_events,
send_event_to_reader,
EVENT_READ_TIMEOUT,
START_READING_DELAY,
quick_cleanup,
MAX_ABS,
MIN_ABS,
)
import unittest
from unittest import mock
import time
import multiprocessing
@ -48,21 +54,27 @@ from evdev.ecodes import (
REL_X,
ABS_X,
ABS_RZ,
REL_HWHEEL,
)
from inputremapper.gui.reader import reader, will_report_up
from inputremapper.gui.active_preset import active_preset
from inputremapper.configs.global_config import BUTTONS, MOUSE
from inputremapper.gui.reader import Reader
from inputremapper.event_combination import EventCombination
from inputremapper.gui.helper import RootHelper
from inputremapper.groups import groups
from inputremapper.groups import _Groups, DeviceType
CODE_1 = 100
CODE_2 = 101
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):
"""Wait for func to return True."""
iterations = 0
@ -77,189 +89,257 @@ def wait(func, timeout=1.0):
class TestReader(unittest.TestCase):
def setUp(self):
self.helper = None
self.groups = _Groups()
self.message_broker = MessageBroker()
self.reader = Reader(self.message_broker, self.groups)
def tearDown(self):
quick_cleanup()
try:
self.reader.terminate()
except (BrokenPipeError, OSError):
pass
if self.helper is not None:
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
# process
if not groups:
groups = self.groups
def start_helper():
helper = RootHelper()
helper = RootHelper(groups)
helper.run()
self.helper = multiprocessing.Process(target=start_helper)
self.helper.start()
time.sleep(0.1)
def test_will_report_up(self):
self.assertFalse(will_report_up(EV_REL))
self.assertTrue(will_report_up(EV_ABS))
self.assertTrue(will_report_up(EV_KEY))
def test_reading(self):
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()
def test_reading_1(self):
# a single event
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(
"Foo Device 2",
[new_event(EV_ABS, REL_X, 1)],
) # mouse movements are ignored
self.create_helper()
reader.start_reading(groups.find(key="Foo Device 2"))
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)
[new_event(EV_REL, REL_WHEEL, -1), new_event(EV_REL, REL_HWHEEL, 1)],
)
time.sleep(0.1)
self.reader._read()
def test_reading_wheel(self):
# will be treated as released automatically at some point
self.assertEqual(
[
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()
reader.start_reading(groups.find(key="Foo Device 2"))
send_event_to_reader(new_event(EV_REL, REL_WHEEL, 0))
self.assertIsNone(reader.read())
send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1))
result = reader.read()
self.assertIsInstance(result, EventCombination)
self.assertIsInstance(result, tuple)
self.assertEqual(result, EventCombination((EV_REL, REL_WHEEL, 1)))
self.assertEqual(result, ((EV_REL, REL_WHEEL, 1),))
self.assertNotEqual(
result,
EventCombination(((EV_REL, REL_WHEEL, 1), (1, 1, 1))),
self.reader.set_group(self.groups.find(key="Foo Device 2"))
self.reader.start_recorder()
push_events("Foo Device 2", [new_event(EV_KEY, KEY_A, 1)])
time.sleep(0.1)
self.reader._read()
# the duplicate event should be ignored
push_events("Foo Device 2", [new_event(EV_KEY, KEY_A, 1)])
time.sleep(0.1)
self.reader._read()
self.assertEqual(
[CombinationRecorded(EventCombination.from_string("1,30,1"))],
l1.calls,
)
# it won't return the same event twice
self.assertEqual(reader.read(), None)
def test_should_read_absolut_axis(self):
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
self.assertEqual(len(reader._unreleased), 1)
# over 30% should trigger
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(
reader.get_unreleased_keys(),
EventCombination((EV_REL, REL_WHEEL, 1)),
[CombinationRecorded(EventCombination.from_string("3,0,1"))],
l1.calls,
)
self.assertIsInstance(reader.get_unreleased_keys(), EventCombination)
# as long as new wheel events arrive, it is considered unreleased
for _ in range(10):
send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1))
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 1)
# read a few more times, at some point it is treated as unreleased
for _ in range(4):
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 0)
self.assertIsNone(reader.get_unreleased_keys())
"""Combinations"""
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.assertEqual([], l2.calls) # no stop recording yet
# less the 30% should release
push_events("Foo Device 2", [new_event(EV_ABS, ABS_X, int(MAX_ABS * 0.2))])
time.sleep(0.1)
self.reader._read()
self.assertEqual(
[CombinationRecorded(EventCombination.from_string("3,0,1"))],
l1.calls,
)
self.assertEqual([Signal(MessageType.recording_finished)], l2.calls)
def test_should_change_direction(self):
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
self.create_helper()
self.assertEqual(reader.read(), None)
reader.start_reading(groups.find(key="Foo Device 2"))
self.assertEqual(reader.read(), None)
send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1))
self.assertEqual(reader.read(), EventCombination((EV_REL, REL_WHEEL, 1)))
self.assertEqual(len(reader._unreleased), 1)
self.assertEqual(reader.read(), None)
send_event_to_reader(new_event(EV_REL, REL_WHEEL, -1))
self.assertEqual(reader.read(), EventCombination((EV_REL, REL_WHEEL, -1)))
# 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.
self.assertEqual(len(reader._unreleased), 1)
self.assertEqual(reader.read(), None)
self.reader.set_group(self.groups.find(key="Foo Device 2"))
self.reader.start_recorder()
push_events(
"Foo Device 2",
[
new_event(EV_KEY, KEY_A, 1),
new_event(EV_ABS, ABS_X, int(MAX_ABS * 0.4)),
new_event(EV_KEY, KEY_COMMA, 1),
new_event(EV_ABS, ABS_X, int(MAX_ABS * 0.1)),
new_event(EV_ABS, ABS_X, int(MIN_ABS * 0.4)),
],
)
time.sleep(0.1)
self.reader._read()
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):
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
push_events(
"Foo Device 2",
[
new_event(EV_KEY, 1, 1),
]
* 100,
* 10,
)
push_events(
"Bar Device",
[
new_event(EV_KEY, 2, 1),
new_event(EV_KEY, 2, 0),
]
* 100,
* 3,
)
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)
self.assertEqual(reader.read(), EventCombination((EV_KEY, 1, 1)))
reader.start_reading(groups.find(name="Bar Device"))
self.reader._read()
self.assertEqual(l1.calls[0].combination, EventCombination((EV_KEY, 1, 1)))
# it's plausible that right after sending the new read command more
# events from the old device might still appear. Give the helper
# some time to handle the new command.
self.reader.set_group(self.groups.find(name="Bar Device"))
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)
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):
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
# a combination of events
push_events(
"Foo Device 2",
[
new_event(EV_KEY, CODE_1, 1, 10000.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
pipe[1].send("refreshed")
with mock.patch.object(groups, "refresh", refresh):
self.create_helper()
groups = _Groups()
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
reader._commands.send(856794)
self.reader._commands.send(856794)
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
# groups is not up-to-date
# self.groups is not up-to-date
self.assertTrue(pipe[0].poll())
self.assertEqual(pipe[0].recv(), "refreshed")
self.reader._read()
self.assertEqual(
reader.read(),
l1.calls[-1].combination,
((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):
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
push_events(
"Foo Device 2",
[
@ -404,26 +386,34 @@ class TestReader(unittest.TestCase):
],
)
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)
self.assertEqual(reader.read(), EventCombination((EV_KEY, CODE_2, 1)))
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 1)
self.reader._read()
self.assertEqual(
l1.calls[-1].combination, EventCombination((EV_KEY, CODE_2, 1))
)
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
push_events(
"Foo Device 2",
[new_event(EV_ABS, ABS_HAT0X, 1), new_event(EV_KEY, CODE_3, 2)],
)
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)
self.assertEqual(reader.read(), EventCombination((EV_ABS, ABS_HAT0X, 1)))
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 1)
self.reader._read()
self.assertEqual(
l1.calls[-1].combination, EventCombination((EV_ABS, ABS_HAT0X, 1))
)
def test_reading_ignore_up(self):
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
push_events(
"Foo Device 2",
[
@ -433,32 +423,18 @@ class TestReader(unittest.TestCase):
],
)
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)
self.assertEqual(reader.read(), EventCombination((EV_KEY, CODE_2, 1)))
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 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())
self.reader._read()
self.assertEqual(
l1.calls[-1].combination, EventCombination((EV_KEY, CODE_2, 1))
)
def test_wrong_device(self):
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
push_events(
"Foo Device 2",
[
@ -468,16 +444,19 @@ class TestReader(unittest.TestCase):
],
)
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)
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 0)
self.reader._read()
self.assertEqual(len(l1.calls), 0)
def test_inputremapper_devices(self):
# Don't read from inputremapper devices, their keycodes are 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
# point.
l1 = Listener()
self.message_broker.subscribe(MessageType.combination_recorded, l1)
push_events(
"input-remapper Bar Device",
[
@ -487,106 +466,105 @@ class TestReader(unittest.TestCase):
],
)
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)
self.assertEqual(reader.read(), None)
self.assertEqual(len(reader._unreleased), 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)
self.reader._read()
self.assertEqual(len(l1.calls), 0)
def test_terminate(self):
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)])
time.sleep(START_READING_DELAY + EVENT_READ_TIMEOUT)
self.assertTrue(reader._results.poll())
self.assertTrue(self.reader._results.poll())
reader.terminate()
reader.clear()
self.reader.terminate()
time.sleep(EVENT_READ_TIMEOUT)
self.assertFalse(self.reader._results.poll())
# no new events arrive after terminating
push_events("Foo Device 2", [new_event(EV_KEY, CODE_3, 1)])
time.sleep(EVENT_READ_TIMEOUT * 3)
self.assertFalse(reader._results.poll())
self.assertFalse(self.reader._results.poll())
def test_are_new_groups_available(self):
l1 = Listener()
self.message_broker.subscribe(MessageType.groups, l1)
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
self.assertFalse(reader.are_new_groups_available())
reader.read()
self.assertTrue(reader.are_new_groups_available())
# a bit weird, but it assumes the gui handled that and returns
# false afterwards
self.assertFalse(reader.are_new_groups_available())
# send the same devices again
reader._get_event({"type": "groups", "message": groups.dumps()})
self.assertFalse(reader.are_new_groups_available())
# send changed devices
message = groups.dumps()
message = message.replace("Foo Device", "foo_device")
reader._get_event({"type": "groups", "message": message})
self.assertTrue(reader.are_new_groups_available())
self.assertFalse(reader.are_new_groups_available())
self.assertEqual("[]", self.reader.groups.dumps())
self.reader._read()
self.assertEqual(
self.reader.groups.dumps(),
json.dumps(
[
json.dumps(
{
"paths": [
"/dev/input/event1",
],
"names": ["Foo Device"],
"types": [DeviceType.KEYBOARD],
"key": "Foo Device",
}
),
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__":

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

Loading…
Cancel
Save