chore(repo): baseline du fork input-remapper (upstream v2.2.1)
Le dépôt ne contenait que README.md ; ajout de l'intégralité du code fonctionnel du fork comme socle stable de la branche de release. L'état runtime d'orchestration (.ideai/) est exclu du suivi. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
18
inputremapper/injection/mapping_handlers/__init__.py
Normal file
18
inputremapper/injection/mapping_handlers/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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/>.
|
||||
152
inputremapper/injection/mapping_handlers/abs_to_abs_handler.py
Normal file
152
inputremapper/injection/mapping_handlers/abs_to_abs_handler.py
Normal file
@ -0,0 +1,152 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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, Optional, Dict, List
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import EV_ABS
|
||||
|
||||
from inputremapper import exceptions
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.utils import get_evdev_constant_name
|
||||
|
||||
|
||||
class AbsToAbsHandler(MappingHandler):
|
||||
"""Handler which transforms EV_ABS to EV_ABS events."""
|
||||
|
||||
_map_axis: InputConfig # the InputConfig for the axis we map
|
||||
_output_axis: Tuple[int, int] # the (type, code) of the output axis
|
||||
_transform: Optional[Transformation]
|
||||
_target_absinfo: evdev.AbsInfo
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
|
||||
# find the input event we are supposed to map. If the input combination is
|
||||
# BTN_A + ABS_X + BTN_B, then use the value of ABS_X for the transformation
|
||||
assert (map_axis := combination.find_analog_input_config(type_=EV_ABS))
|
||||
self._map_axis = map_axis
|
||||
|
||||
assert mapping.output_code is not None
|
||||
assert mapping.output_type == EV_ABS
|
||||
self._output_axis = (mapping.output_type, mapping.output_code)
|
||||
|
||||
target_uinput = global_uinputs.get_uinput(mapping.target_uinput)
|
||||
assert target_uinput is not None
|
||||
abs_capabilities = target_uinput.capabilities(absinfo=True)[EV_ABS]
|
||||
self._target_absinfo = dict(abs_capabilities)[mapping.output_code]
|
||||
|
||||
self._transform = None
|
||||
|
||||
def __str__(self):
|
||||
name = get_evdev_constant_name(*self._map_axis.type_and_code)
|
||||
return (
|
||||
f'AbsToAbsHandler for "{name}" {self._map_axis} '
|
||||
f"maps {self._map_axis} to: {self.mapping.get_output_name_constant()} "
|
||||
f"{self.mapping.get_output_type_code()} at "
|
||||
f"{self.mapping.target_uinput}"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
if event.input_match_hash != self._map_axis.input_match_hash:
|
||||
return False
|
||||
|
||||
if EventActions.recenter in event.actions:
|
||||
self._write(self._scale_to_target(0))
|
||||
return True
|
||||
|
||||
if not self._transform:
|
||||
absinfo = dict(source.capabilities(absinfo=True)[EV_ABS])[event.code] # type: ignore
|
||||
self._transform = Transformation(
|
||||
max_=absinfo.max,
|
||||
min_=absinfo.min,
|
||||
deadzone=self.mapping.deadzone,
|
||||
gain=self.mapping.gain,
|
||||
expo=self.mapping.expo,
|
||||
)
|
||||
|
||||
try:
|
||||
self._write(self._scale_to_target(self._transform(event.value)))
|
||||
return True
|
||||
except (exceptions.UinputNotAvailable, exceptions.EventNotHandled):
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
self._write(self._scale_to_target(0))
|
||||
|
||||
def _scale_to_target(self, x: float) -> int:
|
||||
"""Scales an x value between -1 and 1 to an integer between
|
||||
target_absinfo.min and target_absinfo.max
|
||||
|
||||
input values above 1 or below -1 are clamped to the extreme values
|
||||
"""
|
||||
factor = (self._target_absinfo.max - self._target_absinfo.min) / 2
|
||||
offset = self._target_absinfo.min + factor
|
||||
y = factor * x + offset
|
||||
if y > offset:
|
||||
return int(min(self._target_absinfo.max, y))
|
||||
else:
|
||||
return int(max(self._target_absinfo.min, y))
|
||||
|
||||
def _write(self, value: int):
|
||||
"""Inject."""
|
||||
try:
|
||||
self.global_uinputs.write(
|
||||
(*self._output_axis, value), self.mapping.target_uinput
|
||||
)
|
||||
except OverflowError:
|
||||
# screwed up the calculation of the event value
|
||||
logger.error("OverflowError (%s, %s, %s)", *self._output_axis, value)
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return len(self.input_configs) > 1
|
||||
|
||||
def set_sub_handler(self, handler: MappingHandler) -> None:
|
||||
assert False # cannot have a sub-handler
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
if self.needs_wrapping():
|
||||
return {InputCombination(self.input_configs): HandlerEnums.axisswitch}
|
||||
return {}
|
||||
151
inputremapper/injection/mapping_handlers/abs_to_btn_handler.py
Normal file
151
inputremapper/injection/mapping_handlers/abs_to_btn_handler.py
Normal file
@ -0,0 +1,151 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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 List
|
||||
|
||||
import evdev
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.abs_util import calculate_trigger_point
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.utils import get_evdev_constant_name
|
||||
|
||||
|
||||
class AbsToBtnHandler(MappingHandler):
|
||||
"""Handler which transforms an EV_ABS to a button event."""
|
||||
|
||||
_input_config: InputConfig
|
||||
_configured_direction_was_pressed: bool
|
||||
_sub_handler: MappingHandler
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
|
||||
self._configured_direction_was_pressed = False
|
||||
self._input_config = combination[0]
|
||||
assert self._input_config.analog_threshold
|
||||
assert len(combination) == 1
|
||||
|
||||
def __str__(self):
|
||||
name = get_evdev_constant_name(*self._input_config.type_and_code)
|
||||
return f'AbsToBtnHandler for "{name}" ' f"{self._input_config.type_and_code}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return [self._sub_handler]
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
if event.input_match_hash != self._input_config.input_match_hash:
|
||||
return False
|
||||
|
||||
analog_threshold = self._input_config.analog_threshold
|
||||
assert analog_threshold is not None
|
||||
|
||||
threshold, mid_point = calculate_trigger_point(
|
||||
event,
|
||||
analog_threshold,
|
||||
source,
|
||||
)
|
||||
|
||||
value = event.value
|
||||
|
||||
direction = 1 if value > mid_point else -1
|
||||
|
||||
want_positive = analog_threshold > 0
|
||||
want_negative = analog_threshold < 0
|
||||
|
||||
# For dpads, the threshold is 1, but so is the max value. So <= and >= it is.
|
||||
# If this is dumb, change the threhsold to be a float.
|
||||
pressed = value >= threshold if want_positive else value <= threshold
|
||||
|
||||
'''print(f"""abs_to_btn
|
||||
{pressed=}
|
||||
{value=}
|
||||
{want_positive=}
|
||||
{want_negative=}
|
||||
{direction=}
|
||||
{threshold=}
|
||||
{mid_point=}
|
||||
{analog_threshold=}
|
||||
{self._configured_direction_was_pressed=}"""
|
||||
)'''
|
||||
|
||||
# dpad-right to a:
|
||||
# dpad moves right: a down
|
||||
# dpad returns: a up
|
||||
# dpad goes left: dpad -1
|
||||
# dpad returns: dpad 0
|
||||
# There are two "dpad returns" cases that have different outcomes
|
||||
|
||||
# joystick-right to a:
|
||||
# joystick moves to +1234: ignore (If the architecture could do it, forward 0)
|
||||
# joystick moves over threshold: a down
|
||||
# joystick returns below threshold: a up
|
||||
# joystick moves -1234: forward -1234
|
||||
# joystick goes to 0: forward 0
|
||||
# (In many cases it won't exactly return to 0, but to +1 or something, because
|
||||
# they aren't 100% precise. But the positive direction is mapped, so turn
|
||||
# this into 0. Unfortunately there is currently no way to do this in our
|
||||
# architecture.)
|
||||
|
||||
if not self._configured_direction_was_pressed:
|
||||
# these needs to be <= and >= mid point, to forward the dpad release for
|
||||
# the unmapped direction
|
||||
if want_positive and value <= mid_point:
|
||||
return False
|
||||
if want_negative and value >= mid_point:
|
||||
return False
|
||||
|
||||
# if it was pressed, then we first need to deal with releasing the sub-handler.
|
||||
|
||||
self._configured_direction_was_pressed = pressed
|
||||
|
||||
event = event.modify(
|
||||
pressed=pressed,
|
||||
direction=direction,
|
||||
actions=(EventActions.as_key,),
|
||||
)
|
||||
|
||||
return self._sub_handler.notify(
|
||||
event,
|
||||
source=source,
|
||||
suppress=suppress,
|
||||
)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._configured_direction_was_pressed = False
|
||||
self._sub_handler.reset()
|
||||
245
inputremapper/injection/mapping_handlers/abs_to_rel_handler.py
Normal file
245
inputremapper/injection/mapping_handlers/abs_to_rel_handler.py
Normal file
@ -0,0 +1,245 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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 math
|
||||
import time
|
||||
from functools import partial
|
||||
from typing import Dict, Tuple, Optional, List
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import (
|
||||
EV_REL,
|
||||
EV_ABS,
|
||||
REL_WHEEL,
|
||||
REL_HWHEEL,
|
||||
REL_WHEEL_HI_RES,
|
||||
REL_HWHEEL_HI_RES,
|
||||
)
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import (
|
||||
Mapping,
|
||||
REL_XY_SCALING,
|
||||
WHEEL_SCALING,
|
||||
WHEEL_HI_RES_SCALING,
|
||||
DEFAULT_REL_RATE,
|
||||
)
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.utils import get_evdev_constant_name
|
||||
|
||||
|
||||
class AbsToRelHandler(MappingHandler):
|
||||
"""Handler which transforms an EV_ABS to EV_REL events."""
|
||||
|
||||
_map_axis: InputConfig # the InputConfig for 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: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
|
||||
# find the input event we are supposed to map
|
||||
assert (map_axis := combination.find_analog_input_config(type_=EV_ABS))
|
||||
self._map_axis = map_axis
|
||||
|
||||
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)
|
||||
|
||||
self._run = partial(self._run_wheel_output, codes=codes)
|
||||
|
||||
else:
|
||||
self._run = partial(self._run_normal_output)
|
||||
|
||||
def __str__(self):
|
||||
name = get_evdev_constant_name(*self._map_axis.type_and_code)
|
||||
return (
|
||||
f'AbsToRelHandler for "{name}" {self._map_axis}: '
|
||||
f"maps to {self.mapping.get_output_name_constant()} "
|
||||
f"{self.mapping.get_output_type_code()} at "
|
||||
f"{self.mapping.target_uinput}"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
if event.input_match_hash != self._map_axis.input_match_hash:
|
||||
return False
|
||||
|
||||
if EventActions.recenter in event.actions:
|
||||
self._stop = True
|
||||
return True
|
||||
|
||||
if not self._transform:
|
||||
absinfo = {
|
||||
entry[0]: entry[1] # type: ignore
|
||||
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,
|
||||
)
|
||||
|
||||
transformed = self._transform(event.value)
|
||||
|
||||
self._value = transformed
|
||||
|
||||
if transformed == 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, 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:
|
||||
# rel 0 does not make sense. We don't need to tell linux that the mouse
|
||||
# should not be moved this time.
|
||||
return
|
||||
|
||||
try:
|
||||
self.global_uinputs.write(
|
||||
(type_, keycode, value),
|
||||
self.mapping.target_uinput,
|
||||
)
|
||||
except OverflowError:
|
||||
# screwed up the calculation of mouse movements
|
||||
logger.error("OverflowError (%s, %s, %s)", type_, keycode, value)
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return len(self.input_configs) > 1
|
||||
|
||||
def set_sub_handler(self, handler: MappingHandler) -> None:
|
||||
assert False # cannot have a sub-handler
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
if self.needs_wrapping():
|
||||
return {InputCombination(self.input_configs): HandlerEnums.axisswitch}
|
||||
return {}
|
||||
|
||||
def _calculate_output(self, value, weight, remainder):
|
||||
# self._value is between 0 and 1, scale up with weight
|
||||
scaled = value * weight + remainder
|
||||
# float_value % 1 will result in wrong calculations for negative values
|
||||
remainder = math.fmod(scaled, 1)
|
||||
return int(scaled), remainder
|
||||
|
||||
async def _run_normal_output(self) -> None:
|
||||
"""Start injecting events."""
|
||||
self._running = True
|
||||
self._stop = False
|
||||
remainder = 0.0
|
||||
start = time.time()
|
||||
|
||||
# if the rate is configured to be slower than the default, increase the value, so
|
||||
# that the overall speed stays the same.
|
||||
rate_compensation = DEFAULT_REL_RATE / self.mapping.rel_rate
|
||||
weight = REL_XY_SCALING * rate_compensation
|
||||
|
||||
while not self._stop:
|
||||
value, remainder = self._calculate_output(
|
||||
self._value,
|
||||
weight,
|
||||
remainder,
|
||||
)
|
||||
|
||||
self._write(EV_REL, self.mapping.output_code, value)
|
||||
|
||||
time_taken = time.time() - start
|
||||
sleep = max(0.0, (1 / self.mapping.rel_rate) - time_taken)
|
||||
await asyncio.sleep(sleep)
|
||||
start = time.time()
|
||||
|
||||
self._running = False
|
||||
|
||||
async def _run_wheel_output(self, codes: Tuple[int, int]) -> None:
|
||||
"""Start injecting wheel events.
|
||||
|
||||
made to inject both REL_WHEEL and REL_WHEEL_HI_RES events, because otherwise
|
||||
wheel output doesn't work for some people. See issue #354
|
||||
"""
|
||||
weights = (WHEEL_SCALING, WHEEL_HI_RES_SCALING)
|
||||
|
||||
self._running = True
|
||||
self._stop = False
|
||||
remainder = [0.0, 0.0]
|
||||
start = time.time()
|
||||
while not self._stop:
|
||||
for i in range(len(codes)):
|
||||
value, remainder[i] = self._calculate_output(
|
||||
self._value,
|
||||
weights[i],
|
||||
remainder[i],
|
||||
)
|
||||
|
||||
self._write(EV_REL, codes[i], value)
|
||||
|
||||
time_taken = time.time() - start
|
||||
await asyncio.sleep(max(0.0, (1 / self.mapping.rel_rate) - time_taken))
|
||||
start = time.time()
|
||||
|
||||
self._running = False
|
||||
72
inputremapper/injection/mapping_handlers/abs_util.py
Normal file
72
inputremapper/injection/mapping_handlers/abs_util.py
Normal file
@ -0,0 +1,72 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import EV_ABS, ABS_GAS, ABS_BRAKE, ABS_Z, ABS_RZ
|
||||
|
||||
from inputremapper.input_event import InputEvent
|
||||
|
||||
|
||||
# TODO: potentially cache this function { cache_key_from_args: previous_result }
|
||||
def calculate_trigger_point(
|
||||
event: InputEvent,
|
||||
analog_threshold: int,
|
||||
source: evdev.InputDevice,
|
||||
) -> Tuple[float, float]:
|
||||
"""Calculate the threshold and resting-point of the axis.
|
||||
|
||||
If an EV_ABS events value suprasses the threshold, it should be considered pressed.
|
||||
|
||||
The threshold is the offset from the resting-point/middle in both directions.
|
||||
|
||||
The resting point might be the middle value for a joystick: 0, *128*, 256 or
|
||||
-128, *0*, 128. Or it might be the minimum value of the shoulder triggers: *0* 256.
|
||||
"""
|
||||
absinfo = dict(source.capabilities(absinfo=True)[EV_ABS]) # type: ignore
|
||||
abs_min = absinfo[event.code].min
|
||||
abs_max = absinfo[event.code].max
|
||||
|
||||
assert analog_threshold
|
||||
if abs_min == -1 and abs_max == 1:
|
||||
# this is a hat switch
|
||||
# return +-1
|
||||
return (
|
||||
analog_threshold // abs(analog_threshold),
|
||||
0,
|
||||
)
|
||||
|
||||
if event.code in [ABS_GAS, ABS_BRAKE, ABS_Z, ABS_RZ]:
|
||||
threshold = abs_max * analog_threshold / 100
|
||||
# For the L/R triggers, there is only one direction, and the resting
|
||||
# position is the same as the min_abs.
|
||||
middle = abs_min
|
||||
return threshold, middle
|
||||
|
||||
half_range = (abs_max - abs_min) / 2
|
||||
middle = half_range + abs_min
|
||||
trigger_offset = half_range * analog_threshold / 100
|
||||
# Examples for threshold of +50:
|
||||
# -128 to 128. half_range is 128. middle is 0. trigger_offset is 64 (and above)
|
||||
# 0 to 128. half_range is 64. middle is 64. trigger_offset is 96 (and above)
|
||||
|
||||
# threshold, middle
|
||||
threshold = middle + trigger_offset
|
||||
return threshold, middle
|
||||
190
inputremapper/injection/mapping_handlers/axis_switch_handler.py
Normal file
190
inputremapper/injection/mapping_handlers/axis_switch_handler.py
Normal file
@ -0,0 +1,190 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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, Hashable, List
|
||||
|
||||
import evdev
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination
|
||||
from inputremapper.configs.input_config import InputConfig
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
ContextProtocol,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.utils import get_device_hash
|
||||
|
||||
|
||||
class AxisSwitchHandler(MappingHandler):
|
||||
"""Enables or disables an axis.
|
||||
|
||||
This is used when a combination involving an analog input (rel or abs) is mapped
|
||||
to another analog output (rel or abs). I think.
|
||||
|
||||
Generally, if multiple events are mapped to something in a combination, all of
|
||||
them need to be triggered in order to map to the output.
|
||||
|
||||
If an analog input is combined with a key input, then the same thing should happen.
|
||||
The key needs to be pressed and the joystick needs to be moved in order to generate
|
||||
output.
|
||||
"""
|
||||
|
||||
_map_axis: InputConfig # the InputConfig for the axis we switch on or off
|
||||
_trigger_keys: Tuple[Hashable, ...] # 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 cached source of the axis input events
|
||||
_forward_device: evdev.UInput # the cached forward uinput
|
||||
_sub_handler: MappingHandler
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
context: ContextProtocol,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
):
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
trigger_keys = tuple(
|
||||
event.input_match_hash
|
||||
for event in combination
|
||||
if not event.defines_analog_input
|
||||
)
|
||||
assert len(trigger_keys) >= 1
|
||||
assert (map_axis := combination.find_analog_input_config())
|
||||
self._map_axis = map_axis
|
||||
self._trigger_keys = trigger_keys
|
||||
self._active = False
|
||||
|
||||
self._last_value = 0
|
||||
self._axis_source = None
|
||||
self._forward_device = None
|
||||
|
||||
self.context = context
|
||||
|
||||
def __str__(self):
|
||||
return f"AxisSwitchHandler for {self._map_axis.type_and_code}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return [self._sub_handler]
|
||||
|
||||
def _handle_key_input(self, event: InputEvent):
|
||||
"""If a key is pressed, allow mapping analog events in subhandlers.
|
||||
|
||||
Analog events (e.g. ABS_X, REL_Y) that have gone through Handlers that
|
||||
transform them to buttons also count as keys.
|
||||
"""
|
||||
key_is_pressed = event.is_pressed()
|
||||
if self._active == key_is_pressed:
|
||||
# nothing changed
|
||||
return False
|
||||
|
||||
self._active = key_is_pressed
|
||||
|
||||
if self._axis_source is None:
|
||||
return True
|
||||
|
||||
if not key_is_pressed:
|
||||
# recenter the axis
|
||||
logger.debug("Stopping axis for %s", self.mapping.input_combination)
|
||||
event = InputEvent(
|
||||
0,
|
||||
0,
|
||||
*self._map_axis.type_and_code,
|
||||
0,
|
||||
actions=(EventActions.recenter,),
|
||||
origin_hash=self._map_axis.origin_hash,
|
||||
)
|
||||
self._sub_handler.notify(event, self._axis_source)
|
||||
return True
|
||||
|
||||
if self._map_axis.type == evdev.ecodes.EV_ABS:
|
||||
# send the last cached value so that the abs axis
|
||||
# is at the correct position
|
||||
logger.debug("Starting axis for %s", self.mapping.input_combination)
|
||||
event = InputEvent(
|
||||
0,
|
||||
0,
|
||||
*self._map_axis.type_and_code,
|
||||
self._last_value,
|
||||
origin_hash=self._map_axis.origin_hash,
|
||||
)
|
||||
self._sub_handler.notify(event, self._axis_source)
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
def _should_map(self, event: InputEvent):
|
||||
return (
|
||||
event.input_match_hash in self._trigger_keys
|
||||
or event.input_match_hash == self._map_axis.input_match_hash
|
||||
)
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
if not self._should_map(event):
|
||||
return False
|
||||
|
||||
if event.is_key_event:
|
||||
# A key or an analog even that is being treated as a key (on/off) due to
|
||||
# previous handlers.
|
||||
return self._handle_key_input(event)
|
||||
|
||||
# do some caching so that we can generate the
|
||||
# recenter event and an initial abs event
|
||||
if self._axis_source is None:
|
||||
self._axis_source = source
|
||||
|
||||
if self._forward_device is None:
|
||||
device_hash = get_device_hash(source)
|
||||
self._forward_device = self.context.get_forward_uinput(device_hash)
|
||||
|
||||
# always cache the value
|
||||
self._last_value = event.value
|
||||
|
||||
if self._active:
|
||||
return self._sub_handler.notify(event, source, suppress)
|
||||
|
||||
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[InputCombination, HandlerEnums]:
|
||||
combination = [
|
||||
config for config in self.input_configs if not config.defines_analog_input
|
||||
]
|
||||
return {InputCombination(combination): HandlerEnums.combination}
|
||||
140
inputremapper/injection/mapping_handlers/axis_transform.py
Normal file
140
inputremapper/injection/mapping_handlers/axis_transform.py
Normal file
@ -0,0 +1,140 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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,
|
||||
# if input values are > max_, the return value will be > 1
|
||||
max_: Union[int, float],
|
||||
min_: Union[int, float],
|
||||
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 set_range(self, min_, max_):
|
||||
# TODO docstring
|
||||
if min_ != self._min or max_ != self._max:
|
||||
self._cache = {}
|
||||
|
||||
self._min = min_
|
||||
self._max = max_
|
||||
|
||||
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")
|
||||
275
inputremapper/injection/mapping_handlers/combination_handler.py
Normal file
275
inputremapper/injection/mapping_handlers/combination_handler.py
Normal file
@ -0,0 +1,275 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations # needed for the TYPE_CHECKING import
|
||||
|
||||
from typing import TYPE_CHECKING, Dict, Hashable, Tuple, List
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import EV_ABS, EV_REL
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
MappingHandler,
|
||||
HandlerEnums,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inputremapper.injection.context import Context
|
||||
|
||||
|
||||
class CombinationHandler(MappingHandler):
|
||||
"""Keeps track of a combination and notifies a sub handler."""
|
||||
|
||||
# map of InputEvent.input_match_hash -> bool , keep track of the combination state
|
||||
_pressed_keys: Dict[Hashable, bool]
|
||||
# the last update we sent to a sub-handler. If this is true, the output key is
|
||||
# still being held down.
|
||||
_output_previously_active: bool
|
||||
_sub_handler: MappingHandler
|
||||
_handled_input_hashes: list[Hashable]
|
||||
_requires_a_release: Dict[Tuple[int, int], bool]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
context: Context,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
logger.debug(str(mapping))
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
self._pressed_keys = {}
|
||||
self._output_previously_active = False
|
||||
self._context = context
|
||||
self._requires_a_release = {}
|
||||
|
||||
# prepare a key map for all events with non-zero value
|
||||
for input_config in combination:
|
||||
assert not input_config.defines_analog_input
|
||||
self._pressed_keys[input_config.input_match_hash] = False
|
||||
|
||||
self._handled_input_hashes = [
|
||||
input_config.input_match_hash for input_config in combination
|
||||
]
|
||||
|
||||
assert len(self._pressed_keys) > 0 # no combination handler without a key
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f'CombinationHandler for "{str(self.mapping.input_combination)}" '
|
||||
f"{tuple(t for t in self._pressed_keys.keys())}"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
description = (
|
||||
f'CombinationHandler for "{repr(self.mapping.input_combination)}" '
|
||||
f"{tuple(t for t in self._pressed_keys.keys())}"
|
||||
)
|
||||
return f"<{description} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return [self._sub_handler]
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
if event.input_match_hash not in self._handled_input_hashes:
|
||||
# we are not responsible for the event
|
||||
return False
|
||||
|
||||
# update the state
|
||||
# The value of non-key input should have been changed to either 0 or 1 at this
|
||||
# point by other handlers.
|
||||
is_pressed = event.is_pressed()
|
||||
self._pressed_keys[event.input_match_hash] = is_pressed
|
||||
# maybe this changes the activation status (triggered/not-triggered)
|
||||
changed = self._is_activated() != self._output_previously_active
|
||||
|
||||
if changed:
|
||||
if is_pressed:
|
||||
return self._handle_freshly_activated(suppress, event, source)
|
||||
else:
|
||||
return self._handle_freshly_deactivated(event, source)
|
||||
else:
|
||||
if is_pressed:
|
||||
return self._handle_no_change_press(event)
|
||||
else:
|
||||
return self._handle_no_change_release(event)
|
||||
|
||||
def _handle_no_change_press(self, event: InputEvent) -> bool:
|
||||
"""A key was pressed, but this doesn't change the combinations activation state.
|
||||
Can only happen if either the combination wasn't already active, or a duplicate
|
||||
key-down event arrived (EV_ABS?)
|
||||
"""
|
||||
# self._output_previously_active is negated, because if the output is active, a
|
||||
# key-down event triggered it, which then did not get forwarded, therefore
|
||||
# it doesn't require a release.
|
||||
self._require_release_later(not self._output_previously_active, event)
|
||||
# output is active: consume the event
|
||||
# output inactive: forward the event
|
||||
return self._output_previously_active
|
||||
|
||||
def _handle_no_change_release(self, event: InputEvent) -> bool:
|
||||
"""One of the combinations keys was released, but it didn't untrigger the
|
||||
combination yet."""
|
||||
# Negate: `False` means that the event-reader will forward the release.
|
||||
return not self._should_release_event(event)
|
||||
|
||||
def _handle_freshly_activated(
|
||||
self,
|
||||
suppress: bool,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
) -> bool:
|
||||
"""The combination was deactivated, but is activated now."""
|
||||
if suppress:
|
||||
return False
|
||||
|
||||
# Send key up events to the forwarded uinput if configured to do so.
|
||||
self._forward_release()
|
||||
|
||||
logger.debug(
|
||||
"Sending %s to sub-handler %s",
|
||||
repr(event),
|
||||
repr(self._sub_handler),
|
||||
)
|
||||
self._output_previously_active = event.is_pressed()
|
||||
sub_handler_result = self._sub_handler.notify(event, source, suppress)
|
||||
|
||||
# Only if the sub-handler return False, we need a release-event later.
|
||||
# If it handled the event, the user never sees this key-down event.
|
||||
self._require_release_later(not sub_handler_result, event)
|
||||
return sub_handler_result
|
||||
|
||||
def _handle_freshly_deactivated(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
) -> bool:
|
||||
"""The combination was activated, but is deactivated now."""
|
||||
# We ignore the `suppress` argument for release events. Otherwise, we
|
||||
# might end up with stuck keys (test_event_pipeline.test_combination).
|
||||
# In the case of output axis, this will enable us to activate multiple
|
||||
# axis with the same button.
|
||||
|
||||
logger.debug(
|
||||
"Sending %s to sub-handler %s",
|
||||
repr(event),
|
||||
repr(self._sub_handler),
|
||||
)
|
||||
self._output_previously_active = event.is_pressed()
|
||||
self._sub_handler.notify(event, source, suppress=False)
|
||||
|
||||
# Negate: `False` means that the event-reader will forward the release.
|
||||
return not self._should_release_event(event)
|
||||
|
||||
def _should_release_event(self, event: InputEvent) -> bool:
|
||||
"""Check if the key-up event should be forwarded by the event-reader.
|
||||
|
||||
After this, the release event needs to be injected by someone, otherwise the
|
||||
dictionary was modified erroneously. If there is no entry, we assume that there
|
||||
was no key-down event to release. Maybe a duplicate event arrived.
|
||||
"""
|
||||
# Ensure that all injected key-down events will get their release event
|
||||
# injected eventually.
|
||||
# If a key-up event arrives that will inactivate the combination, but
|
||||
# for which previously a key-down event was injected (because it was
|
||||
# an earlier key in the combination chain), then we need to ensure that its
|
||||
# release is injected as well. So we get two release events in that case:
|
||||
# one for the key, and one for the output.
|
||||
assert event.is_pressed() == 0, f"expected {event.is_pressed()} to be 0"
|
||||
return self._requires_a_release.pop(event.type_and_code, False)
|
||||
|
||||
def _require_release_later(self, require: bool, event: InputEvent) -> None:
|
||||
"""Remember if this key-down event will need a release event later on."""
|
||||
assert event.is_pressed() == 1
|
||||
self._requires_a_release[event.type_and_code] = require
|
||||
|
||||
def reset(self) -> None:
|
||||
self._sub_handler.reset()
|
||||
for key in self._pressed_keys:
|
||||
self._pressed_keys[key] = False
|
||||
self._requires_a_release = {}
|
||||
self._output_previously_active = False
|
||||
|
||||
def _is_activated(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) -> 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._pressed_keys) == 1 or not self.mapping.release_combination_keys:
|
||||
return
|
||||
|
||||
keys_to_release = filter(
|
||||
lambda cfg: self._pressed_keys.get(cfg.input_match_hash),
|
||||
self.mapping.input_combination,
|
||||
)
|
||||
|
||||
logger.debug("Forwarding release for %s", self.mapping.input_combination)
|
||||
|
||||
for input_config in keys_to_release:
|
||||
if not self._requires_a_release.get(input_config.type_and_code):
|
||||
continue
|
||||
|
||||
origin_hash = input_config.origin_hash
|
||||
if origin_hash is None:
|
||||
logger.error(
|
||||
f"Can't forward due to missing origin_hash in {repr(input_config)}"
|
||||
)
|
||||
continue
|
||||
|
||||
forward_to = self._context.get_forward_uinput(origin_hash)
|
||||
logger.write(input_config, forward_to)
|
||||
forward_to.write(*input_config.type_and_code, 0)
|
||||
forward_to.syn()
|
||||
|
||||
# We are done with this key, forget about it
|
||||
del self._requires_a_release[input_config.type_and_code]
|
||||
|
||||
def needs_ranking(self) -> bool:
|
||||
return bool(self.input_configs)
|
||||
|
||||
def rank_by(self) -> InputCombination:
|
||||
return InputCombination(
|
||||
[event for event in self.input_configs if not event.defines_analog_input]
|
||||
)
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
return_dict = {}
|
||||
for config in self.input_configs:
|
||||
if config.type == EV_ABS and not config.defines_analog_input:
|
||||
return_dict[InputCombination([config])] = HandlerEnums.abs2btn
|
||||
|
||||
if config.type == EV_REL and not config.defines_analog_input:
|
||||
return_dict[InputCombination([config])] = HandlerEnums.rel2btn
|
||||
|
||||
return return_dict
|
||||
108
inputremapper/injection/mapping_handlers/hierarchy_handler.py
Normal file
108
inputremapper/injection/mapping_handlers/hierarchy_handler.py
Normal file
@ -0,0 +1,108 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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 List, Dict
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import EV_ABS, EV_REL
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
MappingHandler,
|
||||
HandlerEnums,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
|
||||
|
||||
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_config: InputConfig
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
handlers: List[MappingHandler],
|
||||
input_config: InputConfig,
|
||||
global_uinputs: GlobalUInputs,
|
||||
) -> None:
|
||||
self.handlers = handlers
|
||||
self._input_config = input_config
|
||||
combination = InputCombination([input_config])
|
||||
# use the mapping from the first child TODO: find a better solution
|
||||
mapping = handlers[0].mapping
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
|
||||
def __str__(self):
|
||||
return f"HierarchyHandler for {self._input_config}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return self.handlers
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
if event.input_match_hash != self._input_config.input_match_hash:
|
||||
return False
|
||||
|
||||
handled = False
|
||||
for handler in self.handlers:
|
||||
if handled:
|
||||
# To allow an arbitrary number of output axes to be activated at the
|
||||
# same time, we don't suppress them.
|
||||
handler.notify(
|
||||
event,
|
||||
source,
|
||||
suppress=not handler.mapping.input_combination.defines_analog_input,
|
||||
)
|
||||
continue
|
||||
|
||||
handled = handler.notify(event, source)
|
||||
|
||||
return handled
|
||||
|
||||
def reset(self) -> None:
|
||||
for sub_handler in self.handlers:
|
||||
sub_handler.reset()
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
if (
|
||||
self._input_config.type == EV_ABS
|
||||
and not self._input_config.defines_analog_input
|
||||
):
|
||||
return {InputCombination([self._input_config]): HandlerEnums.abs2btn}
|
||||
if (
|
||||
self._input_config.type == EV_REL
|
||||
and not self._input_config.defines_analog_input
|
||||
):
|
||||
return {InputCombination([self._input_config]): HandlerEnums.rel2btn}
|
||||
return {}
|
||||
|
||||
def set_sub_handler(self, handler: MappingHandler) -> None:
|
||||
assert False
|
||||
90
inputremapper/injection/mapping_handlers/key_handler.py
Normal file
90
inputremapper/injection/mapping_handlers/key_handler.py
Normal file
@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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, List
|
||||
|
||||
from inputremapper import exceptions
|
||||
from inputremapper.configs.input_config import InputCombination
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.exceptions import MappingParsingError
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.utils import get_evdev_constant_name
|
||||
|
||||
|
||||
class KeyHandler(MappingHandler):
|
||||
"""Injects the target key if notified."""
|
||||
|
||||
_active: bool
|
||||
_maps_to: Tuple[int, int]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
):
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
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):
|
||||
name = get_evdev_constant_name(*self._maps_to)
|
||||
return f"KeyHandler to {name} {self._maps_to} on {self.mapping.target_uinput}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
|
||||
def notify(self, event: InputEvent, *_, **__) -> bool:
|
||||
"""Inject the correct value to the target uinput."""
|
||||
event_tuple = (*self._maps_to, 1 if event.is_pressed() else 0)
|
||||
try:
|
||||
self.global_uinputs.write(event_tuple, self.mapping.target_uinput)
|
||||
self._active = bool(event.is_pressed())
|
||||
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)
|
||||
self.global_uinputs.write(event_tuple, self.mapping.target_uinput)
|
||||
self._active = False
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return True
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
return {InputCombination(self.input_configs): HandlerEnums.combination}
|
||||
121
inputremapper/injection/mapping_handlers/macro_handler.py
Normal file
121
inputremapper/injection/mapping_handlers/macro_handler.py
Normal file
@ -0,0 +1,121 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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 traceback
|
||||
from typing import Dict, Callable, Tuple, List
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
from inputremapper.injection.macros.parse import Parser
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
ContextProtocol,
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class MacroHandler(MappingHandler):
|
||||
"""Runs the target macro if notified."""
|
||||
|
||||
# TODO: replace this by the macro itself
|
||||
_macro: Macro
|
||||
_active: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
*,
|
||||
context: ContextProtocol,
|
||||
):
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
self._pressed_keys: Dict[Tuple[int, int], int] = {}
|
||||
self._active = False
|
||||
assert self.mapping.output_symbol is not None
|
||||
self._macro = Parser.parse(self.mapping.output_symbol, context, mapping)
|
||||
|
||||
def __str__(self):
|
||||
return f"MacroHandler maps to {self._macro} on {self.mapping.target_uinput}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
|
||||
async def run_macro(self, handler: Callable):
|
||||
"""Run the macro with the provided function."""
|
||||
try:
|
||||
await self._macro.run(handler)
|
||||
except Exception as exception:
|
||||
logger.error('Macro "%s" failed with %s', self._macro.code, type(exception))
|
||||
traceback.print_exc()
|
||||
|
||||
def notify(self, event: InputEvent, *_, **__) -> bool:
|
||||
if event.is_pressed():
|
||||
self._active = True
|
||||
self._macro.press_trigger()
|
||||
if self._macro.running:
|
||||
return True
|
||||
|
||||
def handler(type_, code, value) -> None:
|
||||
"""Handler for macros."""
|
||||
self._remember_pressed_keys((type_, code, value))
|
||||
|
||||
self.global_uinputs.write(
|
||||
(type_, code, value),
|
||||
self.mapping.target_uinput,
|
||||
)
|
||||
|
||||
asyncio.ensure_future(self.run_macro(handler))
|
||||
return True
|
||||
else:
|
||||
self._active = False
|
||||
self._macro.release_trigger()
|
||||
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._active = False
|
||||
|
||||
# To avoid a key hanging forever. Can be pretty annoying, especially if it is
|
||||
# a modifier that makes you unable to interact with your system.
|
||||
for (type, code), value in self._pressed_keys.items():
|
||||
if value == 1:
|
||||
logger.debug("Releasing key %s", (type, code, value))
|
||||
self.global_uinputs.write(
|
||||
(type, code, 0),
|
||||
self.mapping.target_uinput,
|
||||
)
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return True
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
return {InputCombination(self.input_configs): HandlerEnums.combination}
|
||||
|
||||
def _remember_pressed_keys(self, event: Tuple[int, int, int]) -> None:
|
||||
type, code, value = event
|
||||
self._pressed_keys[(type, code)] = value
|
||||
213
inputremapper/injection/mapping_handlers/mapping_handler.py
Normal file
213
inputremapper/injection/mapping_handlers/mapping_handler.py
Normal file
@ -0,0 +1,213 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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 MappingHandler protocol.
|
||||
An MappingHandler 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
|
||||
- NullHandler
|
||||
|
||||
Step 2 and 3:
|
||||
- KeyHandler
|
||||
- MacroHandler
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from typing import Dict, Protocol, Set, Optional, List
|
||||
|
||||
import evdev
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.exceptions import MappingParsingError
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class EventListener(Protocol):
|
||||
async def __call__(self, event: evdev.InputEvent) -> None: ...
|
||||
|
||||
|
||||
class ContextProtocol(Protocol):
|
||||
"""The parts from context needed for handlers."""
|
||||
|
||||
listeners: Set[EventListener]
|
||||
|
||||
def get_forward_uinput(self, origin_hash) -> evdev.UInput:
|
||||
pass
|
||||
|
||||
|
||||
class NotifyCallback(Protocol):
|
||||
"""Type signature of MappingHandler.notify
|
||||
|
||||
return True if the event was actually taken care of
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool: ...
|
||||
|
||||
|
||||
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:
|
||||
mapping: Mapping
|
||||
# all input events this handler cares about
|
||||
# should always be a subset of mapping.input_combination
|
||||
input_configs: List[InputConfig]
|
||||
_sub_handler: Optional[MappingHandler]
|
||||
|
||||
# https://bugs.python.org/issue44807
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
"""Initialize the handler
|
||||
|
||||
Parameters
|
||||
----------
|
||||
combination
|
||||
the combination from sub_handler.wrap_with()
|
||||
mapping
|
||||
"""
|
||||
self.mapping = mapping
|
||||
self.input_configs = list(combination)
|
||||
self._sub_handler = None
|
||||
self.global_uinputs = global_uinputs
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
"""Notify this handler about an incoming event.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
event
|
||||
The newest event that came from `source`, and that should be mapped to
|
||||
something else
|
||||
source
|
||||
Where `event` comes from
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the state of the handler e.g. release any buttons."""
|
||||
raise NotImplementedError
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
"""If this handler needs to be wrapped in another MappingHandler."""
|
||||
return len(self.wrap_with()) > 0
|
||||
|
||||
def needs_ranking(self) -> bool:
|
||||
"""If this handler needs ranking and wrapping with a HierarchyHandler."""
|
||||
return False
|
||||
|
||||
def rank_by(self) -> Optional[InputCombination]:
|
||||
"""The combination for which this handler needs ranking."""
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
"""A dict of InputCombination -> HandlerEnums.
|
||||
|
||||
for each InputCombination this handler should be wrapped
|
||||
with the given MappingHandler.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def set_sub_handler(self, handler: MappingHandler) -> None:
|
||||
"""Give this handler a sub_handler."""
|
||||
self._sub_handler = handler
|
||||
|
||||
def occlude_input_event(self, input_config: InputConfig) -> None:
|
||||
"""Remove the config from self.input_configs."""
|
||||
if not self.input_configs:
|
||||
logger.debug_mapping_handler(self)
|
||||
raise MappingParsingError(
|
||||
"Cannot remove a non existing config", mapping_handler=self
|
||||
)
|
||||
|
||||
# should be called for each event a wrapping-handler
|
||||
# has in its input_configs InputCombination
|
||||
self.input_configs.remove(input_config)
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
360
inputremapper/injection/mapping_handlers/mapping_parser.py
Normal file
360
inputremapper/injection/mapping_handlers/mapping_parser.py
Normal file
@ -0,0 +1,360 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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 handler tree."""
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Type, Optional, Set, Iterable, Sized, Tuple, Sequence
|
||||
|
||||
from evdev.ecodes import EV_KEY, EV_ABS, EV_REL
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.keyboard_layout import DISABLE_CODE, DISABLE_NAME
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.exceptions import MappingParsingError
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.macros.parse import Parser
|
||||
from inputremapper.injection.mapping_handlers.abs_to_abs_handler import AbsToAbsHandler
|
||||
from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
|
||||
from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler
|
||||
from inputremapper.injection.mapping_handlers.axis_switch_handler import (
|
||||
AxisSwitchHandler,
|
||||
)
|
||||
from inputremapper.injection.mapping_handlers.combination_handler import (
|
||||
CombinationHandler,
|
||||
)
|
||||
from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler
|
||||
from inputremapper.injection.mapping_handlers.key_handler import KeyHandler
|
||||
from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
ContextProtocol,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.injection.mapping_handlers.null_handler import NullHandler
|
||||
from inputremapper.injection.mapping_handlers.rel_to_abs_handler import RelToAbsHandler
|
||||
from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler
|
||||
from inputremapper.injection.mapping_handlers.rel_to_rel_handler import RelToRelHandler
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.utils import get_evdev_constant_name
|
||||
|
||||
EventPipelines = Dict[InputConfig, Set[MappingHandler]]
|
||||
|
||||
mapping_handler_classes: Dict[HandlerEnums, Optional[Type[MappingHandler]]] = {
|
||||
# all available mapping_handlers
|
||||
HandlerEnums.abs2btn: AbsToBtnHandler,
|
||||
HandlerEnums.rel2btn: RelToBtnHandler,
|
||||
HandlerEnums.macro: MacroHandler,
|
||||
HandlerEnums.key: KeyHandler,
|
||||
HandlerEnums.btn2rel: None, # can be a macro
|
||||
HandlerEnums.rel2rel: RelToRelHandler,
|
||||
HandlerEnums.abs2rel: AbsToRelHandler,
|
||||
HandlerEnums.btn2abs: None, # can be a macro
|
||||
HandlerEnums.rel2abs: RelToAbsHandler,
|
||||
HandlerEnums.abs2abs: AbsToAbsHandler,
|
||||
HandlerEnums.combination: CombinationHandler,
|
||||
HandlerEnums.hierarchy: HierarchyHandler,
|
||||
HandlerEnums.axisswitch: AxisSwitchHandler,
|
||||
HandlerEnums.disable: NullHandler,
|
||||
}
|
||||
|
||||
|
||||
class MappingParser:
|
||||
def __init__(
|
||||
self,
|
||||
global_uinputs: GlobalUInputs,
|
||||
) -> None:
|
||||
self.global_uinputs = global_uinputs
|
||||
|
||||
def parse_mappings(
|
||||
self,
|
||||
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 = self._get_output_handler(mapping)
|
||||
constructor = mapping_handler_classes[handler_enum]
|
||||
if not constructor:
|
||||
logger.warning(
|
||||
"a mapping handler '%s' for %s is not implemented",
|
||||
handler_enum,
|
||||
mapping.format_name(),
|
||||
)
|
||||
continue
|
||||
|
||||
output_handler = constructor(
|
||||
mapping.input_combination,
|
||||
mapping,
|
||||
context=context,
|
||||
global_uinputs=self.global_uinputs,
|
||||
)
|
||||
|
||||
# layer other handlers on top until the outer handler needs ranking or can
|
||||
# directly handle a input event
|
||||
handlers.extend(self._create_event_pipeline(output_handler, context))
|
||||
|
||||
# figure out which handlers need ranking and wrap them with hierarchy_handlers
|
||||
need_ranking = defaultdict(set)
|
||||
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].add(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 = self._create_hierarchy_handlers(need_ranking)
|
||||
for handler in ranked_handlers:
|
||||
handlers.extend(
|
||||
self._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 = defaultdict(set)
|
||||
for handler in handlers:
|
||||
assert handler.input_configs
|
||||
for input_config in handler.input_configs:
|
||||
logger.debug(
|
||||
"event-pipeline with entry point: %s %s",
|
||||
get_evdev_constant_name(*input_config.type_and_code),
|
||||
input_config.input_match_hash,
|
||||
)
|
||||
logger.debug_mapping_handler(handler)
|
||||
event_pipelines[input_config].add(handler)
|
||||
|
||||
return event_pipelines
|
||||
|
||||
def _create_event_pipeline(
|
||||
self,
|
||||
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,
|
||||
global_uinputs=self.global_uinputs,
|
||||
)
|
||||
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(self._create_event_pipeline(super_handler, context))
|
||||
|
||||
if handler.input_configs:
|
||||
# the handler was only partially wrapped,
|
||||
# we need to return it as a toplevel handler
|
||||
handlers.append(handler)
|
||||
|
||||
return handlers
|
||||
|
||||
def _get_output_handler(self, 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 Parser.is_this_a_macro(mapping.output_symbol):
|
||||
return HandlerEnums.macro
|
||||
|
||||
return HandlerEnums.key
|
||||
|
||||
if mapping.output_type == EV_KEY:
|
||||
return HandlerEnums.key
|
||||
|
||||
input_event = self._maps_axis(mapping.input_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(self, combination: InputCombination) -> Optional[InputConfig]:
|
||||
"""Whether this InputCombination contains an InputEvent that is treated as
|
||||
an axis and not a binary (key or button) event.
|
||||
"""
|
||||
for event in combination:
|
||||
if event.defines_analog_input:
|
||||
return event
|
||||
return None
|
||||
|
||||
def _create_hierarchy_handlers(
|
||||
self,
|
||||
handlers: Dict[InputCombination, Set[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.update(handlers[combinations_with_event[0]])
|
||||
continue
|
||||
|
||||
# there are multiple handler with the same event.
|
||||
# rank them and create the HierarchyHandler
|
||||
sorted_combinations = self._order_combinations(
|
||||
combinations_with_event,
|
||||
event,
|
||||
)
|
||||
sub_handlers: List[MappingHandler] = []
|
||||
for combination in sorted_combinations:
|
||||
sub_handlers.extend(handlers[combination])
|
||||
|
||||
sorted_handlers.add(
|
||||
HierarchyHandler(
|
||||
sub_handlers,
|
||||
event,
|
||||
self.global_uinputs,
|
||||
)
|
||||
)
|
||||
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(
|
||||
self,
|
||||
combinations: List[InputCombination],
|
||||
common_config: InputConfig,
|
||||
) -> List[InputCombination]:
|
||||
"""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
|
||||
the list which needs ordering
|
||||
common_config
|
||||
the InputConfig all InputCombination's in combinations have in common
|
||||
"""
|
||||
combinations.sort(key=len)
|
||||
|
||||
for start, end in self._ranges_with_constant_length(combinations.copy()):
|
||||
sub_list = combinations[start:end]
|
||||
sub_list.sort(key=lambda x: x.index(common_config))
|
||||
combinations[start:end] = sub_list
|
||||
|
||||
combinations.reverse()
|
||||
return combinations
|
||||
|
||||
def _ranges_with_constant_length(
|
||||
self,
|
||||
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)
|
||||
62
inputremapper/injection/mapping_handlers/null_handler.py
Normal file
62
inputremapper/injection/mapping_handlers/null_handler.py
Normal file
@ -0,0 +1,62 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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, List
|
||||
|
||||
import evdev
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
|
||||
|
||||
class NullHandler(MappingHandler):
|
||||
"""Handler which consumes the event and does nothing."""
|
||||
|
||||
def __str__(self):
|
||||
return f"NullHandler for {self.mapping.input_combination}<{id(self)}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return False in [
|
||||
input_.defines_analog_input for input_ in self.mapping.input_combination
|
||||
]
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
if not self.mapping.input_combination.defines_analog_input:
|
||||
return {self.mapping.input_combination: HandlerEnums.combination}
|
||||
|
||||
assert len(self.mapping.input_combination) > 1, "nees_wrapping ensures this!"
|
||||
return {self.mapping.input_combination: HandlerEnums.axisswitch}
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
pass
|
||||
248
inputremapper/injection/mapping_handlers/rel_to_abs_handler.py
Normal file
248
inputremapper/injection/mapping_handlers/rel_to_abs_handler.py
Normal file
@ -0,0 +1,248 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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 Tuple, Dict, Optional, List
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import (
|
||||
EV_ABS,
|
||||
EV_REL,
|
||||
REL_WHEEL,
|
||||
REL_HWHEEL,
|
||||
REL_HWHEEL_HI_RES,
|
||||
REL_WHEEL_HI_RES,
|
||||
)
|
||||
|
||||
from inputremapper import exceptions
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import (
|
||||
Mapping,
|
||||
WHEEL_SCALING,
|
||||
WHEEL_HI_RES_SCALING,
|
||||
REL_XY_SCALING,
|
||||
DEFAULT_REL_RATE,
|
||||
)
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class RelToAbsHandler(MappingHandler):
|
||||
"""Handler which transforms EV_REL to EV_ABS events.
|
||||
|
||||
High EV_REL input results in high EV_ABS output.
|
||||
If no new EV_REL events are seen, the EV_ABS output is set to 0 after
|
||||
release_timeout.
|
||||
"""
|
||||
|
||||
_map_axis: InputConfig # InputConfig for the relative movement we map
|
||||
_output_axis: Tuple[int, int] # the (type, code) of the output axis
|
||||
_transform: Transformation
|
||||
_target_absinfo: evdev.AbsInfo
|
||||
|
||||
# infinite loop which centers the output when input stops
|
||||
_recenter_loop: Optional[asyncio.Task]
|
||||
_moving: asyncio.Event # event to notify the _recenter_loop
|
||||
|
||||
_previous_event: Optional[InputEvent]
|
||||
_observed_rate: float # input events per second
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
|
||||
# find the input event we are supposed to map. If the input combination is
|
||||
# BTN_A + REL_X + BTN_B, then use the value of REL_X for the transformation
|
||||
assert (map_axis := combination.find_analog_input_config(type_=EV_REL))
|
||||
self._map_axis = map_axis
|
||||
|
||||
assert mapping.output_code is not None
|
||||
assert mapping.output_type == EV_ABS
|
||||
self._output_axis = (mapping.output_type, mapping.output_code)
|
||||
|
||||
target_uinput = global_uinputs.get_uinput(mapping.target_uinput)
|
||||
assert target_uinput is not None
|
||||
abs_capabilities = target_uinput.capabilities(absinfo=True)[EV_ABS]
|
||||
self._target_absinfo = dict(abs_capabilities)[mapping.output_code]
|
||||
|
||||
max_ = self._get_default_cutoff()
|
||||
self._transform = Transformation(
|
||||
min_=-max(1, int(max_)),
|
||||
max_=max(1, int(max_)),
|
||||
deadzone=mapping.deadzone,
|
||||
gain=mapping.gain,
|
||||
expo=mapping.expo,
|
||||
)
|
||||
self._moving = asyncio.Event()
|
||||
self._recenter_loop = None
|
||||
|
||||
self._previous_event = None
|
||||
self._observed_rate = DEFAULT_REL_RATE
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"RelToAbsHandler for {self._map_axis} "
|
||||
f"maps to: {self.mapping.get_output_name_constant()} "
|
||||
f"{self.mapping.get_output_type_code()} at "
|
||||
f"{self.mapping.target_uinput}"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
|
||||
def _observe_rate(self, event: InputEvent):
|
||||
"""Watch incoming events and remember how many events appear per second."""
|
||||
if self._previous_event is not None:
|
||||
delta_time = event.timestamp() - self._previous_event.timestamp()
|
||||
if delta_time == 0:
|
||||
logger.error("Observed two events with the same timestamp")
|
||||
return
|
||||
|
||||
rate = 1 / delta_time
|
||||
# mice seem to have a constant rate. wheel events are jaggy and the
|
||||
# rate depends on how fast it is turned.
|
||||
if rate > self._observed_rate:
|
||||
logger.debug("Updating rate to %s", rate)
|
||||
self._observed_rate = rate
|
||||
self._calculate_cutoff()
|
||||
|
||||
self._previous_event = event
|
||||
|
||||
def _get_default_cutoff(self):
|
||||
"""Get the cutoff value assuming the default input rate."""
|
||||
if self._map_axis.code in [REL_WHEEL, REL_HWHEEL]:
|
||||
return self.mapping.rel_to_abs_input_cutoff * WHEEL_SCALING
|
||||
|
||||
if self._map_axis.code in [REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES]:
|
||||
return self.mapping.rel_to_abs_input_cutoff * WHEEL_HI_RES_SCALING
|
||||
|
||||
return self.mapping.rel_to_abs_input_cutoff * REL_XY_SCALING
|
||||
|
||||
def _calculate_cutoff(self):
|
||||
"""Correct the default cutoff with the observed input rate, and set it."""
|
||||
# Mice that have very high input rates report low values at the same time.
|
||||
# If the rate is high, use a lower cutoff-value. If the rate is low, use a
|
||||
# higher cutoff-value.
|
||||
cutoff = self._get_default_cutoff()
|
||||
cutoff *= DEFAULT_REL_RATE / self._observed_rate
|
||||
|
||||
self._transform.set_range(-max(1, int(cutoff)), max(1, int(cutoff)))
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
self._observe_rate(event)
|
||||
|
||||
if event.input_match_hash != self._map_axis.input_match_hash:
|
||||
return False
|
||||
|
||||
if EventActions.recenter in event.actions:
|
||||
if self._recenter_loop:
|
||||
self._recenter_loop.cancel()
|
||||
self._recenter()
|
||||
return True
|
||||
|
||||
if not self._recenter_loop or self._recenter_loop.cancelled():
|
||||
self._recenter_loop = asyncio.create_task(self._create_recenter_loop())
|
||||
|
||||
self._moving.set() # notify the _recenter_loop
|
||||
try:
|
||||
self._write(self._scale_to_target(self._transform(event.value)))
|
||||
return True
|
||||
except (exceptions.UinputNotAvailable, exceptions.EventNotHandled):
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
if self._recenter_loop:
|
||||
self._recenter_loop.cancel()
|
||||
self._recenter()
|
||||
|
||||
def _recenter(self) -> None:
|
||||
"""Recenter the output."""
|
||||
self._write(self._scale_to_target(0))
|
||||
|
||||
async def _create_recenter_loop(self) -> None:
|
||||
"""Coroutine which waits for the input to start moving,
|
||||
then waits until the input stops moving, centers the output and repeat.
|
||||
|
||||
Runs forever.
|
||||
"""
|
||||
while True:
|
||||
await self._moving.wait() # input moving started
|
||||
while (
|
||||
await asyncio.wait(
|
||||
(asyncio.create_task(self._moving.wait()),),
|
||||
timeout=self.mapping.release_timeout,
|
||||
)
|
||||
)[0]:
|
||||
self._moving.clear() # still moving
|
||||
self._recenter() # input moving stopped
|
||||
|
||||
def _scale_to_target(self, x: float) -> int:
|
||||
"""Scales a x value between -1 and 1 to an integer between
|
||||
target_absinfo.min and target_absinfo.max
|
||||
|
||||
input values above 1 or below -1 are clamped to the extreme values
|
||||
"""
|
||||
factor = (self._target_absinfo.max - self._target_absinfo.min) / 2
|
||||
offset = self._target_absinfo.min + factor
|
||||
y = factor * x + offset
|
||||
if y > offset:
|
||||
return int(min(self._target_absinfo.max, y))
|
||||
else:
|
||||
return int(max(self._target_absinfo.min, y))
|
||||
|
||||
def _write(self, value: int) -> None:
|
||||
"""Inject."""
|
||||
try:
|
||||
self.global_uinputs.write(
|
||||
(*self._output_axis, value),
|
||||
self.mapping.target_uinput,
|
||||
)
|
||||
except OverflowError:
|
||||
# screwed up the calculation of the event value
|
||||
logger.error("OverflowError (%s, %s, %s)", *self._output_axis, value)
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return len(self.input_configs) > 1
|
||||
|
||||
def set_sub_handler(self, handler: MappingHandler) -> None:
|
||||
assert False # cannot have a sub-handler
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
if self.needs_wrapping():
|
||||
return {InputCombination(self.input_configs): HandlerEnums.axisswitch}
|
||||
return {}
|
||||
148
inputremapper/injection/mapping_handlers/rel_to_btn_handler.py
Normal file
148
inputremapper/injection/mapping_handlers/rel_to_btn_handler.py
Normal file
@ -0,0 +1,148 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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 time
|
||||
from typing import List
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import EV_REL
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
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_config: InputConfig
|
||||
_last_activation: float
|
||||
_sub_handler: MappingHandler
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
|
||||
self._active = False
|
||||
self._input_config = combination[0]
|
||||
self._last_activation = time.time()
|
||||
self._abort_release = False
|
||||
assert self._input_config.analog_threshold != 0
|
||||
assert len(combination) == 1
|
||||
|
||||
def __str__(self):
|
||||
return f'RelToBtnHandler for "{self._input_config}"'
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return [self._sub_handler]
|
||||
|
||||
async def _stage_release(
|
||||
self,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool,
|
||||
):
|
||||
while time.time() < self._last_activation + self.mapping.release_timeout:
|
||||
await asyncio.sleep(1 / self.mapping.rel_rate)
|
||||
|
||||
if self._abort_release:
|
||||
self._abort_release = False
|
||||
return
|
||||
|
||||
event = InputEvent(
|
||||
0,
|
||||
0,
|
||||
*self._input_config.type_and_code,
|
||||
value=0,
|
||||
actions=(EventActions.as_key,),
|
||||
origin_hash=self._input_config.origin_hash,
|
||||
)
|
||||
logger.debug("Sending %s to sub_handler", event)
|
||||
self._sub_handler.notify(event, source, suppress)
|
||||
self._active = False
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
assert event.type == EV_REL
|
||||
if event.input_match_hash != self._input_config.input_match_hash:
|
||||
return False
|
||||
|
||||
assert (threshold := self._input_config.analog_threshold)
|
||||
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
|
||||
if self.mapping.force_release_timeout:
|
||||
# consume the event
|
||||
return True
|
||||
event = event.modify(pressed=False, actions=(EventActions.as_key,))
|
||||
logger.debug("Sending %s to sub_handler", event)
|
||||
self._abort_release = True
|
||||
else:
|
||||
# don't consume the event.
|
||||
# We could return True to consume events
|
||||
return False
|
||||
else:
|
||||
# the axis is above the threshold
|
||||
if not self._active:
|
||||
asyncio.ensure_future(self._stage_release(source, suppress))
|
||||
if value >= threshold > 0:
|
||||
direction = 1
|
||||
else:
|
||||
direction = -1
|
||||
self._last_activation = time.time()
|
||||
event = event.modify(
|
||||
pressed=True,
|
||||
direction=direction,
|
||||
actions=(EventActions.as_key,),
|
||||
)
|
||||
|
||||
self._active = event.is_pressed()
|
||||
# logger.debug("Sending %s to sub_handler", event)
|
||||
return self._sub_handler.notify(event, source=source, suppress=suppress)
|
||||
|
||||
def reset(self) -> None:
|
||||
if self._active:
|
||||
self._abort_release = True
|
||||
|
||||
self._active = False
|
||||
self._sub_handler.reset()
|
||||
277
inputremapper/injection/mapping_handlers/rel_to_rel_handler.py
Normal file
277
inputremapper/injection/mapping_handlers/rel_to_rel_handler.py
Normal file
@ -0,0 +1,277 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# 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, List
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import (
|
||||
EV_REL,
|
||||
REL_WHEEL,
|
||||
REL_HWHEEL,
|
||||
REL_WHEEL_HI_RES,
|
||||
REL_HWHEEL_HI_RES,
|
||||
)
|
||||
|
||||
from inputremapper import exceptions
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import (
|
||||
Mapping,
|
||||
REL_XY_SCALING,
|
||||
WHEEL_SCALING,
|
||||
WHEEL_HI_RES_SCALING,
|
||||
)
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
def is_wheel(event) -> bool:
|
||||
return event.type == EV_REL and event.code in (REL_WHEEL, REL_HWHEEL)
|
||||
|
||||
|
||||
def is_high_res_wheel(event) -> bool:
|
||||
return event.type == EV_REL and event.code in (REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES)
|
||||
|
||||
|
||||
class Remainder:
|
||||
_scale: float
|
||||
_remainder: float
|
||||
|
||||
def __init__(self, scale: float):
|
||||
self._scale = scale
|
||||
self._remainder = 0
|
||||
|
||||
def input(self, value: float) -> int:
|
||||
# if the mouse moves very slow, it might not move at all because of the
|
||||
# int-conversion (which is required when writing). store the remainder
|
||||
# (the decimal places) and add it up, until the mouse moves a little.
|
||||
scaled = value * self._scale + self._remainder
|
||||
self._remainder = math.fmod(scaled, 1)
|
||||
|
||||
return int(scaled)
|
||||
|
||||
|
||||
class RelToRelHandler(MappingHandler):
|
||||
"""Handler which transforms EV_REL to EV_REL events."""
|
||||
|
||||
_input_config: InputConfig # the relative movement we map
|
||||
|
||||
_max_observed_input: float
|
||||
|
||||
_transform: Transformation
|
||||
|
||||
_remainder: Remainder
|
||||
_wheel_remainder: Remainder
|
||||
_wheel_hi_res_remainder: Remainder
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
|
||||
assert self.mapping.output_code is not None
|
||||
|
||||
# find the input event we are supposed to map. If the input combination is
|
||||
# BTN_A + REL_X + BTN_B, then use the value of REL_X for the transformation
|
||||
input_config = combination.find_analog_input_config(type_=EV_REL)
|
||||
assert input_config is not None
|
||||
self._input_config = input_config
|
||||
|
||||
self._max_observed_input = 1
|
||||
|
||||
self._remainder = Remainder(REL_XY_SCALING)
|
||||
self._wheel_remainder = Remainder(WHEEL_SCALING)
|
||||
self._wheel_hi_res_remainder = Remainder(WHEEL_HI_RES_SCALING)
|
||||
|
||||
self._transform = Transformation(
|
||||
max_=1,
|
||||
min_=-1,
|
||||
deadzone=self.mapping.deadzone,
|
||||
gain=self.mapping.gain,
|
||||
expo=self.mapping.expo,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"RelToRelHandler for {self._input_config} "
|
||||
f"maps to: {self.mapping.output_code} at {self.mapping.target_uinput}"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
|
||||
def _should_map(self, event: InputEvent):
|
||||
"""Check if this input event is relevant for this handler."""
|
||||
return event.input_match_hash == self._input_config.input_match_hash
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
if not self._should_map(event):
|
||||
return False
|
||||
"""
|
||||
There was the idea to define speed as "movemnt per second".
|
||||
There are deprecated mapping variables in this explanation.
|
||||
|
||||
rel2rel example:
|
||||
- input every 0.1s (`input_rate` of 10 events/s), value of 200
|
||||
- input speed is 2000, because in 1 second a value of 2000 acumulates
|
||||
- `input_rel_speed` is a const defined as 4000 px/s, how fast mice usually move
|
||||
- `transformed = Transformation(input.value, max=input_rel_speed / input_rate)`
|
||||
- get 0.5 because the expo is 0
|
||||
- `abs_to_rel_speed` is 5000
|
||||
- inject 2500 therefore per second, making it a bit faster
|
||||
- divide 2500 by the rate of 10 to inject a value of 250 each time input occurs
|
||||
|
||||
```
|
||||
output_value = Transformation(
|
||||
input.value,
|
||||
max=input_rel_speed / input_rate
|
||||
) * abs_to_rel_speed / input_rate
|
||||
```
|
||||
|
||||
The input_rel_speed could be used here instead of abs_to_rel_speed, because the
|
||||
gain already controls the speed. In that case it would be a 1:1 ratio of
|
||||
input-to-output value if the gain is 1.
|
||||
|
||||
for wheel and wheel_hi_res, different input speed constants must be set.
|
||||
|
||||
abs2rel needs a base value for the output, so `abs_to_rel_speed` is still
|
||||
required.
|
||||
`abs_to_rel_speed / rel_rate * transform(input.value, max=absinfo.max)`
|
||||
is the output value. Both abs_to_rel_speed and the transformation-gain control
|
||||
speed.
|
||||
|
||||
if abs_to_rel_speed controls speed in the abs2rel output, it should also do so
|
||||
in other handlers that have EV_REL output.
|
||||
|
||||
unfortunately input_rate needs to be determined during runtime, which screws
|
||||
the overall speed up when slowly moving the input device in the beginning,
|
||||
because slow input is thought to be the regular input.
|
||||
|
||||
---
|
||||
|
||||
transforming from rate based to rate based speed values won't work well.
|
||||
|
||||
better to use fractional speed values.
|
||||
REL_X of 40 = REL_WHEEL of 1 = REL_WHEE_HI_RES of 1/120
|
||||
|
||||
this is why abs_to_rel_speed does not affect the rel_to_rel handler.
|
||||
|
||||
The expo calculation will be wrong in the beginning, because it is based on
|
||||
the highest observed value. The overall gain will be fine though.
|
||||
"""
|
||||
|
||||
input_value = float(event.value)
|
||||
|
||||
# scale down now, the remainder calculation scales up by the same factor later
|
||||
# depending on what kind of event this becomes.
|
||||
if event.is_wheel_event:
|
||||
input_value /= WHEEL_SCALING
|
||||
elif event.is_wheel_hi_res_event:
|
||||
input_value /= WHEEL_HI_RES_SCALING
|
||||
else:
|
||||
# even though the input rate is unknown we can apply REL_XY_SCALING, which
|
||||
# is based on 60hz or something, because the un-scaling also uses values
|
||||
# based on 60hz. So the rate cancels out
|
||||
input_value /= REL_XY_SCALING
|
||||
|
||||
if abs(input_value) > self._max_observed_input:
|
||||
self._max_observed_input = abs(input_value)
|
||||
|
||||
# If _max_observed_input is wrong when the injection starts and the correct
|
||||
# value learned during runtime, results can be weird at the beginning.
|
||||
# If expo and deadzone are not set, then it is linear and doesn't matter.
|
||||
transformed = self._transform(input_value / self._max_observed_input)
|
||||
transformed *= self._max_observed_input
|
||||
|
||||
is_wheel_output = self.mapping.is_wheel_output()
|
||||
is_hi_res_wheel_output = self.mapping.is_high_res_wheel_output()
|
||||
|
||||
horizontal = self.mapping.output_code in (
|
||||
REL_HWHEEL_HI_RES,
|
||||
REL_HWHEEL,
|
||||
)
|
||||
|
||||
try:
|
||||
if is_wheel_output or is_hi_res_wheel_output:
|
||||
# inject both kinds of wheels, otherwise wheels don't work for some
|
||||
# people. See issue #354
|
||||
self._write(
|
||||
REL_HWHEEL if horizontal else REL_WHEEL,
|
||||
self._wheel_remainder.input(transformed),
|
||||
)
|
||||
self._write(
|
||||
REL_HWHEEL_HI_RES if horizontal else REL_WHEEL_HI_RES,
|
||||
self._wheel_hi_res_remainder.input(transformed),
|
||||
)
|
||||
else:
|
||||
self._write(
|
||||
self.mapping.output_code,
|
||||
self._remainder.input(transformed),
|
||||
)
|
||||
|
||||
return True
|
||||
except OverflowError:
|
||||
# screwed up the calculation of the event value
|
||||
logger.error("OverflowError while handling %s", event)
|
||||
return True
|
||||
except (exceptions.UinputNotAvailable, exceptions.EventNotHandled):
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
pass
|
||||
|
||||
def _write(self, code: int, value: int):
|
||||
if value == 0:
|
||||
# rel 0 does not make sense. We don't need to tell linux that the mouse
|
||||
# should not be moved this time.
|
||||
return
|
||||
|
||||
self.global_uinputs.write(
|
||||
(EV_REL, code, value),
|
||||
self.mapping.target_uinput,
|
||||
)
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return len(self.input_configs) > 1
|
||||
|
||||
def set_sub_handler(self, handler: MappingHandler) -> None:
|
||||
assert False # cannot have a sub-handler
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
if self.needs_wrapping():
|
||||
return {InputCombination(self.input_configs): HandlerEnums.axisswitch}
|
||||
return {}
|
||||
Reference in New Issue
Block a user