Refactored injection (#263)
parent
4f03a7b484
commit
1a2b2d7076
@ -0,0 +1,7 @@
|
||||
|
||||
[mypy]
|
||||
plugins = pydantic.mypy
|
||||
|
||||
# ignore the missing evdev stubs
|
||||
[mypy-evdev.*]
|
||||
ignore_missing_imports = True
|
@ -0,0 +1,16 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Only Integration Tests" type="tests" factoryName="Autodetect">
|
||||
<module name="input-remapper" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="PARENT_ENVS" value="true" />
|
||||
<option name="SDK_HOME" value="" />
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/tests" />
|
||||
<option name="IS_MODULE_SDK" value="true" />
|
||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||
<option name="_new_additionalArguments" value=""--start-dir integration"" />
|
||||
<option name="_new_target" value=""$PROJECT_DIR$/tests"" />
|
||||
<option name="_new_targetType" value=""PATH"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
@ -0,0 +1,16 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Only Unit Tests" type="tests" factoryName="Autodetect">
|
||||
<module name="input-remapper" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="PARENT_ENVS" value="true" />
|
||||
<option name="SDK_HOME" value="" />
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/tests" />
|
||||
<option name="IS_MODULE_SDK" value="true" />
|
||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||
<option name="_new_additionalArguments" value=""--start-dir unit"" />
|
||||
<option name="_new_target" value=""$PROJECT_DIR$/tests"" />
|
||||
<option name="_new_targetType" value=""PATH"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,408 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
from evdev.ecodes import EV_KEY, EV_ABS, EV_REL
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
PositiveInt,
|
||||
confloat,
|
||||
root_validator,
|
||||
validator,
|
||||
ValidationError,
|
||||
PositiveFloat,
|
||||
VERSION,
|
||||
)
|
||||
from typing import Optional, Callable, Tuple, Dict, Union, Any
|
||||
|
||||
import pkg_resources
|
||||
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.configs.system_mapping import system_mapping
|
||||
from inputremapper.exceptions import MacroParsingError
|
||||
from inputremapper.injection.macros.parse import is_this_a_macro, parse
|
||||
from inputremapper.input_event import EventActions
|
||||
|
||||
# TODO: remove pydantic VERSION check as soon as we no longer support
|
||||
# Ubuntu 20.04 and with it the ainchant pydantic 1.2
|
||||
needs_workaround = pkg_resources.parse_version(
|
||||
str(VERSION)
|
||||
) < pkg_resources.parse_version("1.7.1")
|
||||
|
||||
|
||||
# TODO: in python 3.11 inherit enum.StrEnum
|
||||
class KnownUinput(str, enum.Enum):
|
||||
keyboard = "keyboard"
|
||||
mouse = "mouse"
|
||||
gamepad = "gamepad"
|
||||
|
||||
|
||||
CombinationChangedCallback = Optional[
|
||||
Callable[[EventCombination, EventCombination], None]
|
||||
]
|
||||
|
||||
|
||||
class Mapping(BaseModel):
|
||||
"""
|
||||
holds all the data for mapping an
|
||||
input action to an output action
|
||||
"""
|
||||
|
||||
if needs_workaround:
|
||||
__slots__ = ("_combination_changed",)
|
||||
|
||||
# Required attributes
|
||||
# The InputEvent or InputEvent combination which is mapped
|
||||
event_combination: EventCombination
|
||||
target_uinput: KnownUinput # The UInput to which the mapped event will be sent
|
||||
|
||||
# Either `output_symbol` or `output_type` and `output_code` is required
|
||||
output_symbol: Optional[str] = None # The symbol or macro string if applicable
|
||||
output_type: Optional[int] = None # The event type of the mapped event
|
||||
output_code: Optional[int] = None # The event code of the mapped event
|
||||
|
||||
# if release events will be sent to the forwarded device as soon as a combination
|
||||
# triggers see also #229
|
||||
release_combination_keys: bool = True
|
||||
|
||||
# macro settings
|
||||
macro_key_sleep_ms: PositiveInt = 20
|
||||
|
||||
# Optional attributes for mapping Axis to Axis
|
||||
# The deadzone of the input axis
|
||||
deadzone: confloat(ge=0, le=1) = 0.1 # type: ignore
|
||||
gain: float = 1.0 # The scale factor for the transformation
|
||||
# The expo factor for the transformation
|
||||
expo: confloat(ge=-1, le=1) = 0 # type: ignore
|
||||
|
||||
# when mapping to relative axis
|
||||
rate: PositiveInt = 60 # The frequency [Hz] at which EV_REL events get generated
|
||||
# the base speed of the relative axis, compounds with the gain
|
||||
rel_speed: PositiveInt = 100
|
||||
|
||||
# when mapping from relative axis:
|
||||
# the absolute value at which a EV_REL axis is considered at its maximum
|
||||
rel_input_cutoff: PositiveInt = 100
|
||||
# the time until a relative axis is considered stationary if no new events arrive
|
||||
release_timeout: PositiveFloat = 0.05
|
||||
|
||||
# callback which gets called if the event_combination is updated
|
||||
if not needs_workaround:
|
||||
_combination_changed: CombinationChangedCallback = None
|
||||
|
||||
# use type: ignore, looks like a mypy bug related to:
|
||||
# https://github.com/samuelcolvin/pydantic/issues/2949
|
||||
def __init__(self, **kwargs): # type: ignore
|
||||
super().__init__(**kwargs)
|
||||
if needs_workaround:
|
||||
object.__setattr__(self, "_combination_changed", None)
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
"""
|
||||
call the combination changed callback
|
||||
if we are about to update the event_combination
|
||||
"""
|
||||
if key != "event_combination" or self._combination_changed is None:
|
||||
if key == "_combination_changed" and needs_workaround:
|
||||
object.__setattr__(self, "_combination_changed", value)
|
||||
return
|
||||
super(Mapping, self).__setattr__(key, value)
|
||||
return
|
||||
|
||||
# the new combination is not yet validated
|
||||
try:
|
||||
new_combi = EventCombination.validate(value)
|
||||
except ValueError:
|
||||
raise ValidationError(
|
||||
f"failed to Validate {value} as EventCombination", Mapping
|
||||
)
|
||||
|
||||
if new_combi == self.event_combination:
|
||||
return
|
||||
|
||||
# raises a keyError if the combination or a permutation is already mapped
|
||||
self._combination_changed(new_combi, self.event_combination)
|
||||
super(Mapping, self).__setattr__(key, value)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.dict(exclude_defaults=True))
|
||||
|
||||
if needs_workaround:
|
||||
|
||||
def copy(self, *args, **kwargs) -> Mapping:
|
||||
copy = super(Mapping, self).copy(*args, **kwargs)
|
||||
object.__setattr__(copy, "_combination_changed", self._combination_changed)
|
||||
return copy
|
||||
|
||||
def set_combination_changed_callback(self, callback: CombinationChangedCallback):
|
||||
self._combination_changed = callback
|
||||
|
||||
def remove_combination_changed_callback(self):
|
||||
self._combination_changed = None
|
||||
|
||||
def get_output_type_code(self) -> Optional[Tuple[int, int]]:
|
||||
"""
|
||||
returns the output_type and output_code if set,
|
||||
otherwise looks the output_symbol up in the system_mapping
|
||||
return None for unknown symbols and macros
|
||||
"""
|
||||
if self.output_code and self.output_type:
|
||||
return self.output_type, self.output_code
|
||||
if not is_this_a_macro(self.output_symbol):
|
||||
return EV_KEY, system_mapping.get(self.output_symbol)
|
||||
return None
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""if the mapping is valid"""
|
||||
return True
|
||||
|
||||
@validator("output_symbol", pre=True)
|
||||
def validate_symbol(cls, symbol):
|
||||
if not symbol:
|
||||
return None
|
||||
|
||||
if is_this_a_macro(symbol):
|
||||
try:
|
||||
parse(symbol) # raises MacroParsingError
|
||||
return symbol
|
||||
except MacroParsingError as e:
|
||||
raise ValueError(
|
||||
e
|
||||
) # pydantic only catches ValueError, TypeError, and AssertionError
|
||||
|
||||
if system_mapping.get(symbol) is not None:
|
||||
return symbol
|
||||
raise ValueError(
|
||||
f"the output_symbol '{symbol}' is not a macro and not a valid keycode-name"
|
||||
)
|
||||
|
||||
@validator("event_combination")
|
||||
def only_one_analog_input(cls, combination) -> EventCombination:
|
||||
"""
|
||||
check that the event_combination specifies a maximum of one
|
||||
analog to analog mapping
|
||||
"""
|
||||
|
||||
# any event with a value of 0 is considered an analog input (even key events)
|
||||
# any event with a non-zero value is considered a binary input
|
||||
analog_events = [event for event in combination if event.value == 0]
|
||||
if len(analog_events) > 1:
|
||||
raise ValueError(
|
||||
f"cannot map a combination of multiple analog inputs: {analog_events}"
|
||||
f"add trigger points (event.value != 0) to map as a button"
|
||||
)
|
||||
|
||||
return combination
|
||||
|
||||
@validator("event_combination")
|
||||
def trigger_point_in_range(cls, combination) -> EventCombination:
|
||||
"""
|
||||
check if the trigger point for mapping analog axis to buttons is valid
|
||||
"""
|
||||
for event in combination:
|
||||
if event.type == EV_ABS and abs(event.value) >= 100:
|
||||
raise ValueError(
|
||||
f"{event = } maps a absolute axis to a button, but the trigger "
|
||||
f"point (event.value) is not between -100[%] and 100[%]"
|
||||
)
|
||||
return combination
|
||||
|
||||
@validator("event_combination")
|
||||
def set_event_actions(cls, combination):
|
||||
"""sets the correct action for each event"""
|
||||
new_combination = []
|
||||
for event in combination:
|
||||
if event.value != 0:
|
||||
event = event.modify(action=EventActions.as_key)
|
||||
new_combination.append(event)
|
||||
return EventCombination(new_combination)
|
||||
|
||||
@root_validator
|
||||
def contains_output(cls, values):
|
||||
o_symbol = values.get("output_symbol")
|
||||
o_type = values.get("output_type")
|
||||
o_code = values.get("output_code")
|
||||
if o_symbol is None and (o_type is None or o_code is None):
|
||||
raise ValueError(
|
||||
"missing Argument: Mapping must either contain "
|
||||
"`output_symbol` or `output_type` and `output_code`"
|
||||
)
|
||||
return values
|
||||
|
||||
@root_validator
|
||||
def validate_output_integrity(cls, values):
|
||||
symbol = values.get("output_symbol")
|
||||
type_ = values.get("output_type")
|
||||
code = values.get("output_code")
|
||||
if symbol is None:
|
||||
return values # type and code can be anything
|
||||
|
||||
if type_ is None and code is None:
|
||||
return values # we have a symbol: no type and code is fine
|
||||
|
||||
if is_this_a_macro(symbol): # disallow output type and code for macros
|
||||
if type_ is not None or code is not None:
|
||||
raise ValueError(
|
||||
f"output_symbol is a macro: output_type "
|
||||
f"and output_code must be None"
|
||||
)
|
||||
|
||||
if code is not None and code != system_mapping.get(symbol) or type_ != EV_KEY:
|
||||
raise ValueError(
|
||||
f"output_symbol and output_code mismatch: "
|
||||
f"output macro is {symbol} --> {system_mapping.get(symbol)} "
|
||||
f"but output_code is {code} --> {system_mapping.get_name(code)} "
|
||||
)
|
||||
return values
|
||||
|
||||
@root_validator
|
||||
def output_axis_given(cls, values):
|
||||
"""validate that an output type is an axis if we have an input axis"""
|
||||
combination = values.get("event_combination")
|
||||
output_type = values.get("output_type")
|
||||
event_values = [event.value for event in combination]
|
||||
if 0 not in event_values:
|
||||
return values
|
||||
|
||||
if output_type not in (EV_ABS, EV_REL):
|
||||
raise ValueError(
|
||||
f"the {combination = } specifies a input axis, "
|
||||
f"but the {output_type = } is not an axis "
|
||||
)
|
||||
|
||||
return values
|
||||
|
||||
class Config:
|
||||
validate_assignment = True
|
||||
use_enum_values = True
|
||||
underscore_attrs_are_private = True
|
||||
|
||||
json_encoders = {EventCombination: lambda v: v.json_str()}
|
||||
|
||||
|
||||
class UIMapping(Mapping):
|
||||
"""
|
||||
The UI Mapping adds the ability to create Invalid Mapping objects.
|
||||
|
||||
Intended for use in the frontend, where invalid data is allowed
|
||||
during creation of the mapping. Invalid assignments are cached and
|
||||
revalidation is attempted as soon as the mapping changes.
|
||||
"""
|
||||
|
||||
_cache: Dict[str, Any] # the invalid mapping data
|
||||
_last_error: Optional[ValidationError] # the last validation error
|
||||
|
||||
# all attributes that __setattr__ will not forward to super() or _cache
|
||||
ATTRIBUTES = ("_cache", "_last_error")
|
||||
|
||||
# use type: ignore, looks like a mypy bug related to:
|
||||
# https://github.com/samuelcolvin/pydantic/issues/2949
|
||||
def __init__(self, **data): # type: ignore
|
||||
object.__setattr__(self, "_last_error", None)
|
||||
super().__init__(
|
||||
event_combination="99,99,99",
|
||||
target_uinput="keyboard",
|
||||
output_symbol="KEY_A",
|
||||
)
|
||||
cache = {
|
||||
"event_combination": None,
|
||||
"target_uinput": None,
|
||||
"output_symbol": None,
|
||||
}
|
||||
cache.update(**data)
|
||||
object.__setattr__(self, "_cache", cache)
|
||||
self._validate()
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
if key in self.ATTRIBUTES:
|
||||
object.__setattr__(self, key, value)
|
||||
return
|
||||
|
||||
try:
|
||||
super(UIMapping, self).__setattr__(key, value)
|
||||
if key in self._cache:
|
||||
del self._cache[key]
|
||||
|
||||
except ValidationError as error:
|
||||
# cache the value
|
||||
self._last_error = error
|
||||
self._cache[key] = value
|
||||
|
||||
# retry the validation
|
||||
self._validate()
|
||||
|
||||
def __getattribute__(self, item):
|
||||
# intercept any getattribute and prioritize attributes from the cache
|
||||
try:
|
||||
return object.__getattribute__(self, "_cache")[item]
|
||||
except (KeyError, AttributeError):
|
||||
pass
|
||||
|
||||
return object.__getattribute__(self, item)
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""if the mapping is valid"""
|
||||
return len(self._cache) == 0
|
||||
|
||||
def dict(self, *args, **kwargs):
|
||||
"""dict will include the invalid data"""
|
||||
dict_ = super(UIMapping, self).dict(*args, **kwargs)
|
||||
# combine all valid values with the invalid ones
|
||||
dict_.update(**self._cache)
|
||||
if "ATTRIBUTES" in dict_:
|
||||
# remove so that super().__eq__ succeeds
|
||||
# for comparing Mapping with UIMapping
|
||||
del dict_["ATTRIBUTES"]
|
||||
|
||||
if needs_workaround:
|
||||
if "_last_error" in dict_.keys():
|
||||
del dict_["_last_error"]
|
||||
del dict_["_cache"]
|
||||
|
||||
return dict_
|
||||
|
||||
def get_error(self) -> Optional[ValidationError]:
|
||||
"""the validation error or None"""
|
||||
return self._last_error
|
||||
|
||||
def _validate(self) -> None:
|
||||
"""try to validate the mapping"""
|
||||
if self.is_valid():
|
||||
return
|
||||
|
||||
# preserve the combination_changed callback
|
||||
callback = self._combination_changed
|
||||
try:
|
||||
super(UIMapping, self).__init__(**self.dict(exclude_defaults=True))
|
||||
self._cache = {}
|
||||
self._last_error = None
|
||||
self.set_combination_changed_callback(callback)
|
||||
return
|
||||
except ValidationError as error:
|
||||
self._last_error = error
|
||||
|
||||
if (
|
||||
"event_combination" in self._cache.keys()
|
||||
and self._cache["event_combination"]
|
||||
):
|
||||
# the event_combination needs to be valid
|
||||
self._cache["event_combination"] = EventCombination.validate(
|
||||
self._cache["event_combination"]
|
||||
)
|
@ -1,121 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Because multiple calls to async_read_loop won't work."""
|
||||
import asyncio
|
||||
import evdev
|
||||
|
||||
from inputremapper.injection.consumers.joystick_to_mouse import JoystickToMouse
|
||||
from inputremapper.injection.consumers.keycode_mapper import KeycodeMapper
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.injection.context import Context
|
||||
|
||||
|
||||
consumer_classes = [
|
||||
KeycodeMapper,
|
||||
JoystickToMouse,
|
||||
]
|
||||
|
||||
|
||||
class ConsumerControl:
|
||||
"""Reads input events from a single device and distributes them.
|
||||
|
||||
There is one ConsumerControl object for each source, which tells multiple consumers
|
||||
that a new event is ready so that they can inject all sorts of funny
|
||||
things.
|
||||
|
||||
Other devnodes may be present for the hardware device, in which case this
|
||||
needs to be created multiple times.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context: Context,
|
||||
source: evdev.InputDevice,
|
||||
forward_to: evdev.UInput,
|
||||
) -> None:
|
||||
"""Initialize all consumers
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source : evdev.InputDevice
|
||||
where to read keycodes from
|
||||
forward_to : evdev.UInput
|
||||
where to write keycodes to that were not mapped to anything.
|
||||
Should be an UInput with capabilities that work for all forwarded
|
||||
events, so ideally they should be copied from source.
|
||||
"""
|
||||
self._source = source
|
||||
self._forward_to = forward_to
|
||||
|
||||
# add all consumers that are enabled for this particular configuration
|
||||
self._consumers = []
|
||||
for Consumer in consumer_classes:
|
||||
consumer = Consumer(context, source, forward_to)
|
||||
if consumer.is_enabled():
|
||||
self._consumers.append(consumer)
|
||||
|
||||
async def run(self):
|
||||
"""Start doing things.
|
||||
|
||||
Can be stopped by stopping the asyncio loop. This loop
|
||||
reads events from a single device only.
|
||||
"""
|
||||
for consumer in self._consumers:
|
||||
# run all of them in parallel
|
||||
asyncio.ensure_future(consumer.run())
|
||||
|
||||
logger.debug(
|
||||
"Starting to listen for events from %s, fd %s",
|
||||
self._source.path,
|
||||
self._source.fd,
|
||||
)
|
||||
|
||||
async for event in self._source.async_read_loop():
|
||||
if event.type == evdev.ecodes.EV_KEY and event.value == 2:
|
||||
# button-hold event. Environments (gnome, etc.) create them on
|
||||
# their own for the injection-fake-device if the release event
|
||||
# won't appear, no need to forward or map them.
|
||||
continue
|
||||
|
||||
handled = False
|
||||
for consumer in self._consumers:
|
||||
# copy so that the consumer doesn't screw this up for
|
||||
# all other future consumers
|
||||
event_copy = evdev.InputEvent(
|
||||
sec=event.sec,
|
||||
usec=event.usec,
|
||||
type=event.type,
|
||||
code=event.code,
|
||||
value=event.value,
|
||||
)
|
||||
if consumer.is_handled(event_copy):
|
||||
await consumer.notify(event_copy)
|
||||
handled = True
|
||||
|
||||
if not handled:
|
||||
# forward the rest
|
||||
self._forward_to.write(event.type, event.code, event.value)
|
||||
# this already includes SYN events, so need to syn here again
|
||||
|
||||
# This happens all the time in tests because the async_read_loop stops when
|
||||
# there is nothing to read anymore. Otherwise tests would block.
|
||||
logger.error('The async_read_loop for "%s" stopped early', self._source.path)
|
@ -1,83 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Consumer base class.
|
||||
|
||||
Can be notified of new events so that inheriting classes can map them and
|
||||
inject new events based on them.
|
||||
"""
|
||||
|
||||
|
||||
class Consumer:
|
||||
"""Can be notified of new events to inject them. Base class."""
|
||||
|
||||
def __init__(self, context, source, forward_to=None):
|
||||
"""Initialize event consuming functionality.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
context : Context
|
||||
The configuration of the Injector process
|
||||
source : InputDevice
|
||||
Where events used in handle_keycode come from
|
||||
forward_to : evdev.UInput
|
||||
Where to write keycodes to that were not mapped to anything.
|
||||
Should be an UInput with capabilities that work for all forwarded
|
||||
events, so ideally they should be copied from source.
|
||||
"""
|
||||
self.context = context
|
||||
self.forward_to = forward_to
|
||||
self.source = source
|
||||
self.context.update_purposes()
|
||||
|
||||
def is_enabled(self):
|
||||
"""Check if the consumer will have work to do."""
|
||||
raise NotImplementedError
|
||||
|
||||
def forward(self, key):
|
||||
"""Shorthand to forward an event."""
|
||||
self.forward_to.write(*key)
|
||||
|
||||
async def notify(self, event):
|
||||
"""A new event is ready.
|
||||
|
||||
Overwrite this function if the consumer should do something each time
|
||||
a new event arrives. E.g. mapping a single button once clicked.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def is_handled(self, event):
|
||||
"""Check if the consumer will take care of this event.
|
||||
|
||||
If this returns true, the event will not be forwarded anymore
|
||||
automatically. If you want to forward the event after all you can
|
||||
inject it into `self.forward_to`.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def run(self):
|
||||
"""Start doing things.
|
||||
|
||||
Overwrite this function if the consumer should do something
|
||||
continuously even if no new event arrives. e.g. continuously injecting
|
||||
mouse movement events.
|
||||
"""
|
||||
raise NotImplementedError
|
@ -1,273 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Keeps mapping joystick to mouse movements."""
|
||||
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from evdev.ecodes import (
|
||||
EV_REL,
|
||||
REL_X,
|
||||
REL_Y,
|
||||
REL_WHEEL,
|
||||
REL_HWHEEL,
|
||||
EV_ABS,
|
||||
ABS_X,
|
||||
ABS_Y,
|
||||
ABS_RX,
|
||||
ABS_RY,
|
||||
)
|
||||
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.configs.global_config import MOUSE, WHEEL
|
||||
from inputremapper import utils
|
||||
from inputremapper.injection.consumers.consumer import Consumer
|
||||
from inputremapper.groups import classify, GAMEPAD
|
||||
from inputremapper.injection.global_uinputs import global_uinputs
|
||||
|
||||
# miniscule movements on the joystick should not trigger a mouse wheel event
|
||||
WHEEL_THRESHOLD = 0.15
|
||||
|
||||
|
||||
def abs_max(value_1, value_2):
|
||||
"""Get the value with the higher abs value."""
|
||||
if abs(value_1) > abs(value_2):
|
||||
return value_1
|
||||
return value_2
|
||||
|
||||
|
||||
class JoystickToMouse(Consumer):
|
||||
"""Keeps producing events at 60hz if needed.
|
||||
|
||||
Maps joysticks to mouse movements.
|
||||
|
||||
This class does not handle injecting macro stuff over time, that is done
|
||||
by the keycode_mapper.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Construct the event producer without it doing anything yet."""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._abs_range = None
|
||||
self._set_abs_range_from(self.source)
|
||||
|
||||
# events only take ints, so a movement of 0.3 needs to add
|
||||
# up to 1.2 to affect the cursor, with 0.2 remaining
|
||||
self.pending_rel = {REL_X: 0, REL_Y: 0, REL_WHEEL: 0, REL_HWHEEL: 0}
|
||||
# the last known position of the joystick
|
||||
self.abs_state = {ABS_X: 0, ABS_Y: 0, ABS_RX: 0, ABS_RY: 0}
|
||||
|
||||
def is_enabled(self):
|
||||
gamepad = classify(self.source) == GAMEPAD
|
||||
return gamepad and self.context.joystick_as_mouse()
|
||||
|
||||
def _write(self, ev_type, keycode, value):
|
||||
"""Inject."""
|
||||
# if the mouse won't move even though correct stuff is written here,
|
||||
# the capabilities are probably wrong
|
||||
try:
|
||||
global_uinputs.write((ev_type, keycode, value), "mouse")
|
||||
except OverflowError:
|
||||
# screwed up the calculation of mouse movements
|
||||
logger.error("OverflowError (%s, %s, %s)", ev_type, keycode, value)
|
||||
|
||||
def accumulate(self, code, input_value):
|
||||
"""Since devices can't do float values, stuff has to be accumulated.
|
||||
|
||||
If pending is 0.6 and input_value is 0.5, return 0.1 and 1.
|
||||
Because it should move 1px, and 0.1px is rememberd for the next value
|
||||
in pending.
|
||||
"""
|
||||
self.pending_rel[code] += input_value
|
||||
output_value = int(self.pending_rel[code])
|
||||
self.pending_rel[code] -= output_value
|
||||
return output_value
|
||||
|
||||
def _set_abs_range_from(self, device):
|
||||
"""Update the min and max values joysticks will report.
|
||||
|
||||
This information is needed for abs -> rel mapping.
|
||||
"""
|
||||
if device is None:
|
||||
# I don't think this ever happened
|
||||
logger.error("Expected device to not be None")
|
||||
return
|
||||
|
||||
abs_range = utils.get_abs_range(device)
|
||||
if abs_range is None:
|
||||
return
|
||||
|
||||
if abs_range[1] in [0, 1, None]:
|
||||
# max abs_range of joysticks is usually a much higher number
|
||||
return
|
||||
|
||||
self.set_abs_range(*abs_range)
|
||||
logger.debug('ABS range of "%s": %s', device.name, abs_range)
|
||||
|
||||
def set_abs_range(self, min_abs, max_abs):
|
||||
"""Update the min and max values joysticks will report.
|
||||
|
||||
This information is needed for abs -> rel mapping.
|
||||
"""
|
||||
self._abs_range = (min_abs, max_abs)
|
||||
|
||||
# all joysticks in resting position by default
|
||||
center = (self._abs_range[1] + self._abs_range[0]) / 2
|
||||
self.abs_state = {ABS_X: center, ABS_Y: center, ABS_RX: center, ABS_RY: center}
|
||||
|
||||
def get_abs_values(self):
|
||||
"""Get the raw values for wheel and mouse movement.
|
||||
|
||||
Returned values center around 0 and are normalized into -1 and 1.
|
||||
|
||||
If two joysticks have the same purpose, the one that reports higher
|
||||
absolute values takes over the control.
|
||||
"""
|
||||
# center is the value of the resting position
|
||||
center = (self._abs_range[1] + self._abs_range[0]) / 2
|
||||
# normalizer is the maximum possible value after centering
|
||||
normalizer = (self._abs_range[1] - self._abs_range[0]) / 2
|
||||
|
||||
mouse_x = 0
|
||||
mouse_y = 0
|
||||
wheel_x = 0
|
||||
wheel_y = 0
|
||||
|
||||
def standardize(value):
|
||||
return (value - center) / normalizer
|
||||
|
||||
if self.context.left_purpose == MOUSE:
|
||||
mouse_x = abs_max(mouse_x, standardize(self.abs_state[ABS_X]))
|
||||
mouse_y = abs_max(mouse_y, standardize(self.abs_state[ABS_Y]))
|
||||
|
||||
if self.context.left_purpose == WHEEL:
|
||||
wheel_x = abs_max(wheel_x, standardize(self.abs_state[ABS_X]))
|
||||
wheel_y = abs_max(wheel_y, standardize(self.abs_state[ABS_Y]))
|
||||
|
||||
if self.context.right_purpose == MOUSE:
|
||||
mouse_x = abs_max(mouse_x, standardize(self.abs_state[ABS_RX]))
|
||||
mouse_y = abs_max(mouse_y, standardize(self.abs_state[ABS_RY]))
|
||||
|
||||
if self.context.right_purpose == WHEEL:
|
||||
wheel_x = abs_max(wheel_x, standardize(self.abs_state[ABS_RX]))
|
||||
wheel_y = abs_max(wheel_y, standardize(self.abs_state[ABS_RY]))
|
||||
|
||||
# Some joysticks report from 0 to 255 (EMV101),
|
||||
# others from -32768 to 32767 (X-Box 360 Pad)
|
||||
return mouse_x, mouse_y, wheel_x, wheel_y
|
||||
|
||||
def is_handled(self, event):
|
||||
"""Check if the event is something this will take care of."""
|
||||
if event.type != EV_ABS or event.code not in utils.JOYSTICK:
|
||||
return False
|
||||
|
||||
if self._abs_range is None:
|
||||
return False
|
||||
|
||||
purposes = [MOUSE, WHEEL]
|
||||
left_purpose = self.context.left_purpose
|
||||
right_purpose = self.context.right_purpose
|
||||
|
||||
if event.code in (ABS_X, ABS_Y) and left_purpose in purposes:
|
||||
return True
|
||||
|
||||
if event.code in (ABS_RX, ABS_RY) and right_purpose in purposes:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def notify(self, event):
|
||||
if event.type == EV_ABS and event.code in self.abs_state:
|
||||
self.abs_state[event.code] = event.value
|
||||
|
||||
async def run(self):
|
||||
"""Keep writing mouse movements based on the gamepad stick position.
|
||||
|
||||
Even if no new input event arrived because the joystick remained at
|
||||
its position, this will keep injecting the mouse movement events.
|
||||
"""
|
||||
abs_range = self._abs_range
|
||||
preset = self.context.preset
|
||||
pointer_speed = preset.get("gamepad.joystick.pointer_speed")
|
||||
non_linearity = preset.get("gamepad.joystick.non_linearity")
|
||||
x_scroll_speed = preset.get("gamepad.joystick.x_scroll_speed")
|
||||
y_scroll_speed = preset.get("gamepad.joystick.y_scroll_speed")
|
||||
max_speed = 2**0.5 # for normalized abs event values
|
||||
|
||||
if abs_range is not None:
|
||||
logger.info(
|
||||
"Left joystick as %s, right joystick as %s",
|
||||
self.context.left_purpose,
|
||||
self.context.right_purpose,
|
||||
)
|
||||
|
||||
start = time.time()
|
||||
while True:
|
||||
# try to do this as close to 60hz as possible
|
||||
time_taken = time.time() - start
|
||||
await asyncio.sleep(max(0.0, (1 / 60) - time_taken))
|
||||
start = time.time()
|
||||
|
||||
if abs_range is None:
|
||||
# no ev_abs events will be mapped to ev_rel
|
||||
continue
|
||||
|
||||
abs_values = self.get_abs_values()
|
||||
|
||||
if len([val for val in abs_values if not -1 <= val <= 1]) > 0:
|
||||
logger.error("Inconsistent values: %s", abs_values)
|
||||
continue
|
||||
|
||||
mouse_x, mouse_y, wheel_x, wheel_y = abs_values
|
||||
|
||||
# mouse movements
|
||||
if abs(mouse_x) > 0 or abs(mouse_y) > 0:
|
||||
if non_linearity != 1:
|
||||
# to make small movements smaller for more precision
|
||||
speed = (mouse_x**2 + mouse_y**2) ** 0.5 # pythagoras
|
||||
factor = (speed / max_speed) ** non_linearity
|
||||
else:
|
||||
factor = 1
|
||||
|
||||
rel_x = mouse_x * factor * pointer_speed
|
||||
rel_y = mouse_y * factor * pointer_speed
|
||||
rel_x = self.accumulate(REL_X, rel_x)
|
||||
rel_y = self.accumulate(REL_Y, rel_y)
|
||||
if rel_x != 0:
|
||||
self._write(EV_REL, REL_X, rel_x)
|
||||
if rel_y != 0:
|
||||
self._write(EV_REL, REL_Y, rel_y)
|
||||
|
||||
# wheel movements
|
||||
if abs(wheel_x) > 0:
|
||||
change = wheel_x * x_scroll_speed
|
||||
value = self.accumulate(REL_WHEEL, change)
|
||||
if abs(change) > WHEEL_THRESHOLD * x_scroll_speed:
|
||||
self._write(EV_REL, REL_HWHEEL, value)
|
||||
|
||||
if abs(wheel_y) > 0:
|
||||
change = wheel_y * y_scroll_speed
|
||||
value = self.accumulate(REL_HWHEEL, change)
|
||||
if abs(change) > WHEEL_THRESHOLD * y_scroll_speed:
|
||||
self._write(EV_REL, REL_WHEEL, -value)
|
@ -1,554 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Inject a keycode based on the mapping."""
|
||||
|
||||
|
||||
import itertools
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import EV_KEY, EV_ABS
|
||||
|
||||
import inputremapper.exceptions
|
||||
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.configs.system_mapping import DISABLE_CODE
|
||||
from inputremapper import utils
|
||||
from inputremapper.injection.consumers.consumer import Consumer
|
||||
from inputremapper.utils import RELEASE
|
||||
from inputremapper.groups import classify, GAMEPAD
|
||||
from inputremapper.injection.global_uinputs import global_uinputs
|
||||
|
||||
|
||||
# this state is shared by all KeycodeMappers of this process
|
||||
|
||||
# maps mouse buttons to macro instances that have been executed.
|
||||
# They may still be running or already be done. Just like unreleased,
|
||||
# this is a mapping of (type, code). The value is not included in the
|
||||
# key, because a key release event with a value of 0 needs to be able
|
||||
# to find the running macro. The downside is that a d-pad cannot
|
||||
# execute two macros at once, one for each direction.
|
||||
# Only sequentially.
|
||||
active_macros = {}
|
||||
|
||||
|
||||
# mapping of future release event (type, code) to an Unreleased object,
|
||||
# All key-up events have a value of 0, so it is not added to
|
||||
# the tuple. This is needed in order to release the correct event
|
||||
# mapped on a D-Pad. Each direction on one D-Pad axis reports the
|
||||
# same type and code, but different values. There cannot be both at
|
||||
# the same time, as pressing one side of a D-Pad forces the other
|
||||
# side to go up. If both sides of a D-Pad are mapped to different
|
||||
# event-codes, this data structure helps to figure out which of those
|
||||
# two to release on an event of value 0. Same goes for the Wheel.
|
||||
# The input event is remembered to make sure no duplicate down-events
|
||||
# are written. Since wheels report a lot of "down" events that don't
|
||||
# serve any purpose when mapped to a key, those duplicate down events
|
||||
# should be removed. If the same type and code arrives but with a
|
||||
# different value (direction), there must be a way to check if the
|
||||
# event is actually a duplicate and not a different event.
|
||||
unreleased = {}
|
||||
|
||||
|
||||
COMBINATION_INCOMPLETE = 1 # not all keys of the combination are pressed
|
||||
NOT_COMBINED = 2 # this key is not part of a combination
|
||||
|
||||
|
||||
def subsets(combination):
|
||||
"""Return a list of subsets of the combination.
|
||||
|
||||
If combination is only one element long it returns an empty list,
|
||||
because it's not a combination and there is no reason to iterate.
|
||||
|
||||
Includes the complete input as well.
|
||||
|
||||
Parameters
|
||||
-----------
|
||||
combination : tuple
|
||||
tuple of 3-tuples, each being int, int, int (type, code, action)
|
||||
"""
|
||||
combination = list(combination)
|
||||
lengths = list(range(2, len(combination) + 1))
|
||||
lengths.reverse()
|
||||
return list(
|
||||
itertools.chain.from_iterable(
|
||||
itertools.combinations(combination, length) for length in lengths
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Unreleased:
|
||||
"""This represents a key that has been pressed but not released yet."""
|
||||
|
||||
__slots__ = (
|
||||
"target",
|
||||
"input_event_tuple",
|
||||
"triggered_key",
|
||||
)
|
||||
|
||||
def __init__(self, target, input_event_tuple, triggered_key):
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
target : 3-tuple
|
||||
int type, int code of what was injected or forwarded
|
||||
and string target_uinput for injected events,
|
||||
None for forwarded events
|
||||
input_event_tuple : 3-tuple
|
||||
int, int, int / type, code, action
|
||||
triggered_key : tuple of 3-tuples
|
||||
What was used to index key_to_code or macros when stuff
|
||||
was triggered.
|
||||
If nothing was triggered and input_event_tuple forwarded,
|
||||
insert None.
|
||||
"""
|
||||
self.target = target
|
||||
self.input_event_tuple = input_event_tuple
|
||||
self.triggered_key = triggered_key
|
||||
|
||||
if not isinstance(input_event_tuple[0], int) or len(input_event_tuple) != 3:
|
||||
raise ValueError(
|
||||
"Expected input_event_tuple to be a 3-tuple of ints, but "
|
||||
f"got {input_event_tuple}"
|
||||
)
|
||||
|
||||
unreleased[input_event_tuple[:2]] = self
|
||||
|
||||
def is_mapped(self):
|
||||
"""If true, the key-down event was written to context.uinput.
|
||||
|
||||
That means the release event should also be injected into that one.
|
||||
If this returns false, just forward the release event instead.
|
||||
"""
|
||||
# This should end up being equal to context.is_mapped(key)
|
||||
return self.triggered_key is not None
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
"Unreleased("
|
||||
f"target{self.target},"
|
||||
f"input{self.input_event_tuple},"
|
||||
f'key{self.triggered_key or "(None)"}'
|
||||
")"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
|
||||
def find_by_event(key):
|
||||
"""Find an unreleased entry by an event.
|
||||
|
||||
If such an entry exists, it was created by an event that is exactly
|
||||
like the input parameter (except for the timestamp).
|
||||
|
||||
That doesn't mean it triggered something, only that it was seen before.
|
||||
"""
|
||||
unreleased_entry = unreleased.get(key[:2])
|
||||
if unreleased_entry and unreleased_entry.input_event_tuple == key:
|
||||
return unreleased_entry
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_by_key(key):
|
||||
"""Find an unreleased entry by a combination of keys.
|
||||
|
||||
If such an entry exist, it was created when a combination of keys
|
||||
(which matches the parameter, can also be of len 1 = single key)
|
||||
ended up triggering something.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : tuple of int
|
||||
type, code, action
|
||||
"""
|
||||
unreleased_entry = unreleased.get(key[-1][:2])
|
||||
if unreleased_entry and unreleased_entry.triggered_key == key:
|
||||
return unreleased_entry
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class KeycodeMapper(Consumer):
|
||||
"""Injects keycodes and starts macros.
|
||||
|
||||
This really is somewhat complicated because it needs to be able to handle
|
||||
combinations (which is actually not that trivial because the order of keys
|
||||
matters). The nature of some events (D-Pads and Wheels) adds to the
|
||||
complexity. Since macros are mapped the same way keys are, this class
|
||||
takes care of both.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Create a keycode mapper for one virtual device.
|
||||
|
||||
There may be multiple KeycodeMappers for one hardware device. They
|
||||
share some state (unreleased and active_macros) with each other.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._abs_range = None
|
||||
|
||||
if self.context.maps_joystick():
|
||||
self._abs_range = utils.get_abs_range(self.source)
|
||||
|
||||
self._gamepad = classify(self.source) == GAMEPAD
|
||||
|
||||
self.debounces = {}
|
||||
|
||||
# some type checking, prevents me from forgetting what that stuff
|
||||
# is supposed to be when writing tests.
|
||||
for combination in self.context.key_to_code:
|
||||
for event in combination:
|
||||
if abs(event.value) > 1:
|
||||
raise ValueError(
|
||||
f"Expected values to be one of -1, 0 or 1, "
|
||||
f"but got {combination}"
|
||||
)
|
||||
|
||||
def is_enabled(self):
|
||||
# even if the source does not provide a capability that is used here, it might
|
||||
# be important for notifying macros of new events that run on other sources.
|
||||
return len(self.context.key_to_code) > 0 or len(self.context.macros) > 0
|
||||
|
||||
def is_handled(self, event):
|
||||
return utils.should_map_as_btn(event, self.context.preset, self._gamepad)
|
||||
|
||||
async def run(self):
|
||||
"""Provide a debouncer to inject wheel releases."""
|
||||
start = time.time()
|
||||
while True:
|
||||
# try to do this as close to 60hz as possible
|
||||
time_taken = time.time() - start
|
||||
await asyncio.sleep(max(0.0, (1 / 60) - time_taken))
|
||||
start = time.time()
|
||||
|
||||
for debounce in self.debounces.values():
|
||||
if debounce[2] == -1:
|
||||
# has already been triggered
|
||||
continue
|
||||
if debounce[2] == 0:
|
||||
debounce[0](*debounce[1])
|
||||
debounce[2] = -1
|
||||
else:
|
||||
debounce[2] -= 1
|
||||
|
||||
def debounce(self, debounce_id, func, args, ticks):
|
||||
"""Debounce a function call.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
debounce_id : hashable
|
||||
If this function is called with the same debounce_id again,
|
||||
the previous debouncing is overwritten, and therefore restarted.
|
||||
func : function
|
||||
args : tuple
|
||||
ticks : int
|
||||
After ticks * 1 / 60 seconds the function will be executed,
|
||||
unless debounce is called again with the same debounce_id
|
||||
"""
|
||||
self.debounces[debounce_id] = [func, args, ticks]
|
||||
|
||||
async def notify(self, event):
|
||||
"""Receive the newest event that should be mapped."""
|
||||
action = utils.classify_action(event, self._abs_range)
|
||||
|
||||
for macro, _ in self.context.macros.values():
|
||||
macro.notify(event, action)
|
||||
|
||||
will_report_key_up = utils.will_report_key_up(event)
|
||||
if not will_report_key_up:
|
||||
# simulate a key-up event if no down event arrives anymore.
|
||||
# this may release macros, combinations or keycodes.
|
||||
release = evdev.InputEvent(0, 0, event.type, event.code, 0)
|
||||
self.debounce(
|
||||
debounce_id=(event.type, event.code, action),
|
||||
func=self.handle_keycode,
|
||||
args=(release, RELEASE, False),
|
||||
ticks=3,
|
||||
)
|
||||
|
||||
async def delayed_handle_keycode():
|
||||
# give macros a priority of working on their asyncio iterations
|
||||
# first before handle_keycode. This is important for if_single.
|
||||
# If if_single injects a modifier to modify the key that canceled
|
||||
# its sleep, it needs to inject it before handle_keycode injects
|
||||
# anything. This is important for the space cadet shift.
|
||||
# 1. key arrives
|
||||
# 2. stop if_single
|
||||
# 3. make if_single inject `then`
|
||||
# 4. inject key
|
||||
# But I can't just wait for if_single to do its thing because it might
|
||||
# be a macro that sleeps for a few seconds.
|
||||
# This appears to me to be incredibly race-conditiony. For that
|
||||
# reason wait a few more asyncio ticks before continuing.
|
||||
# But a single one also worked. I can't wait for the specific
|
||||
# macro task here because it might block forever. I'll just give
|
||||
# it a few asyncio iterations advance before continuing here.
|
||||
for _ in range(10):
|
||||
# Noticable delays caused by this start at 10000 iterations
|
||||
# Also see the python docs on asyncio.sleep. Sleeping for 0
|
||||
# seconds just iterates the loop once.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
self.handle_keycode(event, action)
|
||||
|
||||
await delayed_handle_keycode()
|
||||
|
||||
def macro_write(self, target_uinput):
|
||||
def f(ev_type, code, value):
|
||||
"""Handler for macros."""
|
||||
logger.debug(
|
||||
f"Macro sending %s to %s", (ev_type, code, value), target_uinput
|
||||
)
|
||||
global_uinputs.write((ev_type, code, value), target_uinput)
|
||||
|
||||
return f
|
||||
|
||||
def _get_key(self, key):
|
||||
"""If the event triggers stuff, get the key for that.
|
||||
|
||||
This key can be used to index `key_to_code` and `macros` and it might
|
||||
be a combination of keys.
|
||||
|
||||
Otherwise, for unmapped events, returns the input.
|
||||
|
||||
The return format is always a tuple of 3-tuples, each 3-tuple being
|
||||
type, code, action (int, int, int)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : tuple of int
|
||||
3-tuple of type, code, action
|
||||
Action should be one of -1, 0 or 1
|
||||
"""
|
||||
unreleased_entry = find_by_event(key)
|
||||
|
||||
# The key used to index the mappings `key_to_code` and `macros`.
|
||||
# If the key triggers a combination, the returned key will be that one
|
||||
# instead
|
||||
action = key[2]
|
||||
key = (key,)
|
||||
|
||||
if unreleased_entry and unreleased_entry.triggered_key is not None:
|
||||
# seen before. If this key triggered a combination,
|
||||
# use the combination that was triggered by this as key.
|
||||
return unreleased_entry.triggered_key
|
||||
|
||||
if utils.is_key_down(action):
|
||||
# get the key/combination that the key-down would trigger
|
||||
|
||||
# the triggering key-down has to be the last element in
|
||||
# combination, all others can have any arbitrary order. By
|
||||
# checking all unreleased keys, a + b + c takes priority over
|
||||
# b + c, if both mappings exist.
|
||||
# WARNING! the combination-down triggers, but a single key-up
|
||||
# releases. Do not check if key in macros and such, if it is an
|
||||
# up event. It's going to be False.
|
||||
combination = tuple(
|
||||
value.input_event_tuple for value in unreleased.values()
|
||||
)
|
||||
if key[0] not in combination: # might be a duplicate-down event
|
||||
combination += key
|
||||
|
||||
# find any triggered combination. macros and key_to_code contain
|
||||
# every possible equivalent permutation of possible macros. The
|
||||
# last key in the combination needs to remain the newest key
|
||||
# though.
|
||||
for subset in subsets(combination):
|
||||
if subset[-1] != key[0]:
|
||||
# only combinations that are completed and triggered by
|
||||
# the newest input are of interest
|
||||
continue
|
||||
|
||||
if self.context.is_mapped(subset):
|
||||
key = subset
|
||||
break
|
||||
else:
|
||||
# no subset found, just use the key. all indices are tuples of
|
||||
# tuples, both for combinations and single keys.
|
||||
if len(combination) > 1:
|
||||
logger.debug_key(combination, "unknown combination")
|
||||
|
||||
return key
|
||||
|
||||
def handle_keycode(self, event, action, forward=True):
|
||||
"""Write mapped keycodes, forward unmapped ones and manage macros.
|
||||
|
||||
As long as the provided event is mapped it will handle it, it won't
|
||||
check any type, code or capability anymore. Otherwise it forwards
|
||||
it as it is.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
action : int
|
||||
One of PRESS, PRESS_NEGATIVE or RELEASE
|
||||
Just looking at the events value is not enough, because then mapping
|
||||
trigger-values that are between 1 and 255 is not possible. They might skip
|
||||
the 1 when pressed fast enough.
|
||||
event : evdev.InputEvent
|
||||
forward : bool
|
||||
if False, will not forward the event if it didn't trigger any
|
||||
mapping
|
||||
"""
|
||||
assert isinstance(action, int)
|
||||
|
||||
type_and_code = (event.type, event.code)
|
||||
active_macro = active_macros.get(type_and_code)
|
||||
original_tuple = (event.type, event.code, event.value)
|
||||
key = self._get_key((*type_and_code, action))
|
||||
is_mapped = self.context.is_mapped(key)
|
||||
|
||||
"""Releasing keys and macros"""
|
||||
|
||||
if utils.is_key_up(action):
|
||||
if active_macro is not None and active_macro.is_holding():
|
||||
# Tell the macro for that keycode that the key is released and
|
||||
# let it decide what to do with that information.
|
||||
active_macro.release_trigger()
|
||||
logger.debug_key(key, "releasing macro")
|
||||
|
||||
if type_and_code in unreleased:
|
||||
# figure out what this release event was for
|
||||
unreleased_entry = unreleased[type_and_code]
|
||||
target_type, target_code, target_uinput = unreleased_entry.target
|
||||
del unreleased[type_and_code]
|
||||
|
||||
if target_code == DISABLE_CODE:
|
||||
logger.debug_key(key, "releasing disabled key")
|
||||
return
|
||||
|
||||
if target_code is None:
|
||||
logger.debug_key(key, "releasing key")
|
||||
return
|
||||
|
||||
if unreleased_entry.is_mapped():
|
||||
# release what the input is mapped to
|
||||
try:
|
||||
logger.debug_key(
|
||||
key, "releasing (%s, %s)", target_code, target_uinput
|
||||
)
|
||||
global_uinputs.write(
|
||||
(target_type, target_code, 0), target_uinput
|
||||
)
|
||||
return
|
||||
except inputremapper.exceptions.Error:
|
||||
logger.debug_key(key, "could not map")
|
||||
pass
|
||||
|
||||
if forward:
|
||||
# forward the release event
|
||||
logger.debug_key((original_tuple,), "forwarding release")
|
||||
self.forward(original_tuple)
|
||||
else:
|
||||
logger.debug_key(key, "not forwarding release")
|
||||
|
||||
return
|
||||
|
||||
if event.type != EV_ABS:
|
||||
# ABS events might be spammed like crazy every time the
|
||||
# position slightly changes
|
||||
logger.debug_key(key, "unexpected key up")
|
||||
|
||||
# everything that can be released is released now
|
||||
return
|
||||
|
||||
"""Filtering duplicate key downs"""
|
||||
|
||||
if is_mapped and utils.is_key_down(action):
|
||||
# unmapped keys should not be filtered here, they should just
|
||||
# be forwarded to populate unreleased and then be written.
|
||||
|
||||
if find_by_key(key) is not None:
|
||||
# this key/combination triggered stuff before.
|
||||
# duplicate key-down. skip this event. Avoid writing millions
|
||||
# of key-down events when a continuous value is reported, for
|
||||
# example for gamepad triggers or mouse-wheel-side buttons
|
||||
logger.debug_key(key, "duplicate key down")
|
||||
return
|
||||
|
||||
# it would start a macro usually
|
||||
in_macros = key in self.context.macros
|
||||
running = active_macro and active_macro.running
|
||||
if in_macros and running:
|
||||
# for key-down events and running macros, don't do anything.
|
||||
# This avoids spawning a second macro while the first one is
|
||||
# not finished, especially since gamepad-triggers report a ton
|
||||
# of events with a positive value.
|
||||
logger.debug_key(key, "macro already running")
|
||||
self.context.macros[key][0].press_trigger()
|
||||
return
|
||||
|
||||
"""starting new macros or injecting new keys"""
|
||||
|
||||
if utils.is_key_down(action):
|
||||
# also enter this for unmapped keys, as they might end up
|
||||
# triggering a combination, so they should be remembered in
|
||||
# unreleased
|
||||
|
||||
if key in self.context.macros:
|
||||
macro, target_uinput = self.context.macros[key]
|
||||
active_macros[type_and_code] = macro
|
||||
Unreleased((None, None, None), (*type_and_code, action), key)
|
||||
macro.press_trigger()
|
||||
logger.debug_key(
|
||||
key, "maps to macro (%s, %s)", macro.code, target_uinput
|
||||
)
|
||||
asyncio.ensure_future(macro.run(self.macro_write(target_uinput)))
|
||||
return
|
||||
|
||||
if key in self.context.key_to_code:
|
||||
target_code, target_uinput = self.context.key_to_code[key]
|
||||
# remember the key that triggered this
|
||||
# (this combination or this single key)
|
||||
Unreleased(
|
||||
(EV_KEY, target_code, target_uinput), (*type_and_code, action), key
|
||||
)
|
||||
|
||||
if target_code == DISABLE_CODE:
|
||||
logger.debug_key(key, "disabled")
|
||||
return
|
||||
|
||||
try:
|
||||
logger.debug_key(
|
||||
key, "maps to (%s, %s)", target_code, target_uinput
|
||||
)
|
||||
global_uinputs.write((EV_KEY, target_code, 1), target_uinput)
|
||||
return
|
||||
except inputremapper.exceptions.Error:
|
||||
logger.debug_key(key, "could not map")
|
||||
pass
|
||||
|
||||
if forward:
|
||||
logger.debug_key((original_tuple,), "forwarding")
|
||||
self.forward(original_tuple)
|
||||
else:
|
||||
logger.debug_key(((*type_and_code, action),), "not forwarding")
|
||||
|
||||
# unhandled events may still be important for triggering
|
||||
# combinations later, so remember them as well.
|
||||
Unreleased((*type_and_code, None), (*type_and_code, action), None)
|
||||
return
|
||||
|
||||
logger.error("%s unhandled", key)
|
@ -0,0 +1,169 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Because multiple calls to async_read_loop won't work."""
|
||||
import asyncio
|
||||
import evdev
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.injection.context import Context
|
||||
|
||||
|
||||
class _ReadLoop:
|
||||
def __init__(self, device: evdev.InputDevice, stop_event: asyncio.Event):
|
||||
self.iterator = device.async_read_loop().__aiter__()
|
||||
self.stop_event = stop_event
|
||||
self.wait_for_stop = asyncio.Task(stop_event.wait())
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
def __anext__(self):
|
||||
if self.stop_event.is_set():
|
||||
raise StopAsyncIteration
|
||||
|
||||
return self.future()
|
||||
|
||||
async def future(self):
|
||||
ev_task = asyncio.Task(self.iterator.__anext__())
|
||||
stop_task = self.wait_for_stop
|
||||
done, pending = await asyncio.wait(
|
||||
{ev_task, stop_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if stop_task in done:
|
||||
raise StopAsyncIteration
|
||||
|
||||
return done.pop().result()
|
||||
|
||||
|
||||
class EventReader:
|
||||
"""Reads input events from a single device and distributes them.
|
||||
|
||||
There is one EventReader object for each source, which tells multiple mapping_handlers
|
||||
that a new event is ready so that they can inject all sorts of funny
|
||||
things.
|
||||
|
||||
Other devnodes may be present for the hardware device, in which case this
|
||||
needs to be created multiple times.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context: Context,
|
||||
source: evdev.InputDevice,
|
||||
forward_to: evdev.UInput,
|
||||
stop_event: asyncio.Event,
|
||||
) -> None:
|
||||
"""Initialize all mapping_handlers
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source : evdev.InputDevice
|
||||
where to read keycodes from
|
||||
forward_to : evdev.UInput
|
||||
where to write keycodes to that were not mapped to anything.
|
||||
Should be an UInput with capabilities that work for all forwarded
|
||||
events, so ideally they should be copied from source.
|
||||
"""
|
||||
self._source = source
|
||||
self._forward_to = forward_to
|
||||
self.context = context
|
||||
self.stop_event = stop_event
|
||||
|
||||
def send_to_handlers(self, event: InputEvent) -> bool:
|
||||
"""Send the event to callback."""
|
||||
if event.type == evdev.ecodes.EV_MSC:
|
||||
return False
|
||||
|
||||
if event.type == evdev.ecodes.EV_SYN:
|
||||
return False
|
||||
|
||||
results = set()
|
||||
for callback in self.context.callbacks.get(event.type_and_code) or ():
|
||||
results.add(callback(event, source=self._source, forward=self._forward_to))
|
||||
|
||||
return True in results
|
||||
|
||||
async def send_to_listeners(self, event: InputEvent) -> None:
|
||||
"""Send the event to listeners."""
|
||||
if event.type == evdev.ecodes.EV_MSC:
|
||||
return
|
||||
|
||||
if event.type == evdev.ecodes.EV_SYN:
|
||||
return
|
||||
|
||||
for listener in self.context.listeners.copy():
|
||||
# use a copy, since the listeners might remove themselves form the set
|
||||
|
||||
# fire and forget, run them in parallel and don't wait for them, since
|
||||
# a listener might be blocking forever while waiting for more events.
|
||||
asyncio.ensure_future(listener(event))
|
||||
|
||||
# Running macros have priority, give them a head-start for processing the
|
||||
# event. If if_single injects a modifier, this modifier should be active
|
||||
# before the next handler injects an "a" or something, so that it is
|
||||
# possible to capitalize it via if_single.
|
||||
# 1. Event from keyboard arrives (e.g. an "a")
|
||||
# 2. the listener for if_single is called
|
||||
# 3. if_single decides runs then (e.g. injects shift_L)
|
||||
# 4. The original event is forwarded (or whatever it is supposed to do)
|
||||
# 5. Capitalized "A" is injected.
|
||||
# So make sure to call the listeners before notifying the handlers.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
def forward(self, event: InputEvent) -> None:
|
||||
"""Forward an event, which injects it unmodified."""
|
||||
if event.type == evdev.ecodes.EV_KEY:
|
||||
logger.debug_key(event.event_tuple, "forwarding")
|
||||
|
||||
self._forward_to.write(*event.event_tuple)
|
||||
|
||||
async def handle(self, event: InputEvent) -> None:
|
||||
if event.type == evdev.ecodes.EV_KEY and event.value == 2:
|
||||
# button-hold event. Environments (gnome, etc.) create them on
|
||||
# their own for the injection-fake-device if the release event
|
||||
# won't appear, no need to forward or map them.
|
||||
return
|
||||
|
||||
await self.send_to_listeners(event)
|
||||
|
||||
if not self.send_to_handlers(event):
|
||||
# no handler took care of it, forward it
|
||||
self.forward(event)
|
||||
|
||||
async def run(self):
|
||||
"""Start doing things.
|
||||
|
||||
Can be stopped by stopping the asyncio loop. This loop
|
||||
reads events from a single device only.
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting to listen for events from %s, fd %s",
|
||||
self._source.path,
|
||||
self._source.fd,
|
||||
)
|
||||
|
||||
async for event in _ReadLoop(self._source, self.stop_event):
|
||||
await self.handle(InputEvent.from_event(event))
|
||||
|
||||
self.context.reset()
|
||||
logger.info("read loop for %s stopped", self._source.path)
|
@ -0,0 +1,129 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import evdev
|
||||
from typing import Optional, Dict, Tuple
|
||||
|
||||
from evdev.ecodes import EV_ABS
|
||||
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
MappingHandler,
|
||||
ContextProtocol,
|
||||
HandlerEnums,
|
||||
InputEventHandler,
|
||||
)
|
||||
|
||||
|
||||
class AbsToBtnHandler(MappingHandler):
|
||||
"""Handler which transforms an EV_ABS to a button event"""
|
||||
|
||||
_input_event: InputEvent
|
||||
_active: bool
|
||||
_sub_handler: InputEventHandler
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: EventCombination,
|
||||
mapping: Mapping,
|
||||
**_,
|
||||
):
|
||||
super().__init__(combination, mapping)
|
||||
|
||||
self._active = False
|
||||
self._input_event = combination[0]
|
||||
assert self._input_event.value != 0
|
||||
assert len(combination) == 1
|
||||
|
||||
def __str__(self):
|
||||
return f"AbsToBtnHandler for {self._input_event.event_tuple} <{id(self)}>:"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
@property
|
||||
def child(self): # used for logging
|
||||
return self._sub_handler
|
||||
|
||||
def _trigger_point(self, abs_min: int, abs_max: int) -> Tuple[float, float]:
|
||||
"""calculate the axis mid and trigger point"""
|
||||
# TODO: potentially cash this function
|
||||
if abs_min == -1 and abs_max == 1:
|
||||
# this is a hat switch
|
||||
return (
|
||||
self._input_event.value // abs(self._input_event.value),
|
||||
0,
|
||||
) # return +-1
|
||||
|
||||
half_range = (abs_max - abs_min) / 2
|
||||
middle = half_range + abs_min
|
||||
trigger_offset = half_range * self._input_event.value / 100
|
||||
|
||||
# threshold, middle
|
||||
return middle + trigger_offset, middle
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
forward: evdev.UInput,
|
||||
supress: bool = False,
|
||||
) -> bool:
|
||||
if event.type_and_code != self._input_event.type_and_code:
|
||||
return False
|
||||
|
||||
absinfo = {
|
||||
entry[0]: entry[1] for entry in source.capabilities(absinfo=True)[EV_ABS]
|
||||
}
|
||||
threshold, mid_point = self._trigger_point(
|
||||
absinfo[event.code].min, absinfo[event.code].max
|
||||
)
|
||||
value = event.value
|
||||
if (value < threshold > mid_point) or (value > threshold < mid_point):
|
||||
if self._active:
|
||||
event = event.modify(value=0, action=EventActions.as_key)
|
||||
else:
|
||||
# consume the event.
|
||||
# We could return False to forward events
|
||||
return True
|
||||
else:
|
||||
if not self._active:
|
||||
event = event.modify(value=1, action=EventActions.as_key)
|
||||
else:
|
||||
# consume the event.
|
||||
# We could return False to forward events
|
||||
return True
|
||||
|
||||
self._active = bool(event.value)
|
||||
logger.debug_key(event.event_tuple, "sending to sub_handler")
|
||||
return self._sub_handler.notify(
|
||||
event,
|
||||
source=source,
|
||||
forward=forward,
|
||||
supress=supress,
|
||||
)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._active = False
|
||||
self._sub_handler.reset()
|
@ -0,0 +1,220 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
from functools import partial
|
||||
|
||||
import evdev
|
||||
import time
|
||||
import asyncio
|
||||
import math
|
||||
|
||||
from typing import Dict, Tuple, Optional, List, Union
|
||||
from evdev.ecodes import (
|
||||
EV_REL,
|
||||
EV_ABS,
|
||||
REL_WHEEL,
|
||||
REL_HWHEEL,
|
||||
REL_WHEEL_HI_RES,
|
||||
REL_HWHEEL_HI_RES,
|
||||
)
|
||||
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
MappingHandler,
|
||||
HandlerEnums,
|
||||
InputEventHandler,
|
||||
)
|
||||
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.injection.global_uinputs import global_uinputs
|
||||
|
||||
|
||||
async def _run_normal(self) -> None:
|
||||
"""start injecting events"""
|
||||
self._running = True
|
||||
self._stop = False
|
||||
# logger.debug("starting AbsToRel loop")
|
||||
remainder = 0.0
|
||||
start = time.time()
|
||||
while not self._stop:
|
||||
float_value = self._value + remainder
|
||||
# float_value % 1 will result in wrong calculations for negative values
|
||||
remainder = math.fmod(float_value, 1)
|
||||
value = int(float_value)
|
||||
self._write(EV_REL, self.mapping.output_code, value)
|
||||
|
||||
time_taken = time.time() - start
|
||||
await asyncio.sleep(max(0.0, (1 / self.mapping.rate) - time_taken))
|
||||
start = time.time()
|
||||
|
||||
# logger.debug("stopping AbsToRel loop")
|
||||
self._running = False
|
||||
|
||||
|
||||
async def _run_wheel(
|
||||
self, codes: Tuple[int, int], weights: Tuple[float, float]
|
||||
) -> None:
|
||||
"""start injecting events"""
|
||||
self._running = True
|
||||
self._stop = False
|
||||
# logger.debug("starting AbsToRel loop")
|
||||
remainder = [0.0, 0.0]
|
||||
start = time.time()
|
||||
while not self._stop:
|
||||
for i in range(0, 2):
|
||||
float_value = self._value * weights[i] + remainder[i]
|
||||
# float_value % 1 will result in wrong calculations for negative values
|
||||
remainder[i] = math.fmod(float_value, 1)
|
||||
value = int(float_value)
|
||||
self._write(EV_REL, codes[i], value)
|
||||
|
||||
time_taken = time.time() - start
|
||||
await asyncio.sleep(max(0.0, (1 / self.mapping.rate) - time_taken))
|
||||
start = time.time()
|
||||
|
||||
# logger.debug("stopping AbsToRel loop")
|
||||
self._running = False
|
||||
|
||||
|
||||
class AbsToRelHandler(MappingHandler):
|
||||
"""Handler which transforms an EV_ABS to EV_REL events"""
|
||||
|
||||
_map_axis: Tuple[int, int] # the (type, code) of the axis we map
|
||||
_value: float # the current output value
|
||||
_running: bool # if the run method is active
|
||||
_stop: bool # if the run loop should return
|
||||
_transform: Optional[Transformation]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: EventCombination,
|
||||
mapping: Mapping,
|
||||
**_,
|
||||
) -> None:
|
||||
super().__init__(combination, mapping)
|
||||
|
||||
# find the input event we are supposed to map
|
||||
for event in combination:
|
||||
if event.value == 0:
|
||||
assert event.type == EV_ABS
|
||||
self._map_axis = event.type_and_code
|
||||
break
|
||||
|
||||
self._value = 0
|
||||
self._running = False
|
||||
self._stop = True
|
||||
self._transform = None
|
||||
|
||||
# bind the correct run method
|
||||
if self.mapping.output_code in (
|
||||
REL_WHEEL,
|
||||
REL_HWHEEL,
|
||||
REL_WHEEL_HI_RES,
|
||||
REL_HWHEEL_HI_RES,
|
||||
):
|
||||
if self.mapping.output_code in (REL_WHEEL, REL_WHEEL_HI_RES):
|
||||
codes = (REL_WHEEL, REL_WHEEL_HI_RES)
|
||||
else:
|
||||
codes = (REL_HWHEEL, REL_HWHEEL_HI_RES)
|
||||
|
||||
if self.mapping.output_code in (REL_WHEEL, REL_HWHEEL):
|
||||
weights = (1.0, 120.0)
|
||||
else:
|
||||
weights = (1 / 120, 1)
|
||||
|
||||
self._run = partial(_run_wheel, self, codes=codes, weights=weights)
|
||||
|
||||
else:
|
||||
self._run = partial(_run_normal, self)
|
||||
|
||||
def __str__(self):
|
||||
return f"AbsToRelHandler for {self._map_axis} <{id(self)}>:"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
@property
|
||||
def child(self): # used for logging
|
||||
return f"maps to: {self.mapping.output_code} at {self.mapping.target_uinput}"
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
forward: evdev.UInput = None,
|
||||
supress: bool = False,
|
||||
) -> bool:
|
||||
|
||||
if event.type_and_code != self._map_axis:
|
||||
return False
|
||||
|
||||
if event.action == EventActions.recenter:
|
||||
self._stop = True
|
||||
return True
|
||||
|
||||
if not self._transform:
|
||||
absinfo = {
|
||||
entry[0]: entry[1]
|
||||
for entry in source.capabilities(absinfo=True)[EV_ABS]
|
||||
}
|
||||
self._transform = Transformation(
|
||||
max_=absinfo[event.code].max,
|
||||
min_=absinfo[event.code].min,
|
||||
deadzone=self.mapping.deadzone,
|
||||
gain=self.mapping.gain,
|
||||
expo=self.mapping.expo,
|
||||
)
|
||||
|
||||
self._value = self._transform(event.value) * self.mapping.rel_speed
|
||||
if self._value == 0:
|
||||
self._stop = True
|
||||
return True
|
||||
|
||||
if not self._running:
|
||||
asyncio.ensure_future(self._run())
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._stop = True
|
||||
|
||||
def _write(self, ev_type, keycode, value):
|
||||
"""Inject."""
|
||||
# if the mouse won't move even though correct stuff is written here,
|
||||
# the capabilities are probably wrong
|
||||
if value == 0:
|
||||
return # rel 0 does not make sense
|
||||
|
||||
try:
|
||||
global_uinputs.write((ev_type, keycode, value), self.mapping.target_uinput)
|
||||
except OverflowError:
|
||||
# screwed up the calculation of mouse movements
|
||||
logger.error("OverflowError (%s, %s, %s)", ev_type, keycode, value)
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return len(self.input_events) > 1
|
||||
|
||||
def set_sub_handler(self, handler: InputEventHandler) -> None:
|
||||
assert False # cannot have a sub-handler
|
||||
|
||||
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
|
||||
if self.needs_wrapping():
|
||||
return {EventCombination(self.input_events): HandlerEnums.axisswitch}
|
||||
return {}
|
@ -0,0 +1,141 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import Dict, Tuple
|
||||
|
||||
import evdev
|
||||
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
MappingHandler,
|
||||
ContextProtocol,
|
||||
HandlerEnums,
|
||||
InputEventHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
|
||||
|
||||
class AxisSwitchHandler(MappingHandler):
|
||||
"""enables or disables an axis"""
|
||||
|
||||
_map_axis: Tuple[int, int] # the axis we switch on or off (type and code)
|
||||
_trigger_key: Tuple[Tuple[int, int]] # all events that can switch the axis
|
||||
_active: bool # whether the axis is on or off
|
||||
_last_value: int # the value of the last axis event that arrived
|
||||
_axis_source: evdev.InputDevice # the cashed source of the axis
|
||||
_forward_device: evdev.UInput # the cashed forward uinput
|
||||
_sub_handler: InputEventHandler
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: EventCombination,
|
||||
mapping: Mapping,
|
||||
**_,
|
||||
):
|
||||
super().__init__(combination, mapping)
|
||||
map_axis = [
|
||||
event.type_and_code for event in combination if not event.is_key_event
|
||||
]
|
||||
trigger_keys = [
|
||||
event.type_and_code for event in combination if event.is_key_event
|
||||
]
|
||||
assert len(map_axis) != 0
|
||||
assert len(trigger_keys) >= 1
|
||||
self._map_axis = map_axis[0]
|
||||
self._trigger_keys = tuple(trigger_keys)
|
||||
self._active = False
|
||||
|
||||
self._last_value = 0
|
||||
self._axis_source = None
|
||||
self._forward_device = None
|
||||
|
||||
def __str__(self):
|
||||
return f"AxisSwitchHandler for {self._map_axis} <{id(self)}>"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
@property
|
||||
def child(self):
|
||||
return self._sub_handler
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
forward: evdev.UInput,
|
||||
supress: bool = False,
|
||||
) -> bool:
|
||||
|
||||
if (
|
||||
event.type_and_code not in self._trigger_keys
|
||||
and event.type_and_code != self._map_axis
|
||||
):
|
||||
return False
|
||||
|
||||
if event.is_key_event:
|
||||
if self._active == bool(event.value):
|
||||
# nothing changed
|
||||
return False
|
||||
|
||||
self._active = bool(event.value)
|
||||
if not self._active:
|
||||
# recenter the axis
|
||||
logger.debug_key(self.mapping.event_combination, "stopping axis")
|
||||
event = InputEvent(
|
||||
0, 0, *self._map_axis, 0, action=EventActions.recenter
|
||||
)
|
||||
self._sub_handler.notify(event, self._axis_source, self._forward_device)
|
||||
elif self._map_axis[0] == evdev.ecodes.EV_ABS:
|
||||
# send the last cached value so that the abs axis
|
||||
# is at the correct position
|
||||
logger.debug_key(self.mapping.event_combination, "starting axis")
|
||||
event = InputEvent(0, 0, *self._map_axis, self._last_value)
|
||||
self._sub_handler.notify(event, self._axis_source, self._forward_device)
|
||||
else:
|
||||
logger.debug_key(self.mapping.event_combination, "starting axis")
|
||||
return True
|
||||
|
||||
# do some caching so that we can generate the
|
||||
# recenter event and an initial abs event
|
||||
if not self._forward_device:
|
||||
self._forward_device = forward
|
||||
self._axis_source = source
|
||||
|
||||
# always cache the value
|
||||
self._last_value = event.value
|
||||
|
||||
if self._active:
|
||||
return self._sub_handler.notify(event, source, forward, supress)
|
||||
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
self._last_value = 0
|
||||
self._active = False
|
||||
self._sub_handler.reset()
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return True
|
||||
|
||||
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
|
||||
combination = [event for event in self.input_events if event.is_key_event]
|
||||
return {EventCombination(combination): HandlerEnums.combination}
|
@ -0,0 +1,130 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
import math
|
||||
from typing import Dict, Union
|
||||
|
||||
|
||||
class Transformation:
|
||||
"""callable that returns the axis transformation at x"""
|
||||
|
||||
def __init__(
|
||||
self, max_: int, min_: int, deadzone: float, gain: float = 1, expo: float = 0
|
||||
) -> None:
|
||||
self._max = max_
|
||||
self._min = min_
|
||||
self._deadzone = deadzone
|
||||
self._gain = gain
|
||||
self._expo = expo
|
||||
self._cache: Dict[float, float] = {}
|
||||
|
||||
def __call__(self, /, x: Union[int, float]) -> float:
|
||||
if x not in self._cache:
|
||||
y = (
|
||||
self._calc_qubic(self._flatten_deadzone(self._normalize(x)))
|
||||
* self._gain
|
||||
)
|
||||
self._cache[x] = y
|
||||
|
||||
return self._cache[x]
|
||||
|
||||
def _normalize(self, x: Union[int, float]) -> float:
|
||||
"""
|
||||
move and scale x to be between -1 and 1
|
||||
return: x
|
||||
"""
|
||||
if self._min == -1 and self._max == 1:
|
||||
return x
|
||||
|
||||
half_range = (self._max - self._min) / 2
|
||||
middle = half_range + self._min
|
||||
return (x - middle) / half_range
|
||||
|
||||
def _flatten_deadzone(self, x: float) -> float:
|
||||
"""
|
||||
y ^ y ^
|
||||
| |
|
||||
1 | / 1 | /
|
||||
| / | /
|
||||
| / ==> | ---
|
||||
| / | /
|
||||
-1 | / -1 | /
|
||||
|------------> |------------>
|
||||
-1 1 x -1 1 x
|
||||
"""
|
||||
if abs(x) <= self._deadzone:
|
||||
return 0
|
||||
|
||||
return (x - self._deadzone * x / abs(x)) / (1 - self._deadzone)
|
||||
|
||||
def _calc_qubic(self, x: float) -> float:
|
||||
"""
|
||||
transforms an x value by applying a qubic function
|
||||
|
||||
k = 0 : will yield no transformation f(x) = x
|
||||
1 > k > 0 : will yield low sensitivity for low x values
|
||||
and high sensitivity for high x values
|
||||
-1 < k < 0 : will yield high sensitivity for low x values
|
||||
and low sensitivity for high x values
|
||||
|
||||
see also: https://www.geogebra.org/calculator/mkdqueky
|
||||
|
||||
Mathematical definition:
|
||||
f(x,d) = d * x + (1 - d) * x ** 3 | d = 1 - k | k ∈ [0,1]
|
||||
the function is designed such that if follows these constraints:
|
||||
f'(0, d) = d and f(1, d) = 1 and f(-x,d) = -f(x,d)
|
||||
|
||||
for k ∈ [-1,0) the above function is mirrored at y = x
|
||||
and d = 1 + k
|
||||
"""
|
||||
k = self._expo
|
||||
|
||||
if k == 0 or x == 0:
|
||||
return x
|
||||
|
||||
if 0 < k <= 1:
|
||||
d = 1 - k
|
||||
return d * x + (1 - d) * x**3
|
||||
|
||||
if -1 <= k < 0:
|
||||
# calculate return value with the real inverse solution
|
||||
# of y = b * x + a * x ** 3
|
||||
# LaTeX for better readability:
|
||||
#
|
||||
# y=\frac{{{\left( \sqrt{27 {{x}^{2}}+\frac{4 {{b}^{3}}}{a}}
|
||||
# +{{3}^{\frac{3}{2}}} x\right) }^{\frac{1}{3}}}}
|
||||
# {{{2}^{\frac{1}{3}}} \sqrt{3} {{a}^{\frac{1}{3}}}}
|
||||
# -\frac{{{2}^{\frac{1}{3}}} b}
|
||||
# {\sqrt{3} {{a}^{\frac{2}{3}}}
|
||||
# {{\left( \sqrt{27 {{x}^{2}}+\frac{4 {{b}^{3}}}{a}}
|
||||
# +{{3}^{\frac{3}{2}}} x\right) }^{\frac{1}{3}}}}
|
||||
sign = x / abs(x)
|
||||
x = math.fabs(x)
|
||||
d = 1 + k
|
||||
a = 1 - d
|
||||
b = d
|
||||
c = (math.sqrt(27 * x**2 + (4 * b**3) / a) + 3 ** (3 / 2) * x) ** (
|
||||
1 / 3
|
||||
)
|
||||
y = c / (2 ** (1 / 3) * math.sqrt(3) * a ** (1 / 3)) - (
|
||||
2 ** (1 / 3) * b
|
||||
) / (math.sqrt(3) * a ** (2 / 3) * c)
|
||||
return y * sign
|
||||
|
||||
raise ValueError("k must be between -1 and 1")
|
@ -0,0 +1,160 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
import asyncio
|
||||
|
||||
import evdev
|
||||
|
||||
from typing import Dict, Tuple, Optional, List
|
||||
from evdev.ecodes import EV_ABS, EV_REL, EV_KEY
|
||||
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
ContextProtocol,
|
||||
MappingHandler,
|
||||
InputEventHandler,
|
||||
HandlerEnums,
|
||||
)
|
||||
|
||||
|
||||
class CombinationHandler(MappingHandler):
|
||||
"""keeps track of a combination and notifies a sub handler"""
|
||||
|
||||
# map of (event.type, event.code) -> bool , keep track of the combination state
|
||||
_pressed_keys: Dict[Tuple[int, int], bool]
|
||||
_output_state: bool # the last update we sent to a sub-handler
|
||||
_sub_handler: InputEventHandler
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: EventCombination,
|
||||
mapping: Mapping,
|
||||
**_,
|
||||
) -> None:
|
||||
logger.debug(mapping)
|
||||
super().__init__(combination, mapping)
|
||||
self._pressed_keys = {}
|
||||
self._output_state = False
|
||||
|
||||
# prepare a key map for all events with non-zero value
|
||||
for event in combination:
|
||||
assert event.is_key_event
|
||||
self._pressed_keys[event.type_and_code] = False
|
||||
|
||||
assert len(self._pressed_keys) > 0 # no combination handler without a key
|
||||
|
||||
def __str__(self):
|
||||
return f"CombinationHandler for {self.mapping.event_combination} <{id(self)}>:"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
@property
|
||||
def child(self): # used for logging
|
||||
return self._sub_handler
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
forward: evdev.UInput,
|
||||
supress: bool = False,
|
||||
) -> bool:
|
||||
type_code = event.type_and_code
|
||||
if type_code not in self._pressed_keys.keys():
|
||||
return False # we are not responsible for the event
|
||||
|
||||
last_state = self.get_active()
|
||||
self._pressed_keys[type_code] = event.value == 1
|
||||
|
||||
if self.get_active() == last_state or self.get_active() == self._output_state:
|
||||
# nothing changed
|
||||
if self._output_state:
|
||||
# combination is active, consume the event
|
||||
return True
|
||||
else:
|
||||
# combination inactive, forward the event
|
||||
return False
|
||||
|
||||
if self.get_active():
|
||||
# send key up events to the forwarded uinput
|
||||
self.forward_release(forward)
|
||||
event = event.modify(value=1)
|
||||
else:
|
||||
if self._output_state:
|
||||
# we ignore the supress argument for release events
|
||||
# otherwise we might end up with stuck keys
|
||||
# (test_event_pipeline.test_combination)
|
||||
supress = False
|
||||
event = event.modify(value=0)
|
||||
|
||||
if supress:
|
||||
return False
|
||||
|
||||
logger.debug_key(
|
||||
self.mapping.event_combination, "triggered: sending to sub-handler"
|
||||
)
|
||||
self._output_state = bool(event.value)
|
||||
return self._sub_handler.notify(event, source, forward, supress)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._sub_handler.reset()
|
||||
for key in self._pressed_keys:
|
||||
self._pressed_keys[key] = False
|
||||
self._output_state = False
|
||||
|
||||
def get_active(self) -> bool:
|
||||
"""return if all keys in the keymap are set to True"""
|
||||
return False not in self._pressed_keys.values()
|
||||
|
||||
def forward_release(self, forward: evdev.UInput) -> None:
|
||||
"""forward a button release for all keys if this is a combination
|
||||
|
||||
this might cause duplicate key-up events but those are ignored by evdev anyway
|
||||
"""
|
||||
if (
|
||||
len(self.mapping.event_combination) == 1
|
||||
or not self.mapping.release_combination_keys
|
||||
):
|
||||
return
|
||||
for event in self.mapping.event_combination:
|
||||
forward.write(*event.type_and_code, 0)
|
||||
forward.syn()
|
||||
|
||||
def needs_ranking(self) -> bool:
|
||||
return bool(self.input_events)
|
||||
|
||||
def rank_by(self) -> EventCombination:
|
||||
return EventCombination(
|
||||
event for event in self.input_events if event.value != 0
|
||||
)
|
||||
|
||||
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
|
||||
return_dict = {}
|
||||
for event in self.input_events:
|
||||
if event.type == EV_ABS and event.value != 0:
|
||||
return_dict[EventCombination(event)] = HandlerEnums.abs2btn
|
||||
|
||||
if event.type == EV_REL and event.value != 0:
|
||||
return_dict[EventCombination(event)] = HandlerEnums.rel2btn
|
||||
|
||||
return return_dict
|
@ -0,0 +1,94 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
import asyncio
|
||||
import evdev
|
||||
|
||||
from evdev.ecodes import EV_KEY, EV_ABS, EV_REL
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
from inputremapper.event_combination import EventCombination
|
||||
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
MappingHandler,
|
||||
InputEventHandler,
|
||||
HandlerEnums,
|
||||
)
|
||||
|
||||
|
||||
class HierarchyHandler(MappingHandler):
|
||||
"""
|
||||
handler consisting of an ordered list of MappingHandler
|
||||
|
||||
only the first handler which successfully handles the event will execute it,
|
||||
all other handlers will be notified, but suppressed
|
||||
"""
|
||||
|
||||
_input_event: InputEvent
|
||||
|
||||
def __init__(self, handlers: List[MappingHandler], event: InputEvent) -> None:
|
||||
self.handlers = handlers
|
||||
self._input_event = event
|
||||
combination = EventCombination(event)
|
||||
# use the mapping from the first child TODO: find a better solution
|
||||
mapping = handlers[0].mapping
|
||||
super().__init__(combination, mapping)
|
||||
|
||||
def __str__(self):
|
||||
return f"HierarchyHandler for {self._input_event} <{id(self)}>:"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
@property
|
||||
def child(self): # used for logging
|
||||
return self.handlers
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice = None,
|
||||
forward: evdev.UInput = None,
|
||||
supress: bool = False,
|
||||
) -> bool:
|
||||
if event.type_and_code != self._input_event.type_and_code:
|
||||
return False
|
||||
|
||||
success = False
|
||||
for handler in self.handlers:
|
||||
if not success:
|
||||
success = handler.notify(event, source, forward)
|
||||
else:
|
||||
handler.notify(event, source, forward, supress=True)
|
||||
return success
|
||||
|
||||
def reset(self) -> None:
|
||||
for sub_handler in self.handlers:
|
||||
sub_handler.reset()
|
||||
|
||||
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
|
||||
if self._input_event.type == EV_ABS and self._input_event.value != 0:
|
||||
return {EventCombination(self._input_event): HandlerEnums.abs2btn}
|
||||
if self._input_event.type == EV_REL and self._input_event.value != 0:
|
||||
return {EventCombination(self._input_event): HandlerEnums.rel2btn}
|
||||
return {}
|
||||
|
||||
def set_sub_handler(self, handler: InputEventHandler) -> None:
|
||||
assert False
|
@ -0,0 +1,92 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import Tuple, Dict, Optional
|
||||
|
||||
from inputremapper import exceptions
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.exceptions import MappingParsingError
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
MappingHandler,
|
||||
ContextProtocol,
|
||||
HandlerEnums,
|
||||
)
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.injection.global_uinputs import global_uinputs
|
||||
|
||||
|
||||
class KeyHandler(MappingHandler):
|
||||
"""injects the target key if notified"""
|
||||
|
||||
_active: bool
|
||||
_maps_to: Tuple[int, int]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: EventCombination,
|
||||
mapping: Mapping,
|
||||
**_,
|
||||
):
|
||||
super().__init__(combination, mapping)
|
||||
maps_to = mapping.get_output_type_code()
|
||||
if not maps_to:
|
||||
raise MappingParsingError(
|
||||
"unable to create key handler from mapping", mapping=mapping
|
||||
)
|
||||
|
||||
self._maps_to = maps_to
|
||||
self._active = False
|
||||
|
||||
def __str__(self):
|
||||
return f"KeyHandler <{id(self)}>:"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
@property
|
||||
def child(self): # used for logging
|
||||
return f"maps to: {self._maps_to} on {self.mapping.target_uinput}"
|
||||
|
||||
def notify(self, event: InputEvent, *_, **__) -> bool:
|
||||
"""inject event.value to the target key"""
|
||||
|
||||
event_tuple = (*self._maps_to, event.value)
|
||||
try:
|
||||
global_uinputs.write(event_tuple, self.mapping.target_uinput)
|
||||
logger.debug_key(event_tuple, "sending to %s", self.mapping.target_uinput)
|
||||
self._active = bool(event.value)
|
||||
return True
|
||||
except exceptions.Error:
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
logger.debug("resetting key_handler")
|
||||
if self._active:
|
||||
event_tuple = (*self._maps_to, 0)
|
||||
global_uinputs.write(event_tuple, self.mapping.target_uinput)
|
||||
self._active = False
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return True
|
||||
|
||||
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
|
||||
return {EventCombination(self.input_events): HandlerEnums.combination}
|
@ -0,0 +1,101 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
import asyncio
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.injection.global_uinputs import global_uinputs
|
||||
from inputremapper.injection.macros.parse import parse
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
ContextProtocol,
|
||||
MappingHandler,
|
||||
HandlerEnums,
|
||||
)
|
||||
|
||||
|
||||
class MacroHandler(MappingHandler):
|
||||
"""runs the target macro if notified"""
|
||||
|
||||
# TODO: replace this by the macro itself
|
||||
_macro: Macro
|
||||
_active: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: EventCombination,
|
||||
mapping: Mapping,
|
||||
*,
|
||||
context: ContextProtocol,
|
||||
):
|
||||
super().__init__(combination, mapping)
|
||||
self._active = False
|
||||
self._macro = parse(self.mapping.output_symbol, context, mapping)
|
||||
|
||||
def __str__(self):
|
||||
return f"MacroHandler <{id(self)}>:"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
@property
|
||||
def child(self): # used for logging
|
||||
return f"maps to {self._macro} on {self.mapping.target_uinput}"
|
||||
|
||||
def notify(self, event: InputEvent, *_, **__) -> bool:
|
||||
|
||||
if event.value == 1:
|
||||
self._active = True
|
||||
self._macro.press_trigger()
|
||||
if self._macro.running:
|
||||
return True
|
||||
|
||||
def f(ev_type, code, value) -> None:
|
||||
"""Handler for macros."""
|
||||
logger.debug_key(
|
||||
(ev_type, code, value),
|
||||
"sending from macro to %s",
|
||||
self.mapping.target_uinput,
|
||||
)
|
||||
global_uinputs.write((ev_type, code, value), self.mapping.target_uinput)
|
||||
|
||||
asyncio.ensure_future(self._macro.run(f))
|
||||
return True
|
||||
else:
|
||||
self._active = False
|
||||
if self._macro.is_holding():
|
||||
self._macro.release_trigger()
|
||||
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._active = False
|
||||
if self._macro.is_holding():
|
||||
self._macro.release_trigger()
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return True
|
||||
|
||||
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
|
||||
return {EventCombination(self.input_events): HandlerEnums.combination}
|
@ -0,0 +1,198 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""provides protocols for mapping handlers
|
||||
|
||||
|
||||
*** The architecture behind mapping handlers ***
|
||||
|
||||
Handling an InputEvent is done in 3 steps:
|
||||
1. Input Event Handling
|
||||
A MappingHandler that does Input event handling receives Input Events directly from the EventReader.
|
||||
To do so it must implement the InputEventHandler protocol.
|
||||
A InputEventHandler may handle multiple events (InputEvent.type_and_code)
|
||||
|
||||
2. Event Transformation
|
||||
The event gets transformed as described by the mapping.
|
||||
e.g.: combining multiple events to a single one
|
||||
transforming EV_ABS to EV_REL
|
||||
macros
|
||||
...
|
||||
Multiple transformations may get chained
|
||||
|
||||
3. Event Injection
|
||||
The transformed event gets injected to a global_uinput
|
||||
|
||||
MappingHandlers can implement one or more of these steps.
|
||||
|
||||
Overview of implemented handlers and the steps they implement:
|
||||
|
||||
Step 1:
|
||||
- HierarchyHandler
|
||||
|
||||
Step 1 and 2:
|
||||
- CombinationHandler
|
||||
- AbsToBtnHandler
|
||||
- RelToBtnHandler
|
||||
|
||||
Step 1, 2 and 3:
|
||||
- AbsToRelHandler
|
||||
|
||||
Step 2 and 3:
|
||||
- KeyHandler
|
||||
- MacroHandler
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
import evdev
|
||||
from typing import Dict, Protocol, Set, Optional, List
|
||||
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.exceptions import MappingParsingError
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.logger import logger
|
||||
|
||||
|
||||
class EventListener(Protocol):
|
||||
async def __call__(self, event: evdev.InputEvent) -> None:
|
||||
...
|
||||
|
||||
|
||||
class ContextProtocol(Protocol):
|
||||
"""the parts from context needed for macros"""
|
||||
|
||||
preset: Preset
|
||||
listeners: Set[EventListener]
|
||||
|
||||
|
||||
class InputEventHandler(Protocol):
|
||||
"""the protocol any handler, which can be part of an event pipeline, must follow"""
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
forward: evdev.UInput,
|
||||
supress: bool = False,
|
||||
) -> bool:
|
||||
...
|
||||
|
||||
def reset(self) -> None:
|
||||
"""reset the state of the handler e.g. release any buttons"""
|
||||
...
|
||||
|
||||
|
||||
class HandlerEnums(enum.Enum):
|
||||
# converting to btn
|
||||
abs2btn = enum.auto()
|
||||
rel2btn = enum.auto()
|
||||
|
||||
macro = enum.auto()
|
||||
key = enum.auto()
|
||||
|
||||
# converting to "analog"
|
||||
btn2rel = enum.auto()
|
||||
rel2rel = enum.auto()
|
||||
abs2rel = enum.auto()
|
||||
|
||||
btn2abs = enum.auto()
|
||||
rel2abs = enum.auto()
|
||||
abs2abs = enum.auto()
|
||||
|
||||
# special handlers
|
||||
combination = enum.auto()
|
||||
hierarchy = enum.auto()
|
||||
axisswitch = enum.auto()
|
||||
disable = enum.auto()
|
||||
|
||||
|
||||
class MappingHandler(InputEventHandler):
|
||||
"""
|
||||
the protocol a InputEventHandler must follow if it should be
|
||||
dynamically integrated in an event-pipeline by the mapping parser
|
||||
"""
|
||||
|
||||
mapping: Mapping
|
||||
# all input events this handler cares about
|
||||
# should always be a subset of mapping.event_combination
|
||||
input_events: List[InputEvent]
|
||||
_sub_handler: Optional[InputEventHandler]
|
||||
|
||||
# https://bugs.python.org/issue44807
|
||||
def __init__(
|
||||
self,
|
||||
combination: EventCombination,
|
||||
mapping: Mapping,
|
||||
**_,
|
||||
) -> None:
|
||||
"""initialize the handler
|
||||
|
||||
Parameters
|
||||
----------
|
||||
combination : EventCombination
|
||||
the combination from sub_handler.wrap_with()
|
||||
mapping : Mapping
|
||||
"""
|
||||
new_combination = []
|
||||
for event in combination:
|
||||
if event.value != 0:
|
||||
event = event.modify(action=EventActions.as_key)
|
||||
new_combination.append(event)
|
||||
|
||||
self.mapping = mapping
|
||||
self.input_events = new_combination
|
||||
self._sub_handler = None
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
"""if this handler needs to be wrapped in another MappingHandler"""
|
||||
return len(self.wrap_with()) > 0
|
||||
|
||||
def needs_ranking(self) -> bool:
|
||||
"""if this handler needs ranking and wrapping with a HierarchyHandler"""
|
||||
return False
|
||||
|
||||
def rank_by(self) -> Optional[EventCombination]:
|
||||
"""the combination for which this handler needs ranking"""
|
||||
pass
|
||||
|
||||
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
|
||||
"""a dict of EventCombination -> HandlerEnums"""
|
||||
# this handler should be wrapped with the MappingHandler corresponding
|
||||
# to the HandlerEnums, and the EventCombination as first argument
|
||||
# TODO: better explanation
|
||||
return {}
|
||||
|
||||
def set_sub_handler(self, handler: InputEventHandler) -> None:
|
||||
"""give this handler a sub_handler"""
|
||||
self._sub_handler = handler
|
||||
|
||||
def occlude_input_event(self, event: InputEvent) -> None:
|
||||
"""remove the event from self.input_events"""
|
||||
if not self.input_events:
|
||||
logger.debug_mapping_handler(self)
|
||||
raise MappingParsingError(
|
||||
"cannot remove a non existing event", mapping_handler=self
|
||||
)
|
||||
# should be called for each event a wrapping-handler
|
||||
# has in its input_events EventCombination
|
||||
self.input_events.remove(event)
|
@ -0,0 +1,320 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""functions to assemble the mapping handlers"""
|
||||
|
||||
from typing import Dict, List, Type, Optional, Set, Iterable, Sized, Tuple, Sequence
|
||||
from evdev.ecodes import (
|
||||
EV_KEY,
|
||||
EV_ABS,
|
||||
EV_REL,
|
||||
)
|
||||
|
||||
from inputremapper.exceptions import MappingParsingError
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
ContextProtocol,
|
||||
)
|
||||
from inputremapper.injection.mapping_handlers.combination_handler import (
|
||||
CombinationHandler,
|
||||
)
|
||||
from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler
|
||||
from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
|
||||
from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler
|
||||
from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler
|
||||
from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler
|
||||
from inputremapper.injection.mapping_handlers.key_handler import KeyHandler
|
||||
from inputremapper.injection.mapping_handlers.axis_switch_handler import (
|
||||
AxisSwitchHandler,
|
||||
)
|
||||
from inputremapper.injection.mapping_handlers.null_handler import NullHandler
|
||||
from inputremapper.injection.macros.parse import is_this_a_macro
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.configs.system_mapping import DISABLE_CODE, DISABLE_NAME
|
||||
|
||||
EventPipelines = Dict[InputEvent, List[MappingHandler]]
|
||||
|
||||
mapping_handler_classes: Dict[HandlerEnums, Type[MappingHandler]] = {
|
||||
# all available mapping_handlers
|
||||
HandlerEnums.abs2btn: AbsToBtnHandler,
|
||||
HandlerEnums.rel2btn: RelToBtnHandler,
|
||||
HandlerEnums.macro: MacroHandler,
|
||||
HandlerEnums.key: KeyHandler,
|
||||
HandlerEnums.btn2rel: None, # type: ignore
|
||||
HandlerEnums.rel2rel: None, # type: ignore
|
||||
HandlerEnums.abs2rel: AbsToRelHandler,
|
||||
HandlerEnums.btn2abs: None, # type: ignore
|
||||
HandlerEnums.rel2abs: None, # type: ignore
|
||||
HandlerEnums.abs2abs: None, # type: ignore
|
||||
HandlerEnums.combination: CombinationHandler,
|
||||
HandlerEnums.hierarchy: HierarchyHandler,
|
||||
HandlerEnums.axisswitch: AxisSwitchHandler,
|
||||
HandlerEnums.disable: NullHandler,
|
||||
}
|
||||
|
||||
|
||||
def parse_mappings(preset: Preset, context: ContextProtocol) -> EventPipelines:
|
||||
"""create a dict with a list of MappingHandler for each InputEvent"""
|
||||
handlers = []
|
||||
for mapping in preset:
|
||||
# start with the last handler in the chain, each mapping only has one output,
|
||||
# but may have multiple inputs, therefore the last handler is a good starting
|
||||
# point to assemble the pipeline
|
||||
handler_enum = _get_output_handler(mapping)
|
||||
constructor = mapping_handler_classes[handler_enum]
|
||||
if not constructor:
|
||||
raise NotImplementedError(
|
||||
f"mapping handler {handler_enum} is not implemented"
|
||||
)
|
||||
|
||||
output_handler = constructor(
|
||||
mapping.event_combination, mapping, context=context
|
||||
)
|
||||
|
||||
# layer other handlers on top until the outer handler needs ranking or can
|
||||
# directly handle a input event
|
||||
handlers.extend(_create_event_pipeline(output_handler, context))
|
||||
|
||||
# figure out which handlers need ranking and wrap them with hierarchy_handlers
|
||||
need_ranking = {}
|
||||
for handler in handlers.copy():
|
||||
if handler.needs_ranking():
|
||||
combination = handler.rank_by()
|
||||
if not combination:
|
||||
raise MappingParsingError(
|
||||
f"{type(handler).__name__} claims to need ranking but does not "
|
||||
f"return a combination to rank by",
|
||||
mapping_handler=handler,
|
||||
)
|
||||
need_ranking[combination] = handler
|
||||
handlers.remove(handler)
|
||||
|
||||
# the HierarchyHandler's might not be the starting point of the event pipeline
|
||||
# layer other handlers on top again.
|
||||
ranked_handlers = _create_hierarchy_handlers(need_ranking)
|
||||
for handler in ranked_handlers:
|
||||
handlers.extend(_create_event_pipeline(handler, context, ignore_ranking=True))
|
||||
|
||||
# group all handlers by the input events they take care of. One handler might end
|
||||
# up in multiple groups if it takes care of multiple InputEvents
|
||||
event_pipelines: EventPipelines = {}
|
||||
for handler in handlers:
|
||||
assert handler.input_events
|
||||
for event in handler.input_events:
|
||||
if event in event_pipelines.keys():
|
||||
logger.debug("event-pipeline with entry point: %s", event.type_and_code)
|
||||
logger.debug_mapping_handler(handler)
|
||||
event_pipelines[event].append(handler)
|
||||
else:
|
||||
logger.debug("event-pipeline with entry point: %s", event.type_and_code)
|
||||
logger.debug_mapping_handler(handler)
|
||||
event_pipelines[event] = [handler]
|
||||
|
||||
return event_pipelines
|
||||
|
||||
|
||||
def _create_event_pipeline(
|
||||
handler: MappingHandler, context: ContextProtocol, ignore_ranking=False
|
||||
) -> List[MappingHandler]:
|
||||
"""
|
||||
recursively wrap a handler with other handlers until the
|
||||
outer handler needs ranking or is finished wrapping
|
||||
"""
|
||||
if not handler.needs_wrapping() or (handler.needs_ranking() and not ignore_ranking):
|
||||
return [handler]
|
||||
|
||||
handlers = []
|
||||
for combination, handler_enum in handler.wrap_with().items():
|
||||
constructor = mapping_handler_classes[handler_enum]
|
||||
if not constructor:
|
||||
raise NotImplementedError(
|
||||
f"mapping handler {handler_enum} is not implemented"
|
||||
)
|
||||
|
||||
super_handler = constructor(combination, handler.mapping, context=context)
|
||||
super_handler.set_sub_handler(handler)
|
||||
for event in combination:
|
||||
# the handler now has a super_handler which takes care about the events.
|
||||
# so we need to hide them on the handler
|
||||
handler.occlude_input_event(event)
|
||||
|
||||
handlers.extend(_create_event_pipeline(super_handler, context))
|
||||
|
||||
if handler.input_events:
|
||||
# the handler was only partially wrapped,
|
||||
# we need to return it as a toplevel handler
|
||||
handlers.append(handler)
|
||||
|
||||
return handlers
|
||||
|
||||
|
||||
def _get_output_handler(mapping: Mapping) -> HandlerEnums:
|
||||
"""
|
||||
determine the correct output handler
|
||||
this is used as a starting point for the mapping parser
|
||||
"""
|
||||
if mapping.output_code == DISABLE_CODE or mapping.output_symbol == DISABLE_NAME:
|
||||
return HandlerEnums.disable
|
||||
|
||||
if mapping.output_symbol:
|
||||
if is_this_a_macro(mapping.output_symbol):
|
||||
return HandlerEnums.macro
|
||||
else:
|
||||
return HandlerEnums.key
|
||||
|
||||
if mapping.output_type == EV_KEY:
|
||||
return HandlerEnums.key
|
||||
|
||||
input_event = _maps_axis(mapping.event_combination)
|
||||
if not input_event:
|
||||
raise MappingParsingError(
|
||||
f"this {mapping = } does not map to an axis, key or macro", mapping=Mapping
|
||||
)
|
||||
|
||||
if mapping.output_type == EV_REL:
|
||||
if input_event.type == EV_KEY:
|
||||
return HandlerEnums.btn2rel
|
||||
if input_event.type == EV_REL:
|
||||
return HandlerEnums.rel2rel
|
||||
if input_event.type == EV_ABS:
|
||||
return HandlerEnums.abs2rel
|
||||
|
||||
if mapping.output_type == EV_ABS:
|
||||
if input_event.type == EV_KEY:
|
||||
return HandlerEnums.btn2abs
|
||||
if input_event.type == EV_REL:
|
||||
return HandlerEnums.rel2abs
|
||||
if input_event.type == EV_ABS:
|
||||
return HandlerEnums.abs2abs
|
||||
|
||||
raise MappingParsingError(f"the output of {mapping = } is unknown", mapping=Mapping)
|
||||
|
||||
|
||||
def _maps_axis(combination: EventCombination) -> Optional[InputEvent]:
|
||||
"""
|
||||
whether this EventCombination contains an InputEvent that is treated as
|
||||
an axis and not a binary (key or button) event.
|
||||
"""
|
||||
for event in combination:
|
||||
if event.value == 0:
|
||||
return event
|
||||
return None
|
||||
|
||||
|
||||
def _create_hierarchy_handlers(
|
||||
handlers: Dict[EventCombination, MappingHandler]
|
||||
) -> Set[MappingHandler]:
|
||||
"""sort handlers by input events and create Hierarchy handlers"""
|
||||
sorted_handlers = set()
|
||||
all_combinations = handlers.keys()
|
||||
events = set()
|
||||
|
||||
# gather all InputEvents from all handlers
|
||||
for combination in all_combinations:
|
||||
for event in combination:
|
||||
events.add(event)
|
||||
|
||||
# create a ranking for each event
|
||||
for event in events:
|
||||
# find all combinations (from handlers) which contain the event
|
||||
combinations_with_event = [
|
||||
combination for combination in all_combinations if event in combination
|
||||
]
|
||||
|
||||
if len(combinations_with_event) == 1:
|
||||
# there was only one handler containing that event return it as is
|
||||
sorted_handlers.add(handlers[combinations_with_event[0]])
|
||||
continue
|
||||
|
||||
# there are multiple handler with the same event.
|
||||
# rank them and create the HierarchyHandler
|
||||
sorted_combinations = _order_combinations(combinations_with_event, event)
|
||||
sub_handlers = []
|
||||
for combination in sorted_combinations:
|
||||
sub_handlers.append(handlers[combination])
|
||||
|
||||
sorted_handlers.add(HierarchyHandler(sub_handlers, event))
|
||||
for handler in sub_handlers:
|
||||
# the handler now has a HierarchyHandler which takes care about this event.
|
||||
# so we hide need to hide it on the handler
|
||||
handler.occlude_input_event(event)
|
||||
|
||||
return sorted_handlers
|
||||
|
||||
|
||||
def _order_combinations(
|
||||
combinations: List[EventCombination], common_event: InputEvent
|
||||
) -> List[EventCombination]:
|
||||
"""reorder the keys according to some rules
|
||||
|
||||
such that a combination a+b+c is in front of a+b which is in front of b
|
||||
for a+b+c vs. b+d+e: a+b+c would be in front of b+d+e, because the common key b
|
||||
has the higher index in the a+b+c (1), than in the b+c+d (0) list
|
||||
in this example b would be the common key
|
||||
as for combinations like a+b+c and e+d+c with the common key c: ¯\\_(ツ)_/¯
|
||||
|
||||
Parameters
|
||||
----------
|
||||
combinations : List[Key]
|
||||
the list which needs ordering
|
||||
common_event : InputEvent
|
||||
the Key all members of Keys have in common
|
||||
"""
|
||||
combinations.sort(key=len)
|
||||
|
||||
for start, end in ranges_with_constant_length(combinations.copy()):
|
||||
sub_list = combinations[start:end]
|
||||
sub_list.sort(key=lambda x: x.index(common_event))
|
||||
combinations[start:end] = sub_list
|
||||
|
||||
combinations.reverse()
|
||||
return combinations
|
||||
|
||||
|
||||
def ranges_with_constant_length(x: Sequence[Sized]) -> Iterable[Tuple[int, int]]:
|
||||
"""
|
||||
get all ranges of x for which the elements have constant length
|
||||
|
||||
Parameters
|
||||
----------
|
||||
x: Sequence[Sized]
|
||||
l must be ordered by increasing length of elements
|
||||
"""
|
||||
start_idx = 0
|
||||
last_len = 0
|
||||
for idx, y in enumerate(x):
|
||||
if len(y) > last_len and idx - start_idx > 1:
|
||||
yield start_idx, idx
|
||||
|
||||
if len(y) == last_len and idx + 1 == len(x):
|
||||
yield start_idx, idx + 1
|
||||
|
||||
if len(y) > last_len:
|
||||
start_idx = idx
|
||||
|
||||
if len(y) < last_len:
|
||||
raise MappingParsingError(
|
||||
"ranges_with_constant_length " "was called with an unordered list"
|
||||
)
|
||||
last_len = len(y)
|
@ -0,0 +1,60 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import evdev
|
||||
from typing import Optional, Dict
|
||||
|
||||
from evdev.ecodes import EV_KEY
|
||||
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
MappingHandler,
|
||||
HandlerEnums,
|
||||
)
|
||||
|
||||
|
||||
class NullHandler(MappingHandler):
|
||||
"""handler which consumes the event and does nothing"""
|
||||
|
||||
def __str__(self):
|
||||
return f"NullHandler for {self.mapping.event_combination}<{id(self)}>"
|
||||
|
||||
@property
|
||||
def child(self):
|
||||
return "Voids all events"
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return True in [event.value != 0 for event in self.input_events]
|
||||
|
||||
def wrap_with(self) -> Dict[EventCombination, HandlerEnums]:
|
||||
return {EventCombination(self.input_events): HandlerEnums.combination}
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
forward: evdev.UInput,
|
||||
supress: bool = False,
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
pass
|
@ -0,0 +1,132 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import evdev
|
||||
import time
|
||||
import asyncio
|
||||
|
||||
from typing import Optional, Dict
|
||||
from evdev.ecodes import EV_REL
|
||||
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
MappingHandler,
|
||||
ContextProtocol,
|
||||
HandlerEnums,
|
||||
InputEventHandler,
|
||||
)
|
||||
|
||||
|
||||
class RelToBtnHandler(MappingHandler):
|
||||
"""
|
||||
Handler which transforms an EV_REL to a button event
|
||||
and sends that to a sub_handler
|
||||
|
||||
adheres to the MappingHandler protocol
|
||||
"""
|
||||
|
||||
_active: bool
|
||||
_input_event: InputEvent
|
||||
_last_activation: float
|
||||
_sub_handler: InputEventHandler
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: EventCombination,
|
||||
mapping: Mapping,
|
||||
**_,
|
||||
) -> None:
|
||||
super().__init__(combination, mapping)
|
||||
|
||||
self._active = False
|
||||
self._input_event = combination[0]
|
||||
self._last_activation = time.time()
|
||||
self._abort_release = False
|
||||
assert self._input_event.value != 0
|
||||
assert len(combination) == 1
|
||||
|
||||
def __str__(self):
|
||||
return f"RelToBtnHandler for {self._input_event.event_tuple} <{id(self)}>:"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
@property
|
||||
def child(self): # used for logging
|
||||
return self._sub_handler
|
||||
|
||||
async def _stage_release(self, source, forward, supress):
|
||||
while time.time() < self._last_activation + self.mapping.release_timeout:
|
||||
await asyncio.sleep(1 / self.mapping.rate)
|
||||
|
||||
if self._abort_release:
|
||||
self._abort_release = False
|
||||
return
|
||||
|
||||
event = self._input_event.modify(value=0, action=EventActions.as_key)
|
||||
logger.debug_key(event.event_tuple, "sending to sub_handler")
|
||||
self._sub_handler.notify(event, source, forward, supress)
|
||||
self._active = False
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
forward: evdev.UInput,
|
||||
supress: bool = False,
|
||||
) -> bool:
|
||||
|
||||
assert event.type == EV_REL
|
||||
if event.type_and_code != self._input_event.type_and_code:
|
||||
return False
|
||||
|
||||
threshold = self._input_event.value
|
||||
value = event.value
|
||||
if (value < threshold > 0) or (value > threshold < 0):
|
||||
if self._active:
|
||||
# the axis is below the threshold and the stage_release function is running
|
||||
event = event.modify(value=0, action=EventActions.as_key)
|
||||
logger.debug_key(event.event_tuple, "sending to sub_handler")
|
||||
self._abort_release = True
|
||||
self._active = False
|
||||
return self._sub_handler.notify(event, source, forward, supress)
|
||||
else:
|
||||
# don't consume the event.
|
||||
# We could return True to consume events
|
||||
return False
|
||||
|
||||
# the axis is above the threshold
|
||||
event = event.modify(value=1, action=EventActions.as_key)
|
||||
self._last_activation = time.time()
|
||||
if not self._active:
|
||||
logger.debug_key(event.event_tuple, "sending to sub_handler")
|
||||
asyncio.ensure_future(self._stage_release(source, forward, supress))
|
||||
self._active = True
|
||||
return self._sub_handler.notify(event, source, forward, supress)
|
||||
|
||||
def reset(self) -> None:
|
||||
if self._active:
|
||||
self._abort_release = True
|
||||
|
||||
self._active = False
|
||||
self._sub_handler.reset()
|
@ -0,0 +1,188 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
import dataclasses
|
||||
import functools
|
||||
import unittest
|
||||
import itertools
|
||||
from typing import Iterable, List
|
||||
|
||||
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
|
||||
|
||||
|
||||
class TestAxisTransformation(unittest.TestCase):
|
||||
@dataclasses.dataclass
|
||||
class InitArgs:
|
||||
max_: int
|
||||
min_: int
|
||||
deadzone: float
|
||||
gain: float
|
||||
expo: float
|
||||
|
||||
def values(self):
|
||||
return self.__dict__.values()
|
||||
|
||||
def get_init_args(
|
||||
self,
|
||||
max_=(255, 1000, 2**15),
|
||||
min_=(50, 0, -255),
|
||||
deadzone=(0, 0.5),
|
||||
gain=(0.5, 1, 2),
|
||||
expo=(-0.9, 0, 0.3),
|
||||
) -> Iterable[InitArgs]:
|
||||
for args in itertools.product(max_, min_, deadzone, gain, expo):
|
||||
yield self.InitArgs(*args)
|
||||
|
||||
@staticmethod
|
||||
def scale_to_range(min_, max_, x=(-1, -0.2, 0, 0.6, 1)) -> List[float]:
|
||||
"""scale values between -1 and 1 up, such that they are between min and max"""
|
||||
half_range = (max_ - min_) / 2
|
||||
return [float_x * half_range + min_ + half_range for float_x in x]
|
||||
|
||||
def test_scale_to_range(self):
|
||||
"""make sure scale_to_range will actually return the min and max values
|
||||
(avoid "off by one" errors)"""
|
||||
max_ = (255, 1000, 2**15)
|
||||
min_ = (50, 0, -255)
|
||||
|
||||
for x1, x2 in itertools.product(min_, max_):
|
||||
scaled = self.scale_to_range(x1, x2, (-1, 1))
|
||||
self.assertEqual(scaled, [x1, x2])
|
||||
|
||||
def test_expo_symmetry(self):
|
||||
"""
|
||||
test that the transformation is symmetric for expo parameter
|
||||
x = f(g(x)), if f._expo == - g._expo
|
||||
|
||||
with the following constraints:
|
||||
min = -1, max = 1
|
||||
gain = 1
|
||||
deadzone = 0
|
||||
|
||||
we can remove the constraints for min, max and gain,
|
||||
by scaling the values appropriately after each transformation
|
||||
"""
|
||||
|
||||
for init_args in self.get_init_args(deadzone=(0,)):
|
||||
f = Transformation(*init_args.values())
|
||||
init_args.expo = -init_args.expo
|
||||
g = Transformation(*init_args.values())
|
||||
|
||||
scale = functools.partial(
|
||||
self.scale_to_range, init_args.min_, init_args.max_
|
||||
)
|
||||
for x in scale():
|
||||
y1 = g(x)
|
||||
y1 = y1 / init_args.gain # remove the gain
|
||||
y1 = scale((y1,))[0] # remove the min/max constraint
|
||||
|
||||
y2 = f(y1)
|
||||
y2 = y2 / init_args.gain # remove the gain
|
||||
y2 = scale((y2,))[0] # remove the min/max constraint
|
||||
self.assertAlmostEqual(x, y2, msg=f"test expo symmetry for {init_args}")
|
||||
|
||||
def test_origin_symmetry(self):
|
||||
"""
|
||||
test that the transformation is symmetric to the origin
|
||||
f(x) = - f(-x)
|
||||
within the constraints: min = -max
|
||||
"""
|
||||
|
||||
for init_args in self.get_init_args():
|
||||
init_args.min_ = -init_args.max_
|
||||
f = Transformation(*init_args.values())
|
||||
for x in self.scale_to_range(init_args.min_, init_args.max_):
|
||||
self.assertAlmostEqual(
|
||||
f(x), -f(-x), msg=f"test origin symmetry at {x=} for {init_args}"
|
||||
)
|
||||
|
||||
def test_gain(self):
|
||||
"""test that f(max) = gain and f(min) = -gain"""
|
||||
for init_args in self.get_init_args():
|
||||
f = Transformation(*init_args.values())
|
||||
self.assertAlmostEqual(
|
||||
f(init_args.max_), init_args.gain, msg=f"test gain for {init_args}"
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
f(init_args.min_), -init_args.gain, msg=f"test gain for {init_args}"
|
||||
)
|
||||
|
||||
def test_deadzone(self):
|
||||
"""test the Transfomation returns exactly 0 in the range of the deadzone"""
|
||||
|
||||
for init_args in self.get_init_args(deadzone=(0.1, 0.2, 0.9)):
|
||||
f = Transformation(*init_args.values())
|
||||
for x in self.scale_to_range(
|
||||
init_args.min_,
|
||||
init_args.max_,
|
||||
x=(
|
||||
init_args.deadzone * 0.999,
|
||||
-init_args.deadzone * 0.999,
|
||||
0.3 * init_args.deadzone,
|
||||
0,
|
||||
),
|
||||
):
|
||||
self.assertEqual(f(x), 0, msg=f"test deadzone at {x=} for {init_args}")
|
||||
|
||||
def test_continuity_near_deadzone(self):
|
||||
"""test that the Transfomation is continues (no sudden jump) next to the
|
||||
deadzone"""
|
||||
|
||||
for init_args in self.get_init_args(deadzone=(0.1, 0.2, 0.9)):
|
||||
f = Transformation(*init_args.values())
|
||||
scale = functools.partial(
|
||||
self.scale_to_range,
|
||||
init_args.min_,
|
||||
init_args.max_,
|
||||
)
|
||||
x = (
|
||||
init_args.deadzone * 1.00001,
|
||||
init_args.deadzone * 1.001,
|
||||
-init_args.deadzone * 1.00001,
|
||||
-init_args.deadzone * 1.001,
|
||||
)
|
||||
scaled_x = scale(x=x)
|
||||
|
||||
p1 = (x[0], f(scaled_x[0])) # first point right of deadzone
|
||||
p2 = (x[1], f(scaled_x[1])) # second point right of deadzone
|
||||
|
||||
# calculate a linear function y = m * x + b from p1 and p2
|
||||
m = (p1[1] - p2[1]) / (p1[0] - p2[0])
|
||||
b = p1[1] - m * p1[0]
|
||||
|
||||
# the zero intersection of that function must be close to the
|
||||
# edge of the deadzone
|
||||
self.assertAlmostEqual(
|
||||
-b / m,
|
||||
init_args.deadzone,
|
||||
places=5,
|
||||
msg=f"test continuity at {init_args.deadzone} for {init_args}",
|
||||
)
|
||||
|
||||
# same thing on the other side
|
||||
p1 = (x[2], f(scaled_x[2]))
|
||||
p2 = (x[3], f(scaled_x[3]))
|
||||
m = (p1[1] - p2[1]) / (p1[0] - p2[0])
|
||||
b = p1[1] - m * p1[0]
|
||||
self.assertAlmostEqual(
|
||||
-b / m,
|
||||
-init_args.deadzone,
|
||||
places=5,
|
||||
msg=f"test continuity at {- init_args.deadzone} for {init_args}",
|
||||
)
|
@ -1,211 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from tests.test import new_event, quick_cleanup
|
||||
|
||||
import unittest
|
||||
import asyncio
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import EV_KEY, EV_ABS, ABS_Y, EV_REL
|
||||
|
||||
from inputremapper.injection.consumers.keycode_mapper import active_macros
|
||||
from inputremapper.configs.global_config import BUTTONS, MOUSE, WHEEL
|
||||
|
||||
from inputremapper.injection.context import Context
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.injection.consumer_control import ConsumerControl, consumer_classes
|
||||
from inputremapper.injection.consumers.consumer import Consumer
|
||||
from inputremapper.injection.consumers.keycode_mapper import KeycodeMapper
|
||||
from inputremapper.configs.system_mapping import system_mapping
|
||||
from inputremapper.injection.global_uinputs import global_uinputs
|
||||
|
||||
|
||||
class ExampleConsumer(Consumer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def is_enabled(self):
|
||||
return True
|
||||
|
||||
async def notify(self, event):
|
||||
pass
|
||||
|
||||
def is_handled(self, event):
|
||||
pass
|
||||
|
||||
async def run(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestConsumerControl(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
consumer_classes.append(ExampleConsumer)
|
||||
self.gamepad_source = evdev.InputDevice("/dev/input/event30")
|
||||
self.mapping = Preset()
|
||||
|
||||
def tearDown(self):
|
||||
quick_cleanup()
|
||||
consumer_classes.remove(ExampleConsumer)
|
||||
|
||||
def setup(self, source, mapping):
|
||||
"""Set a a ConsumerControl up for the test and run it in the background."""
|
||||
forward_to = evdev.UInput()
|
||||
context = Context(mapping)
|
||||
context.uinput = evdev.UInput()
|
||||
consumer_control = ConsumerControl(context, source, forward_to)
|
||||
for consumer in consumer_control._consumers:
|
||||
consumer._abs_range = (-10, 10)
|
||||
asyncio.ensure_future(consumer_control.run())
|
||||
return context, consumer_control
|
||||
|
||||
async def test_no_keycode_mapper_needed(self):
|
||||
self.mapping.change(EventCombination([EV_KEY, 1, 1]), "keyboard", "b")
|
||||
_, consumer_control = self.setup(self.gamepad_source, self.mapping)
|
||||
consumer_types = [type(consumer) for consumer in consumer_control._consumers]
|
||||
self.assertIn(KeycodeMapper, consumer_types)
|
||||
|
||||
self.mapping.empty()
|
||||
_, consumer_control = self.setup(self.gamepad_source, self.mapping)
|
||||
consumer_types = [type(consumer) for consumer in consumer_control._consumers]
|
||||
self.assertNotIn(KeycodeMapper, consumer_types)
|
||||
|
||||
self.mapping.change(EventCombination([EV_KEY, 1, 1]), "keyboard", "k(a)")
|
||||
_, consumer_control = self.setup(self.gamepad_source, self.mapping)
|
||||
consumer_types = [type(consumer) for consumer in consumer_control._consumers]
|
||||
self.assertIn(KeycodeMapper, consumer_types)
|
||||
|
||||
async def test_if_single_joystick_then(self):
|
||||
# Integration test style for if_single.
|
||||
# won't care about the event, because the purpose is not set to BUTTON
|
||||
code_a = system_mapping.get("a")
|
||||
code_shift = system_mapping.get("KEY_LEFTSHIFT")
|
||||
trigger = 1
|
||||
self.mapping.change(
|
||||
EventCombination([EV_KEY, trigger, 1]),
|
||||
"keyboard",
|
||||
"if_single(k(a), k(KEY_LEFTSHIFT))",
|
||||
)
|
||||
self.mapping.change(EventCombination([EV_ABS, ABS_Y, 1]), "keyboard", "b")
|
||||
|
||||
self.mapping.set("gamepad.joystick.left_purpose", MOUSE)
|
||||
self.mapping.set("gamepad.joystick.right_purpose", WHEEL)
|
||||
context, _ = self.setup(self.gamepad_source, self.mapping)
|
||||
|
||||
self.gamepad_source.push_events(
|
||||
[
|
||||
new_event(EV_KEY, trigger, 1), # start the macro
|
||||
new_event(EV_ABS, ABS_Y, 10), # ignored
|
||||
new_event(EV_KEY, 2, 2), # ignored
|
||||
new_event(EV_KEY, 2, 0), # ignored
|
||||
new_event(EV_REL, 1, 1), # ignored
|
||||
new_event(
|
||||
EV_KEY, trigger, 0
|
||||
), # stop it, the only way to trigger `then`
|
||||
]
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
self.assertFalse(active_macros[(EV_KEY, 1)].running)
|
||||
history = [a.t for a in global_uinputs.get_uinput("keyboard").write_history]
|
||||
self.assertIn((EV_KEY, code_a, 1), history)
|
||||
self.assertIn((EV_KEY, code_a, 0), history)
|
||||
self.assertNotIn((EV_KEY, code_shift, 1), history)
|
||||
self.assertNotIn((EV_KEY, code_shift, 0), history)
|
||||
|
||||
async def test_if_single_joystickelse_(self):
|
||||
"""triggers else + delayed_handle_keycode"""
|
||||
# Integration test style for if_single.
|
||||
# If a joystick that is mapped to a button is moved, if_single stops
|
||||
code_b = system_mapping.get("b")
|
||||
code_shift = system_mapping.get("KEY_LEFTSHIFT")
|
||||
trigger = 1
|
||||
self.mapping.change(
|
||||
EventCombination([EV_KEY, trigger, 1]),
|
||||
"keyboard",
|
||||
"if_single(k(a), k(KEY_LEFTSHIFT))",
|
||||
)
|
||||
self.mapping.change(EventCombination([EV_ABS, ABS_Y, 1]), "keyboard", "b")
|
||||
|
||||
self.mapping.set("gamepad.joystick.left_purpose", BUTTONS)
|
||||
self.mapping.set("gamepad.joystick.right_purpose", BUTTONS)
|
||||
context, _ = self.setup(self.gamepad_source, self.mapping)
|
||||
|
||||
self.gamepad_source.push_events(
|
||||
[
|
||||
new_event(EV_KEY, trigger, 1), # start the macro
|
||||
new_event(EV_ABS, ABS_Y, 10), # not ignored, stops it
|
||||
]
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
self.assertFalse(active_macros[(EV_KEY, 1)].running)
|
||||
history = [a.t for a in global_uinputs.get_uinput("keyboard").write_history]
|
||||
|
||||
# the key that triggered if_single should be injected after
|
||||
# if_single had a chance to inject keys (if the macro is fast enough),
|
||||
# so that if_single can inject a modifier to e.g. capitalize the
|
||||
# triggering key. This is important for the space cadet shift
|
||||
self.assertListEqual(
|
||||
history,
|
||||
[
|
||||
(EV_KEY, code_shift, 1),
|
||||
(EV_KEY, code_b, 1), # would be capitalized now
|
||||
(EV_KEY, code_shift, 0),
|
||||
],
|
||||
)
|
||||
|
||||
async def test_if_single_joystick_under_threshold(self):
|
||||
"""triggers then because the joystick events value is too low."""
|
||||
code_a = system_mapping.get("a")
|
||||
trigger = 1
|
||||
self.mapping.change(
|
||||
EventCombination([EV_KEY, trigger, 1]),
|
||||
"keyboard",
|
||||
"if_single(k(a), k(KEY_LEFTSHIFT))",
|
||||
)
|
||||
self.mapping.change(EventCombination([EV_ABS, ABS_Y, 1]), "keyboard", "b")
|
||||
|
||||
self.mapping.set("gamepad.joystick.left_purpose", BUTTONS)
|
||||
self.mapping.set("gamepad.joystick.right_purpose", BUTTONS)
|
||||
context, _ = self.setup(self.gamepad_source, self.mapping)
|
||||
|
||||
self.gamepad_source.push_events(
|
||||
[
|
||||
new_event(EV_KEY, trigger, 1), # start the macro
|
||||
new_event(EV_ABS, ABS_Y, 1), # ignored because value too low
|
||||
new_event(EV_KEY, trigger, 0), # stop, only way to trigger `then`
|
||||
]
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
self.assertFalse(active_macros[(EV_KEY, 1)].running)
|
||||
history = [a.t for a in global_uinputs.get_uinput("keyboard").write_history]
|
||||
|
||||
# the key that triggered if_single should be injected after
|
||||
# if_single had a chance to inject keys (if the macro is fast enough),
|
||||
# so that if_single can inject a modifier to e.g. capitalize the
|
||||
# triggering key. This is important for the space cadet shift
|
||||
self.assertListEqual(
|
||||
history,
|
||||
[
|
||||
(EV_KEY, code_a, 1),
|
||||
(EV_KEY, code_a, 0),
|
||||
],
|
||||
)
|
File diff suppressed because it is too large
Load Diff
@ -1,216 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from tests.test import (
|
||||
InputDevice,
|
||||
UInput,
|
||||
MAX_ABS,
|
||||
clear_write_history,
|
||||
uinput_write_history,
|
||||
quick_cleanup,
|
||||
new_event,
|
||||
MIN_ABS,
|
||||
)
|
||||
|
||||
import unittest
|
||||
import asyncio
|
||||
|
||||
from evdev.ecodes import (
|
||||
EV_REL,
|
||||
REL_X,
|
||||
REL_Y,
|
||||
REL_WHEEL,
|
||||
REL_HWHEEL,
|
||||
EV_ABS,
|
||||
ABS_X,
|
||||
ABS_Y,
|
||||
ABS_RX,
|
||||
ABS_RY,
|
||||
)
|
||||
|
||||
from inputremapper.configs.global_config import global_config
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.injection.context import Context
|
||||
from inputremapper.injection.consumers.joystick_to_mouse import (
|
||||
JoystickToMouse,
|
||||
MOUSE,
|
||||
WHEEL,
|
||||
)
|
||||
|
||||
|
||||
abs_state = [0, 0, 0, 0]
|
||||
|
||||
|
||||
class TestJoystickToMouse(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
self.mapping = Preset()
|
||||
self.context = Context(self.mapping)
|
||||
|
||||
uinput = UInput()
|
||||
self.context.uinput = uinput
|
||||
|
||||
source = InputDevice("/dev/input/event30")
|
||||
self.joystick_to_mouse = JoystickToMouse(self.context, source)
|
||||
|
||||
global_config.set("gamepad.joystick.x_scroll_speed", 1)
|
||||
global_config.set("gamepad.joystick.y_scroll_speed", 1)
|
||||
|
||||
def tearDown(self):
|
||||
quick_cleanup()
|
||||
|
||||
def assertClose(self, a, b, within):
|
||||
"""a has to be within b - b * within, b + b * within."""
|
||||
self.assertLess(a - abs(a) * within, b)
|
||||
self.assertGreater(a + abs(a) * within, b)
|
||||
|
||||
async def test_assertClose(self):
|
||||
self.assertClose(5, 5, 0.1)
|
||||
self.assertClose(5, 5, 1)
|
||||
self.assertClose(6, 5, 0.2)
|
||||
self.assertClose(4, 5, 0.3)
|
||||
self.assertRaises(AssertionError, lambda: self.assertClose(6, 5, 0.1))
|
||||
self.assertRaises(AssertionError, lambda: self.assertClose(4, 5, 0.1))
|
||||
|
||||
self.assertClose(-5, -5, 0.1)
|
||||
self.assertClose(-5, -5, 1)
|
||||
self.assertClose(-6, -5, 0.2)
|
||||
self.assertClose(-4, -5, 0.3)
|
||||
self.assertRaises(AssertionError, lambda: self.assertClose(-6, -5, 0.1))
|
||||
self.assertRaises(AssertionError, lambda: self.assertClose(-4, -5, 0.1))
|
||||
|
||||
async def do(self, a, b, c, d, expectation):
|
||||
"""Present fake values to the loop and observe the outcome.
|
||||
|
||||
Depending on the configuration, the cursor or wheel should move.
|
||||
"""
|
||||
clear_write_history()
|
||||
self.joystick_to_mouse.context.update_purposes()
|
||||
await self.joystick_to_mouse.notify(new_event(EV_ABS, ABS_X, a))
|
||||
await self.joystick_to_mouse.notify(new_event(EV_ABS, ABS_Y, b))
|
||||
await self.joystick_to_mouse.notify(new_event(EV_ABS, ABS_RX, c))
|
||||
await self.joystick_to_mouse.notify(new_event(EV_ABS, ABS_RY, d))
|
||||
|
||||
# sleep long enough to test if multiple events are written
|
||||
await asyncio.sleep(5 / 60)
|
||||
|
||||
history = [h.t for h in uinput_write_history]
|
||||
self.assertGreater(len(history), 1)
|
||||
self.assertIn(expectation, history)
|
||||
|
||||
for history_entry in history:
|
||||
self.assertEqual(history_entry[:2], expectation[:2])
|
||||
# if the injected cursor movement is 19 or 20 doesn't really matter
|
||||
self.assertClose(history_entry[2], expectation[2], 0.1)
|
||||
|
||||
async def test_joystick_purpose_1(self):
|
||||
asyncio.ensure_future(self.joystick_to_mouse.run())
|
||||
|
||||
speed = 20
|
||||
self.mapping.set("gamepad.joystick.non_linearity", 1)
|
||||
self.mapping.set("gamepad.joystick.pointer_speed", speed)
|
||||
self.mapping.set("gamepad.joystick.left_purpose", MOUSE)
|
||||
self.mapping.set("gamepad.joystick.right_purpose", WHEEL)
|
||||
|
||||
min_abs = 0
|
||||
# if `rest` is not exactly `max_abs / 2` decimal places might add up
|
||||
# and cause higher or lower values to be written after a few events,
|
||||
# which might be difficult to test.
|
||||
max_abs = 256
|
||||
rest = 128 # resting position of the cursor
|
||||
self.joystick_to_mouse.set_abs_range(min_abs, max_abs)
|
||||
|
||||
await self.do(max_abs, rest, rest, rest, (EV_REL, REL_X, speed))
|
||||
await self.do(min_abs, rest, rest, rest, (EV_REL, REL_X, -speed))
|
||||
await self.do(rest, max_abs, rest, rest, (EV_REL, REL_Y, speed))
|
||||
await self.do(rest, min_abs, rest, rest, (EV_REL, REL_Y, -speed))
|
||||
|
||||
# vertical wheel event values are negative
|
||||
await self.do(rest, rest, max_abs, rest, (EV_REL, REL_HWHEEL, 1))
|
||||
await self.do(rest, rest, min_abs, rest, (EV_REL, REL_HWHEEL, -1))
|
||||
await self.do(rest, rest, rest, max_abs, (EV_REL, REL_WHEEL, -1))
|
||||
await self.do(rest, rest, rest, min_abs, (EV_REL, REL_WHEEL, 1))
|
||||
|
||||
async def test_joystick_purpose_2(self):
|
||||
asyncio.ensure_future(self.joystick_to_mouse.run())
|
||||
|
||||
speed = 30
|
||||
global_config.set("gamepad.joystick.non_linearity", 1)
|
||||
global_config.set("gamepad.joystick.pointer_speed", speed)
|
||||
global_config.set("gamepad.joystick.left_purpose", WHEEL)
|
||||
global_config.set("gamepad.joystick.right_purpose", MOUSE)
|
||||
global_config.set("gamepad.joystick.x_scroll_speed", 1)
|
||||
global_config.set("gamepad.joystick.y_scroll_speed", 2)
|
||||
|
||||
# vertical wheel event values are negative
|
||||
await self.do(MAX_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, 1))
|
||||
await self.do(MIN_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, -1))
|
||||
await self.do(0, MAX_ABS, 0, 0, (EV_REL, REL_WHEEL, -2))
|
||||
await self.do(0, MIN_ABS, 0, 0, (EV_REL, REL_WHEEL, 2))
|
||||
|
||||
await self.do(0, 0, MAX_ABS, 0, (EV_REL, REL_X, speed))
|
||||
await self.do(0, 0, MIN_ABS, 0, (EV_REL, REL_X, -speed))
|
||||
await self.do(0, 0, 0, MAX_ABS, (EV_REL, REL_Y, speed))
|
||||
await self.do(0, 0, 0, MIN_ABS, (EV_REL, REL_Y, -speed))
|
||||
|
||||
async def test_joystick_purpose_3(self):
|
||||
asyncio.ensure_future(self.joystick_to_mouse.run())
|
||||
|
||||
speed = 40
|
||||
self.mapping.set("gamepad.joystick.non_linearity", 1)
|
||||
global_config.set("gamepad.joystick.pointer_speed", speed)
|
||||
self.mapping.set("gamepad.joystick.left_purpose", MOUSE)
|
||||
global_config.set("gamepad.joystick.right_purpose", MOUSE)
|
||||
|
||||
await self.do(MAX_ABS, 0, 0, 0, (EV_REL, REL_X, speed))
|
||||
await self.do(MIN_ABS, 0, 0, 0, (EV_REL, REL_X, -speed))
|
||||
await self.do(0, MAX_ABS, 0, 0, (EV_REL, REL_Y, speed))
|
||||
await self.do(0, MIN_ABS, 0, 0, (EV_REL, REL_Y, -speed))
|
||||
|
||||
await self.do(0, 0, MAX_ABS, 0, (EV_REL, REL_X, speed))
|
||||
await self.do(0, 0, MIN_ABS, 0, (EV_REL, REL_X, -speed))
|
||||
await self.do(0, 0, 0, MAX_ABS, (EV_REL, REL_Y, speed))
|
||||
await self.do(0, 0, 0, MIN_ABS, (EV_REL, REL_Y, -speed))
|
||||
|
||||
async def test_joystick_purpose_4(self):
|
||||
asyncio.ensure_future(self.joystick_to_mouse.run())
|
||||
|
||||
global_config.set("gamepad.joystick.left_purpose", WHEEL)
|
||||
global_config.set("gamepad.joystick.right_purpose", WHEEL)
|
||||
self.mapping.set("gamepad.joystick.x_scroll_speed", 2)
|
||||
self.mapping.set("gamepad.joystick.y_scroll_speed", 3)
|
||||
|
||||
await self.do(MAX_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, 2))
|
||||
await self.do(MIN_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, -2))
|
||||
await self.do(0, MAX_ABS, 0, 0, (EV_REL, REL_WHEEL, -3))
|
||||
await self.do(0, MIN_ABS, 0, 0, (EV_REL, REL_WHEEL, 3))
|
||||
|
||||
# vertical wheel event values are negative
|
||||
await self.do(0, 0, MAX_ABS, 0, (EV_REL, REL_HWHEEL, 2))
|
||||
await self.do(0, 0, MIN_ABS, 0, (EV_REL, REL_HWHEEL, -2))
|
||||
await self.do(0, 0, 0, MAX_ABS, (EV_REL, REL_WHEEL, -3))
|
||||
await self.do(0, 0, 0, MIN_ABS, (EV_REL, REL_WHEEL, 3))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
@ -0,0 +1,177 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from tests.test import new_event, quick_cleanup, get_key_mapping
|
||||
|
||||
import unittest
|
||||
import asyncio
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import (
|
||||
EV_KEY,
|
||||
EV_ABS,
|
||||
ABS_X,
|
||||
ABS_Y,
|
||||
ABS_RX,
|
||||
ABS_RY,
|
||||
EV_REL,
|
||||
REL_X,
|
||||
REL_Y,
|
||||
REL_HWHEEL_HI_RES,
|
||||
REL_WHEEL_HI_RES,
|
||||
)
|
||||
|
||||
from inputremapper.configs.global_config import BUTTONS, MOUSE, WHEEL
|
||||
|
||||
from inputremapper.injection.context import Context
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.injection.event_reader import EventReader
|
||||
from inputremapper.configs.system_mapping import system_mapping
|
||||
from inputremapper.injection.global_uinputs import global_uinputs
|
||||
|
||||
|
||||
class TestEventReader(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.gamepad_source = evdev.InputDevice("/dev/input/event30")
|
||||
self.stop_event = asyncio.Event()
|
||||
self.preset = Preset()
|
||||
|
||||
def tearDown(self):
|
||||
quick_cleanup()
|
||||
|
||||
def setup(self, source, mapping):
|
||||
"""Set a a EventReader up for the test and run it in the background."""
|
||||
forward_to = evdev.UInput()
|
||||
context = Context(mapping)
|
||||
context.uinput = evdev.UInput()
|
||||
consumer_control = EventReader(context, source, forward_to, self.stop_event)
|
||||
# for consumer in consumer_control._consumers:
|
||||
# consumer._abs_range = (-10, 10)
|
||||
asyncio.ensure_future(consumer_control.run())
|
||||
return context, consumer_control
|
||||
|
||||
async def test_if_single_joystick_then(self):
|
||||
# TODO: Move this somewhere more sensible
|
||||
# Integration test style for if_single.
|
||||
# won't care about the event, because the purpose is not set to BUTTON
|
||||
code_a = system_mapping.get("a")
|
||||
code_shift = system_mapping.get("KEY_LEFTSHIFT")
|
||||
trigger = 1
|
||||
|
||||
self.preset.add(
|
||||
get_key_mapping(
|
||||
EventCombination([EV_KEY, trigger, 1]),
|
||||
"keyboard",
|
||||
"if_single(key(a), key(KEY_LEFTSHIFT))",
|
||||
)
|
||||
)
|
||||
self.preset.add(
|
||||
get_key_mapping(EventCombination([EV_ABS, ABS_Y, 1]), "keyboard", "b")
|
||||
)
|
||||
|
||||
# left x to mouse x
|
||||
cfg = {
|
||||
"event_combination": ",".join((str(EV_ABS), str(ABS_X), "0")),
|
||||
"target_uinput": "mouse",
|
||||
"output_type": EV_REL,
|
||||
"output_code": REL_X,
|
||||
}
|
||||
self.preset.add(Mapping(**cfg))
|
||||
|
||||
# left y to mouse y
|
||||
cfg["event_combination"] = ",".join((str(EV_ABS), str(ABS_Y), "0"))
|
||||
cfg["output_code"] = REL_Y
|
||||
self.preset.add(Mapping(**cfg))
|
||||
|
||||
# right x to wheel x
|
||||
cfg["event_combination"] = ",".join((str(EV_ABS), str(ABS_RX), "0"))
|
||||
cfg["output_code"] = REL_HWHEEL_HI_RES
|
||||
self.preset.add(Mapping(**cfg))
|
||||
|
||||
# right y to wheel y
|
||||
cfg["event_combination"] = ",".join((str(EV_ABS), str(ABS_RY), "0"))
|
||||
cfg["output_code"] = REL_WHEEL_HI_RES
|
||||
self.preset.add(Mapping(**cfg))
|
||||
|
||||
context, _ = self.setup(self.gamepad_source, self.preset)
|
||||
|
||||
self.gamepad_source.push_events(
|
||||
[
|
||||
new_event(EV_KEY, trigger, 1), # start the macro
|
||||
new_event(EV_ABS, ABS_Y, 10), # ignored
|
||||
new_event(EV_KEY, 2, 2), # ignored
|
||||
new_event(EV_KEY, 2, 0), # ignored
|
||||
new_event(EV_REL, 1, 1), # ignored
|
||||
# stop it, the only way to trigger `then`
|
||||
new_event(EV_KEY, trigger, 0),
|
||||
]
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
self.assertEqual(len(context.listeners), 0)
|
||||
history = [a.t for a in global_uinputs.get_uinput("keyboard").write_history]
|
||||
self.assertIn((EV_KEY, code_a, 1), history)
|
||||
self.assertIn((EV_KEY, code_a, 0), history)
|
||||
self.assertNotIn((EV_KEY, code_shift, 1), history)
|
||||
self.assertNotIn((EV_KEY, code_shift, 0), history)
|
||||
|
||||
async def test_if_single_joystick_under_threshold(self):
|
||||
"""triggers then because the joystick events value is too low."""
|
||||
# TODO: Move this somewhere more sensible
|
||||
code_a = system_mapping.get("a")
|
||||
trigger = 1
|
||||
self.preset.add(
|
||||
get_key_mapping(
|
||||
EventCombination([EV_KEY, trigger, 1]),
|
||||
"keyboard",
|
||||
"if_single(k(a), k(KEY_LEFTSHIFT))",
|
||||
)
|
||||
)
|
||||
self.preset.add(
|
||||
get_key_mapping(EventCombination([EV_ABS, ABS_Y, 1]), "keyboard", "b")
|
||||
)
|
||||
|
||||
# self.preset.set("gamepad.joystick.left_purpose", BUTTONS)
|
||||
# self.preset.set("gamepad.joystick.right_purpose", BUTTONS)
|
||||
context, _ = self.setup(self.gamepad_source, self.preset)
|
||||
|
||||
self.gamepad_source.push_events(
|
||||
[
|
||||
new_event(EV_KEY, trigger, 1), # start the macro
|
||||
new_event(EV_ABS, ABS_Y, 1), # ignored because value too low
|
||||
new_event(EV_KEY, trigger, 0), # stop, only way to trigger `then`
|
||||
]
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
self.assertEqual(len(context.listeners), 0)
|
||||
history = [a.t for a in global_uinputs.get_uinput("keyboard").write_history]
|
||||
|
||||
# the key that triggered if_single should be injected after
|
||||
# if_single had a chance to inject keys (if the macro is fast enough),
|
||||
# so that if_single can inject a modifier to e.g. capitalize the
|
||||
# triggering key. This is important for the space cadet shift
|
||||
self.assertListEqual(
|
||||
history,
|
||||
[
|
||||
(EV_KEY, code_a, 1),
|
||||
(EV_KEY, code_a, 0),
|
||||
],
|
||||
)
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,335 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import unittest
|
||||
from functools import partial
|
||||
|
||||
from evdev.ecodes import EV_KEY
|
||||
from pydantic import ValidationError
|
||||
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.configs.system_mapping import system_mapping
|
||||
from inputremapper.input_event import EventActions
|
||||
from inputremapper.event_combination import EventCombination
|
||||
|
||||
|
||||
class TestMapping(unittest.IsolatedAsyncioTestCase):
|
||||
def test_init(self):
|
||||
"""test init and that defaults are set"""
|
||||
cfg = {
|
||||
"event_combination": "1,2,1",
|
||||
"target_uinput": "keyboard",
|
||||
"output_symbol": "a",
|
||||
}
|
||||
m = Mapping(**cfg)
|
||||
self.assertEqual(m.event_combination, EventCombination.validate("1,2,1"))
|
||||
self.assertEqual(m.target_uinput, "keyboard")
|
||||
self.assertEqual(m.output_symbol, "a")
|
||||
|
||||
self.assertIsNone(m.output_code)
|
||||
self.assertIsNone(m.output_type)
|
||||
|
||||
self.assertEqual(m.macro_key_sleep_ms, 20)
|
||||
self.assertEqual(m.deadzone, 0.1)
|
||||
self.assertEqual(m.gain, 1)
|
||||
self.assertEqual(m.expo, 0)
|
||||
self.assertEqual(m.rate, 60)
|
||||
self.assertEqual(m.rel_speed, 100)
|
||||
self.assertEqual(m.rel_input_cutoff, 100)
|
||||
self.assertEqual(m.release_timeout, 0.05)
|
||||
|
||||
def test_get_output_type_code(self):
|
||||
cfg = {
|
||||
"event_combination": "1,2,1",
|
||||
"target_uinput": "keyboard",
|
||||
"output_symbol": "a",
|
||||
}
|
||||
m = Mapping(**cfg)
|
||||
a = system_mapping.get("a")
|
||||
self.assertEqual(m.get_output_type_code(), (EV_KEY, a))
|
||||
m.output_symbol = "key(a)"
|
||||
self.assertIsNone(m.get_output_type_code())
|
||||
cfg = {
|
||||
"event_combination": "1,2,1+3,1,0",
|
||||
"target_uinput": "keyboard",
|
||||
"output_type": 2,
|
||||
"output_code": 3,
|
||||
}
|
||||
m = Mapping(**cfg)
|
||||
self.assertEqual(m.get_output_type_code(), (2, 3))
|
||||
|
||||
def test_init_sets_event_actions(self):
|
||||
"""test that InputEvent.actions are set properly"""
|
||||
cfg = {
|
||||
"event_combination": "1,2,1+2,1,1+3,1,0",
|
||||
"target_uinput": "keyboard",
|
||||
"output_type": 2,
|
||||
"output_code": 3,
|
||||
}
|
||||
m = Mapping(**cfg)
|
||||
expected_actions = [EventActions.as_key, EventActions.as_key, EventActions.none]
|
||||
actions = [event.action for event in m.event_combination]
|
||||
self.assertEqual(expected_actions, actions)
|
||||
|
||||
# copy keeps the event action
|
||||
m2 = m.copy()
|
||||
actions = [event.action for event in m2.event_combination]
|
||||
self.assertEqual(expected_actions, actions)
|
||||
|
||||
# changing the combination sets the action
|
||||
m3 = m.copy()
|
||||
m3.event_combination = "1,2,1+2,1,0+3,1,10"
|
||||
expected_actions = [EventActions.as_key, EventActions.none, EventActions.as_key]
|
||||
actions = [event.action for event in m3.event_combination]
|
||||
self.assertEqual(expected_actions, actions)
|
||||
|
||||
def test_combination_changed_callback(self):
|
||||
cfg = {
|
||||
"event_combination": "1,1,1",
|
||||
"target_uinput": "keyboard",
|
||||
"output_symbol": "a",
|
||||
}
|
||||
m = Mapping(**cfg)
|
||||
arguments = []
|
||||
|
||||
def callback(*args):
|
||||
arguments.append(tuple(args))
|
||||
|
||||
m.set_combination_changed_callback(callback)
|
||||
m.event_combination = "1,1,2"
|
||||
m.event_combination = "1,1,3"
|
||||
|
||||
# make sure a copy works as expected and keeps the callback
|
||||
m2 = m.copy()
|
||||
m2.event_combination = "1,1,4"
|
||||
m2.remove_combination_changed_callback()
|
||||
m.remove_combination_changed_callback()
|
||||
m.event_combination = "1,1,5"
|
||||
m2.event_combination = "1,1,6"
|
||||
self.assertEqual(
|
||||
arguments,
|
||||
[
|
||||
(
|
||||
EventCombination.from_string("1,1,2"),
|
||||
EventCombination.from_string("1,1,1"),
|
||||
),
|
||||
(
|
||||
EventCombination.from_string("1,1,3"),
|
||||
EventCombination.from_string("1,1,2"),
|
||||
),
|
||||
(
|
||||
EventCombination.from_string("1,1,4"),
|
||||
EventCombination.from_string("1,1,3"),
|
||||
),
|
||||
],
|
||||
)
|
||||
m.remove_combination_changed_callback()
|
||||
|
||||
def test_init_fails(self):
|
||||
"""test that the init fails with invalid data"""
|
||||
test = partial(self.assertRaises, ValidationError, Mapping)
|
||||
cfg = {
|
||||
"event_combination": "1,2,3",
|
||||
"target_uinput": "keyboard",
|
||||
"output_symbol": "a",
|
||||
}
|
||||
Mapping(**cfg)
|
||||
|
||||
# missing output symbol
|
||||
del cfg["output_symbol"]
|
||||
test(**cfg)
|
||||
cfg["output_code"] = 1
|
||||
test(**cfg)
|
||||
cfg["output_type"] = 1
|
||||
Mapping(**cfg)
|
||||
|
||||
# matching type, code and symbol
|
||||
a = system_mapping.get("a")
|
||||
cfg["output_code"] = a
|
||||
cfg["output_symbol"] = "a"
|
||||
cfg["output_type"] = EV_KEY
|
||||
Mapping(**cfg)
|
||||
|
||||
# macro + type and code
|
||||
cfg["output_symbol"] = "key(a)"
|
||||
test(**cfg)
|
||||
cfg["output_symbol"] = "a"
|
||||
Mapping(**cfg)
|
||||
|
||||
# mismatching type, code and symbol
|
||||
cfg["output_symbol"] = "b"
|
||||
test(**cfg)
|
||||
del cfg["output_type"]
|
||||
del cfg["output_code"]
|
||||
Mapping(**cfg) # no error
|
||||
|
||||
# empty symbol string without type and code
|
||||
cfg["output_symbol"] = ""
|
||||
test(**cfg)
|
||||
cfg["output_symbol"] = "a"
|
||||
|
||||
# missing target
|
||||
del cfg["target_uinput"]
|
||||
test(**cfg)
|
||||
|
||||
# unknown target
|
||||
cfg["target_uinput"] = "foo"
|
||||
test(**cfg)
|
||||
cfg["target_uinput"] = "keyboard"
|
||||
Mapping(**cfg)
|
||||
|
||||
# missing event_combination
|
||||
del cfg["event_combination"]
|
||||
test(**cfg)
|
||||
cfg["event_combination"] = "1,2,3"
|
||||
Mapping(**cfg)
|
||||
|
||||
# no macro and not a known symbol
|
||||
cfg["output_symbol"] = "qux"
|
||||
test(**cfg)
|
||||
cfg["output_symbol"] = "key(a)"
|
||||
Mapping(**cfg)
|
||||
|
||||
# invalid macro
|
||||
cfg["output_symbol"] = "key('a')"
|
||||
test(**cfg)
|
||||
cfg["output_symbol"] = "a"
|
||||
Mapping(**cfg)
|
||||
|
||||
# map axis but no output type and code given
|
||||
cfg["event_combination"] = "3,0,0"
|
||||
test(**cfg)
|
||||
del cfg["output_symbol"]
|
||||
cfg["output_code"] = 1
|
||||
cfg["output_type"] = 3
|
||||
Mapping(**cfg)
|
||||
|
||||
# empty symbol string is allowed when type and code is set
|
||||
cfg["output_symbol"] = ""
|
||||
Mapping(**cfg)
|
||||
del cfg["output_symbol"]
|
||||
|
||||
# multiple axis as axis in event combination
|
||||
cfg["event_combination"] = "3,0,0+3,1,0"
|
||||
test(**cfg)
|
||||
cfg["event_combination"] = "3,0,0"
|
||||
Mapping(**cfg)
|
||||
|
||||
del cfg["output_type"]
|
||||
del cfg["output_code"]
|
||||
cfg["event_combination"] = "1,2,3"
|
||||
cfg["output_symbol"] = "a"
|
||||
Mapping(**cfg)
|
||||
|
||||
# map EV_ABS as key with trigger point out of range
|
||||
cfg["event_combination"] = "3,0,100"
|
||||
test(**cfg)
|
||||
cfg["event_combination"] = "3,0,99"
|
||||
Mapping(**cfg)
|
||||
cfg["event_combination"] = "3,0,-100"
|
||||
test(**cfg)
|
||||
cfg["event_combination"] = "3,0,-99"
|
||||
Mapping(**cfg)
|
||||
|
||||
cfg["event_combination"] = "1,2,3"
|
||||
Mapping(**cfg)
|
||||
|
||||
# deadzone out of range
|
||||
test(**cfg, deadzone=1.01)
|
||||
test(**cfg, deadzone=-0.01)
|
||||
Mapping(**cfg, deadzone=1)
|
||||
Mapping(**cfg, deadzone=0)
|
||||
|
||||
# expo out of range
|
||||
test(**cfg, expo=1.01)
|
||||
test(**cfg, expo=-1.01)
|
||||
Mapping(**cfg, expo=1)
|
||||
Mapping(**cfg, expo=-1)
|
||||
|
||||
# negative rate
|
||||
test(**cfg, rate=-1)
|
||||
test(**cfg, rate=0)
|
||||
Mapping(**cfg, rate=1)
|
||||
Mapping(**cfg, rate=200)
|
||||
|
||||
# negative rel_speed
|
||||
test(**cfg, rel_speed=-1)
|
||||
test(**cfg, rel_speed=0)
|
||||
Mapping(**cfg, rel_speed=1)
|
||||
Mapping(**cfg, rel_speed=200)
|
||||
|
||||
# negative rel_input_cutoff
|
||||
test(**cfg, rel_input_cutoff=-1)
|
||||
test(**cfg, rel_input_cutoff=0)
|
||||
Mapping(**cfg, rel_input_cutoff=1)
|
||||
Mapping(**cfg, rel_input_cutoff=200)
|
||||
|
||||
# negative release timeout
|
||||
test(**cfg, release_timeout=-0.1)
|
||||
test(**cfg, release_timeout=0)
|
||||
Mapping(**cfg, release_timeout=0.05)
|
||||
Mapping(**cfg, release_timeout=0.3)
|
||||
|
||||
def test_revalidate_at_assignment(self):
|
||||
cfg = {
|
||||
"event_combination": "1,1,1",
|
||||
"target_uinput": "keyboard",
|
||||
"output_symbol": "a",
|
||||
}
|
||||
m = Mapping(**cfg)
|
||||
test = partial(self.assertRaises, ValidationError, m.__setattr__)
|
||||
|
||||
# invalid input event
|
||||
test("event_combination", "1,2,3,4")
|
||||
|
||||
# unknown target
|
||||
test("target_uinput", "foo")
|
||||
|
||||
# invalid macro
|
||||
test("output_symbol", "key()")
|
||||
|
||||
# we could do a lot more tests here but since pydantic uses the same validation
|
||||
# code as for the initialization we only need to make sure that the
|
||||
# assignment validation is active
|
||||
|
||||
def test_set_invalid_combination_with_callback(self):
|
||||
cfg = {
|
||||
"event_combination": "1,1,1",
|
||||
"target_uinput": "keyboard",
|
||||
"output_symbol": "a",
|
||||
}
|
||||
m = Mapping(**cfg)
|
||||
m.set_combination_changed_callback(lambda *args: None)
|
||||
self.assertRaises(ValidationError, m.__setattr__, "event_combination", "1,2")
|
||||
m.event_combination = "1,2,3"
|
||||
m.event_combination = "1,2,3"
|
||||
|
||||
def test_is_valid(self):
|
||||
cfg = {
|
||||
"event_combination": "1,1,1",
|
||||
"target_uinput": "keyboard",
|
||||
"output_symbol": "a",
|
||||
}
|
||||
m = Mapping(**cfg)
|
||||
self.assertTrue(m.is_valid())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
Loading…
Reference in New Issue