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:
2026-06-18 00:47:12 +02:00
parent 1d790a78fb
commit 0ab77d2a64
257 changed files with 51859 additions and 0 deletions

View File

View File

@ -0,0 +1,132 @@
# -*- 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/>.
"""Stores injection-process wide information."""
from __future__ import annotations
from collections import defaultdict
from typing import List, Dict, Set, Hashable
import evdev
from inputremapper.configs.preset import Preset
from inputremapper.injection.mapping_handlers.mapping_handler import (
EventListener,
NotifyCallback,
)
from inputremapper.injection.mapping_handlers.mapping_parser import (
MappingParser,
EventPipelines,
)
from inputremapper.input_event import InputEvent
from inputremapper.logging.logger import logger
from inputremapper.utils import DeviceHash
class Context:
"""Stores injection-process wide information.
In some ways this is a wrapper for the preset that derives some
information that is specifically important to the injection.
The information in the context does not change during the injection.
One Context exists for each injection process, which is shared
with all coroutines and used objects.
Benefits of the context:
- less redundant passing around of parameters
- easier to add new process wide information without having to adjust
all function calls in unittests
- makes the injection class shorter and more specific to a certain task,
which is actually spinning up the injection.
Note, that for the reader_service a ContextDummy is used.
Members
-------
preset : Preset
The preset holds all Mappings for the injection process
listeners : Set[EventListener]
A set of callbacks which receive all events
callbacks : Dict[Tuple[int, int], List[NotifyCallback]]
All entry points to the event pipeline sorted by InputEvent.type_and_code
"""
listeners: Set[EventListener]
_notify_callbacks: Dict[Hashable, List[NotifyCallback]]
_handlers: EventPipelines
_forward_devices: Dict[DeviceHash, evdev.UInput]
_source_devices: Dict[DeviceHash, evdev.InputDevice]
def __init__(
self,
preset: Preset,
source_devices: Dict[DeviceHash, evdev.InputDevice],
forward_devices: Dict[DeviceHash, evdev.UInput],
mapping_parser: MappingParser,
) -> None:
if len(forward_devices) == 0:
logger.warning("forward_devices not set")
if len(source_devices) == 0:
logger.warning("source_devices not set")
self.listeners = set()
self._source_devices = source_devices
self._forward_devices = forward_devices
self._notify_callbacks = defaultdict(list)
self._handlers = mapping_parser.parse_mappings(preset, self)
self._create_callbacks()
def reset(self) -> None:
"""Call the reset method for each handler in the context."""
for handlers in self._handlers.values():
for handler in handlers:
handler.reset()
def _create_callbacks(self) -> None:
"""Add the notify method from all _handlers to self.callbacks."""
for input_config, handler_list in self._handlers.items():
input_match_hash = input_config.input_match_hash
logger.debug("Adding NotifyCallback for %s", input_match_hash)
self._notify_callbacks[input_match_hash].extend(
handler.notify for handler in handler_list
)
def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]:
input_match_hash = input_event.input_match_hash
return self._notify_callbacks[input_match_hash]
def get_forward_uinput(self, origin_hash: DeviceHash) -> evdev.UInput:
"""Get the "forward" uinput events from the given origin should go into."""
return self._forward_devices[origin_hash]
def get_source(self, key: DeviceHash) -> evdev.InputDevice:
return self._source_devices[key]
def get_leds(self) -> Set[int]:
"""Get a set of LED_* ecodes that are currently on."""
leds = set()
for device in self._source_devices.values():
leds.update(device.leds())
return leds

View File

@ -0,0 +1,205 @@
# -*- 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/>.
"""Because multiple calls to async_read_loop won't work."""
import asyncio
import os
import traceback
from typing import AsyncIterator, Protocol, Set, List
import evdev
from inputremapper.injection.mapping_handlers.mapping_handler import (
EventListener,
NotifyCallback,
)
from inputremapper.input_event import InputEvent
from inputremapper.logging.logger import logger
from inputremapper.utils import get_device_hash, DeviceHash
class Context(Protocol):
listeners: Set[EventListener]
def reset(self): ...
def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]: ...
def get_forward_uinput(self, origin_hash: DeviceHash) -> evdev.UInput: ...
class EventReader:
"""Reads input events from a single device and distributes them.
There is one EventReader object for each source, which tells multiple
mapping_handlers that a new event is ready so that they can inject all sorts of
funny things.
Other devnodes may be present for the hardware device, in which case this
needs to be created multiple times.
"""
def __init__(
self,
context: Context,
source: evdev.InputDevice,
stop_event: asyncio.Event,
) -> None:
"""Initialize all mapping_handlers
Parameters
----------
source
where to read keycodes from
"""
self._device_hash = get_device_hash(source)
self._source = source
self.context = context
self.stop_event = stop_event
def stop(self):
"""Stop the reader."""
self.stop_event.set()
async def read_loop(self) -> AsyncIterator[evdev.InputEvent]:
stop_task = asyncio.Task(self.stop_event.wait())
loop = asyncio.get_running_loop()
events_ready = asyncio.Event()
loop.add_reader(self._source.fileno(), events_ready.set)
while True:
_, pending = await asyncio.wait(
{stop_task, asyncio.Task(events_ready.wait())},
return_when=asyncio.FIRST_COMPLETED,
)
fd_broken = os.stat(self._source.fileno()).st_nlink == 0
if fd_broken:
# happens when the device is unplugged while reading, causing 100% cpu
# usage because events_ready.set is called repeatedly forever,
# while read_loop will hang at self._source.read_one().
logger.error("fd broke, was the device unplugged?")
if stop_task.done() or fd_broken:
for task in pending:
task.cancel()
loop.remove_reader(self._source.fileno())
logger.debug("read loop stopped")
return
events_ready.clear()
while event := self._source.read_one():
yield event
def send_to_handlers(self, event: InputEvent) -> bool:
"""Send the event to the NotifyCallbacks.
Return if anyone took care of the event.
"""
if event.type == evdev.ecodes.EV_MSC:
return False
if event.type == evdev.ecodes.EV_SYN:
return False
handled = False
notify_callbacks = self.context.get_notify_callbacks(event)
if notify_callbacks:
for notify_callback in notify_callbacks:
handled = notify_callback(event, source=self._source) | handled
return handled
async def send_to_listeners(self, event: InputEvent) -> None:
"""Send the event to listeners."""
if event.type == evdev.ecodes.EV_MSC:
return
if event.type == evdev.ecodes.EV_SYN:
return
for listener in self.context.listeners.copy():
# use a copy, since the listeners might remove themselves from the set
await listener(event)
# Running macros have priority, give them a head-start for processing the
# event. If if_single injects a modifier, this modifier should be active
# before the next handler injects an "a" or something, so that it is
# possible to capitalize it via if_single.
# 1. Event from keyboard arrives (e.g. an "a")
# 2. the listener for if_single is called
# 3. if_single decides runs then (e.g. injects shift_L)
# 4. The original event is forwarded (or whatever it is supposed to do)
# 5. Capitalized "A" is injected.
# So make sure to call the listeners before notifying the handlers.
for _ in range(5):
await asyncio.sleep(0)
def forward(self, event: InputEvent) -> None:
"""Forward an event, which injects it unmodified."""
forward_to = self.context.get_forward_uinput(self._device_hash)
logger.write(event, forward_to)
forward_to.write(*event.event_tuple)
async def handle(self, event: InputEvent) -> None:
if event.type == evdev.ecodes.EV_KEY and event.value == 2:
# button-hold event. Environments (gnome, etc.) create them on
# their own for the injection-fake-device if the release event
# won't appear, no need to forward or map them.
return
await self.send_to_listeners(event)
handled = self.send_to_handlers(event)
if not handled:
# no handler took care of it, forward it
self.forward(event)
async def run(self):
"""Start doing things.
Can be stopped by stopping the asyncio loop or by setting the stop_event.
This loop reads events from a single device only.
"""
logger.debug(
"Starting to listen for events from %s, fd %s",
self._source.path,
self._source.fd,
)
async for event in self.read_loop():
try:
# Fire and forget, so that handlers and listeners can take their time,
# if they want to wait for something special to happen.
asyncio.ensure_future(
self.handle(
InputEvent.from_event(event, origin_hash=self._device_hash)
)
)
except Exception as e:
logger.error("Handling event %s failed with %s", event, type(e))
traceback.print_exception(e)
self.context.reset()
logger.info("read loop for %s stopped", self._source.path)

View File

@ -0,0 +1,192 @@
# -*- 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, Union, Tuple, Optional, List, Type
import evdev
import inputremapper.exceptions
import inputremapper.utils
from inputremapper.logging.logger import logger
MIN_ABS = -(2**15) # -32768
MAX_ABS = 2**15 # 32768
DEV_NAME = "input-remapper"
DEFAULT_UINPUTS = {
# for event codes see linux/input-event-codes.h
"keyboard": {
evdev.ecodes.EV_KEY: list(evdev.ecodes.KEY.keys() & evdev.ecodes.keys.keys())
},
"gamepad": {
evdev.ecodes.EV_KEY: [*range(0x130, 0x13F)], # BTN_SOUTH - BTN_THUMBR
evdev.ecodes.EV_ABS: [
*(
(i, evdev.AbsInfo(0, MIN_ABS, MAX_ABS, 0, 0, 0))
for i in range(0x00, 0x06)
),
*((i, evdev.AbsInfo(0, -1, 1, 0, 0, 0)) for i in range(0x10, 0x12)),
], # 6-axis and 1 hat switch
},
"mouse": {
evdev.ecodes.EV_KEY: [*range(0x110, 0x118)], # BTN_LEFT - BTN_TASK
evdev.ecodes.EV_REL: [*range(0x00, 0x0D)], # all REL axis
},
}
DEFAULT_UINPUTS["keyboard + mouse"] = {
evdev.ecodes.EV_KEY: [
*DEFAULT_UINPUTS["keyboard"][evdev.ecodes.EV_KEY],
*DEFAULT_UINPUTS["mouse"][evdev.ecodes.EV_KEY],
],
evdev.ecodes.EV_REL: [
*DEFAULT_UINPUTS["mouse"][evdev.ecodes.EV_REL],
],
}
class UInput(evdev.UInput):
_capabilities_cache: Optional[Dict] = None
def __init__(self, *args, **kwargs):
name = kwargs["name"]
logger.debug('creating UInput device: "%s"', name)
super().__init__(*args, **kwargs)
def can_emit(self, event: Tuple[int, int, int]):
"""Check if an event can be emitted by the UIinput.
Wrong events might be injected if the group mappings are wrong,
"""
# this will never change, so we cache it since evdev runs an expensive loop to
# gather the capabilities. (can_emit is called regularly)
if self._capabilities_cache is None:
self._capabilities_cache = self.capabilities(absinfo=False)
return event[1] in self._capabilities_cache.get(event[0], [])
class FrontendUInput:
"""Uinput which can not actually send events, for use in the frontend."""
def __init__(self, *_, events=None, name="py-evdev-uinput", **__):
# see https://python-evdev.readthedocs.io/en/latest/apidoc.html#module-evdev.uinput # noqa pylint: disable=line-too-long
self.events = events
self.name = name
logger.debug('creating fake UInput device: "%s"', self.name)
def capabilities(self):
return self.events
class GlobalUInputs:
"""Manages all UInputs that are shared between all injection processes."""
def __init__(
self,
uinput_factory: Union[Type[UInput], Type[FrontendUInput]],
):
self.devices: Dict[str, Union[UInput, FrontendUInput]] = {}
self._uinput_factory = uinput_factory
def __iter__(self):
return iter(uinput for _, uinput in self.devices.items())
@staticmethod
def can_default_uinput_emit(target: str, type_: int, code: int) -> bool:
"""Check if the uinput with the target name is capable of the event."""
capabilities = DEFAULT_UINPUTS.get(target, {}).get(type_)
return capabilities is not None and code in capabilities
@staticmethod
def find_fitting_default_uinputs(type_: int, code: int) -> List[str]:
"""Find the names of default uinputs that are able to emit this event."""
return [
uinput
for uinput in DEFAULT_UINPUTS
if code in DEFAULT_UINPUTS[uinput].get(type_, [])
]
def reset(self):
self.devices = {}
self.prepare_all()
def prepare_all(self):
"""Generate UInputs."""
for name, events in DEFAULT_UINPUTS.items():
if name in self.devices.keys():
continue
self.devices[name] = self._uinput_factory(
name=f"{DEV_NAME} {name}",
phys=DEV_NAME,
events=events,
)
def prepare_single(self, name: str):
"""Generate a single uinput.
This has to be done in the main process before injections that use it start.
"""
if name not in DEFAULT_UINPUTS:
raise KeyError("Could not find a matching uinput to generate.")
if name in self.devices:
logger.debug('Target "%s" already exists', name)
return
self.devices[name] = self._uinput_factory(
name=f"{DEV_NAME} {name}",
phys=DEV_NAME,
events=DEFAULT_UINPUTS[name],
)
def write(self, event: Tuple[int, int, int], target_uinput):
"""Write event to target uinput."""
uinput = self.get_uinput(target_uinput)
if not uinput:
raise inputremapper.exceptions.UinputNotAvailable(target_uinput)
if not uinput.can_emit(event):
raise inputremapper.exceptions.EventNotHandled(event)
# Was bool once due to a bug during development
assert not isinstance(event[2], bool) and isinstance(event[2], int)
logger.write(event, uinput)
uinput.write(*event)
uinput.syn()
def get_uinput(self, name: str) -> Optional[evdev.UInput]:
"""UInput with name
Or None if there is no uinput with this name.
Parameters
----------
name
uniqe name of the uinput device
"""
if name not in self.devices:
logger.error(
f'UInput "{name}" is unknown. '
+ f"Available: {list(self.devices.keys())}"
)
return None
return self.devices.get(name)

View File

@ -0,0 +1,511 @@
# -*- 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/>.
"""Keeps injecting keycodes in the background based on the preset."""
from __future__ import annotations
import asyncio
import enum
import multiprocessing
import sys
import time
from collections import defaultdict
from dataclasses import dataclass
from multiprocessing.connection import Connection
from typing import Dict, List, Optional, Tuple, Union
import evdev
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.preset import Preset
from inputremapper.groups import (
_Group,
classify,
DeviceType,
)
from inputremapper.gui.messages.message_broker import MessageType
from inputremapper.injection.context import Context
from inputremapper.injection.event_reader import EventReader
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock
from inputremapper.logging.logger import logger
from inputremapper.utils import get_device_hash, DeviceHash
CapabilitiesDict = Dict[int, List[int]]
DEV_NAME = "input-remapper"
# messages sent to the injector process
class InjectorCommand(str, enum.Enum):
CLOSE = "CLOSE"
# messages the injector process reports back to the service
class InjectorState(str, enum.Enum):
UNKNOWN = "UNKNOWN"
STARTING = "STARTING"
ERROR = "FAILED"
RUNNING = "RUNNING"
STOPPED = "STOPPED"
NO_GRAB = "NO_GRAB"
UPGRADE_EVDEV = "UPGRADE_EVDEV"
def is_in_capabilities(
combination: InputCombination, capabilities: CapabilitiesDict
) -> bool:
"""Are this combination or one of its sub keys in the capabilities?"""
for event in combination:
if event.code in capabilities.get(event.type, []):
return True
return False
def get_udev_name(name: str, suffix: str) -> str:
"""Make sure the generated name is not longer than 80 chars."""
max_len = 80 # based on error messages
remaining_len = max_len - len(DEV_NAME) - len(suffix) - 2
middle = name[:remaining_len]
name = f"{DEV_NAME} {middle} {suffix}"
return name
def get_forward_name(name: str) -> str:
"""Keep forwarded uinput names within evdev's 80 character limit."""
return name[:80]
def get_forward_phys(source: evdev.InputDevice) -> str:
"""Use a stable phys marker for forwarded devices.
The original phys path must not be reused because it makes the forwarded
device look like the hardware device to our autoload rule. However, using a
dedicated input-remapper phys marker still allows us to identify and ignore
the forwarded device elsewhere.
"""
if source.phys:
return f"{DEV_NAME}/{source.phys}"
return DEV_NAME
@dataclass(frozen=True)
class InjectorStateMessage:
message_type = MessageType.injector_state
state: Union[InjectorState]
def active(self) -> bool:
return self.state in [InjectorState.RUNNING, InjectorState.STARTING]
def inactive(self) -> bool:
return self.state in [InjectorState.STOPPED, InjectorState.NO_GRAB]
class Injector(multiprocessing.Process):
"""Initializes, starts and stops injections.
Is a process to make it non-blocking for the rest of the code and to
make running multiple injector easier. There is one process per
hardware-device that is being mapped.
"""
group: _Group
preset: Preset
context: Optional[Context]
_devices: List[evdev.InputDevice]
_state: InjectorState
_msg_pipe: Tuple[Connection, Connection]
_event_readers: List[EventReader]
_stop_event: asyncio.Event
regrab_timeout = 0.2
def __init__(
self,
group: _Group,
preset: Preset,
mapping_parser: MappingParser,
) -> None:
"""
Parameters
----------
group
the device group
"""
self.group = group
self.mapping_parser = mapping_parser
self._state = InjectorState.UNKNOWN
# used to interact with the parts of this class that are running within
# the new process
self._msg_pipe = multiprocessing.Pipe()
self.preset = preset
self.context = None # only needed inside the injection process
self._event_readers = []
super().__init__(name=group.key)
"""Functions to interact with the running process."""
def get_state(self) -> InjectorState:
"""Get the state of the injection.
Can be safely called from the main process.
"""
# before we try to we try to guess anything lets check if there is a message
state = self._state
while self._msg_pipe[1].poll():
state = self._msg_pipe[1].recv()
# figure out what is going on step by step
alive = self.is_alive()
# if `self.start()` has been called
started = state != InjectorState.UNKNOWN or alive
if started:
if state == InjectorState.UNKNOWN and alive:
# if it is alive, it is definitely at least starting up.
state = InjectorState.STARTING
if state in (InjectorState.STARTING, InjectorState.RUNNING) and not alive:
# we thought it is running (maybe it was when get_state was previously),
# but the process is not alive. It probably crashed
state = InjectorState.ERROR
logger.error("Injector was unexpectedly found stopped")
logger.debug(
'Injector state of "%s", "%s": %s',
self.group.key,
self.preset.name,
state,
)
self._state = state
return self._state
@ensure_numlock
def stop_injecting(self) -> None:
"""Stop injecting keycodes.
Can be safely called from the main procss.
"""
logger.info('Stopping injecting keycodes for group "%s"', self.group.key)
self._msg_pipe[1].send(InjectorCommand.CLOSE)
"""Process internal stuff."""
def _find_input_device(
self, input_config: InputConfig
) -> Optional[evdev.InputDevice]:
"""find the InputDevice specified by the InputConfig
ensures the devices supports the type and code specified by the InputConfig"""
devices_by_hash = {get_device_hash(device): device for device in self._devices}
# mypy thinks None is the wrong type for dict.get()
if device := devices_by_hash.get(input_config.origin_hash): # type: ignore
if input_config.code in device.capabilities(absinfo=False).get(
input_config.type, []
):
return device
return None
def _find_input_device_fallback(
self, input_config: InputConfig
) -> Optional[evdev.InputDevice]:
"""find the InputDevice specified by the InputConfig fallback logic"""
ranking = [
DeviceType.KEYBOARD,
DeviceType.GAMEPAD,
DeviceType.MOUSE,
DeviceType.TOUCHPAD,
DeviceType.GRAPHICS_TABLET,
DeviceType.CAMERA,
DeviceType.UNKNOWN,
]
candidates: List[evdev.InputDevice] = [
device
for device in self._devices
if input_config.code
in device.capabilities(absinfo=False).get(input_config.type, [])
]
if len(candidates) > 1:
# there is more than on input device which can be used for this
# event we choose only one determined by the ranking
return sorted(candidates, key=lambda d: ranking.index(classify(d)))[0]
if len(candidates) == 1:
return candidates.pop()
logger.error(f"Could not find input for {input_config}")
return None
def _grab_devices(self) -> Dict[DeviceHash, evdev.InputDevice]:
"""Grab all InputDevices that match a mappings' origin_hash."""
# use a dict because the InputDevice is not directly hashable
needed_devices = {}
input_configs = set()
# find all unique input_config's
for mapping in self.preset:
for input_config in mapping.input_combination:
input_configs.add(input_config)
# find all unique input_device's
for input_config in input_configs:
if not (device := self._find_input_device(input_config)):
# there is no point in trying the fallback because
# self._update_preset already did that.
continue
needed_devices[device.path] = device
grabbed_devices = {}
for device in needed_devices.values():
if device := self._grab_device(device):
grabbed_devices[get_device_hash(device)] = device
return grabbed_devices
def _update_preset(self):
"""Update all InputConfigs in the preset to include correct origin_hash
information."""
mappings_by_input = defaultdict(list)
for mapping in self.preset:
for input_config in mapping.input_combination:
mappings_by_input[input_config].append(mapping)
for input_config in mappings_by_input:
if self._find_input_device(input_config):
continue
if not (device := self._find_input_device_fallback(input_config)):
# fallback failed, this mapping will be ignored
continue
for mapping in mappings_by_input[input_config]:
combination: List[InputConfig] = list(mapping.input_combination)
device_hash = get_device_hash(device)
idx = combination.index(input_config)
combination[idx] = combination[idx].modify(origin_hash=device_hash)
mapping.input_combination = combination
def _grab_device(self, device: evdev.InputDevice) -> Optional[evdev.InputDevice]:
"""Try to grab the device, return None if not possible.
Without grab, original events from it would reach the display server
even though they are mapped.
"""
error = None
for attempt in range(10):
try:
device.grab()
logger.debug("Grab %s", device.path)
return device
except IOError as err:
# it might take a little time until the device is free if
# it was previously grabbed.
error = err
logger.debug("Failed attempts to grab %s: %d", device.path, attempt + 1)
time.sleep(self.regrab_timeout)
logger.error("Cannot grab %s, it is possibly in use", device.path)
logger.error(str(error))
return None
@staticmethod
def _copy_capabilities(input_device: evdev.InputDevice) -> CapabilitiesDict:
"""Copy capabilities for a new device."""
ecodes = evdev.ecodes
# copy the capabilities because the uinput is going
# to act like the device.
capabilities = input_device.capabilities(absinfo=True)
# just like what python-evdev does in from_device
if ecodes.EV_SYN in capabilities:
del capabilities[ecodes.EV_SYN]
if ecodes.EV_FF in capabilities:
del capabilities[ecodes.EV_FF]
if ecodes.ABS_VOLUME in capabilities.get(ecodes.EV_ABS, []):
# For some reason an ABS_VOLUME capability likes to appear
# for some users. It prevents mice from moving around and
# keyboards from writing symbols
capabilities[ecodes.EV_ABS].remove(ecodes.ABS_VOLUME)
return capabilities
async def _msg_listener(self) -> None:
"""Wait for messages from the main process to do special stuff."""
loop = asyncio.get_event_loop()
while True:
frame_available = asyncio.Event()
loop.add_reader(self._msg_pipe[0].fileno(), frame_available.set)
await frame_available.wait()
frame_available.clear()
msg = self._msg_pipe[0].recv()
if msg == InjectorCommand.CLOSE:
await self._close()
return
async def _close(self):
logger.debug("Received close signal")
self._stop_event.set()
# give the event pipeline some time to reset devices
# before shutting the loop down
await asyncio.sleep(0.1)
# stop the event loop and cause the process to reach its end
# cleanly. Using .terminate prevents coverage from working.
loop = asyncio.get_event_loop()
loop.stop()
self._msg_pipe[0].send(InjectorState.STOPPED)
def _create_forwarding_device(self, source: evdev.InputDevice) -> evdev.UInput:
# copy as much information as possible, because libinput uses the extra
# information to enable certain features like "Disable touchpad while
# typing"
try:
forward_to = evdev.UInput(
# Keep the original name as far as possible so system hwdb rules
# can still match the virtual device and restore properties such
# as MOUSE_DPI.
name=get_forward_name(source.name),
events=self._copy_capabilities(source),
# Reusing source.phys causes our autoload rule to treat the
# forwarded device as hardware. Prefix it so it stays
# distinguishable while still carrying some source identity.
phys=get_forward_phys(source),
vendor=source.info.vendor,
product=source.info.product,
version=source.info.version,
bustype=source.info.bustype,
input_props=source.input_props(),
)
except TypeError as e:
if "input_props" in str(e):
# UInput constructor doesn't support input_props and
# source.input_props doesn't exist with old python-evdev versions.
logger.error("Please upgrade your python-evdev version. Exiting")
self._msg_pipe[0].send(InjectorState.UPGRADE_EVDEV)
sys.exit(12)
raise e
return forward_to
def run(self) -> None:
"""The injection worker that keeps injecting until terminated.
Stuff is non-blocking by using asyncio in order to do multiple things
somewhat concurrently.
Use this function as starting point in a process. It creates
the loops needed to read and map events and keeps running them.
"""
logger.info('Starting injecting the preset for "%s"', self.group.key)
# create a new event loop, because somehow running an infinite loop
# that sleeps on iterations (joystick_to_mouse) in one process causes
# another injection process to screw up reading from the grabbed
# device.
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self._devices = self.group.get_devices()
# InputConfigs may not contain the origin_hash information, this will try to
# make a good guess if the origin_hash information is missing or invalid.
self._update_preset()
# grab devices as early as possible. If events appear that won't get
# released anymore before the grab they appear to be held down forever
sources = self._grab_devices()
forward_devices = {}
for device_hash, device in sources.items():
forward_devices[device_hash] = self._create_forwarding_device(device)
# create this within the process after the event loop creation,
# so that the macros use the correct loop
self.context = Context(
self.preset,
sources,
forward_devices,
self.mapping_parser,
)
self._stop_event = asyncio.Event()
if len(sources) == 0:
# maybe the preset was empty or something
logger.error("Did not grab any device")
self._msg_pipe[0].send(InjectorState.NO_GRAB)
return
numlock_state = is_numlock_on()
coroutines = []
for device_hash in sources:
# actually doing things
event_reader = EventReader(
self.context,
sources[device_hash],
self._stop_event,
)
coroutines.append(event_reader.run())
self._event_readers.append(event_reader)
coroutines.append(self._msg_listener())
# set the numlock state to what it was before injecting, because
# grabbing devices screws this up
set_numlock(numlock_state)
self._msg_pipe[0].send(InjectorState.RUNNING)
try:
loop.run_until_complete(asyncio.gather(*coroutines))
except RuntimeError as error:
# the loop might have been stopped via a `CLOSE` message,
# which causes the error message below. This is expected behavior
if str(error) != "Event loop stopped before Future completed.":
raise error
except OSError as error:
logger.error("Failed to run injector coroutines: %s", str(error))
if len(coroutines) > 0:
# expected when stop_injecting is called,
# during normal operation as well as tests this point is not
# reached otherwise.
logger.debug("Injector coroutines ended")
for source in sources.values():
# ungrab at the end to make the next injection process not fail
# its grabs
try:
source.ungrab()
except OSError as error:
# it might have disappeared
logger.debug("OSError for ungrab on %s: %s", source.path, str(error))

View File

@ -0,0 +1,320 @@
#!/usr/bin/env python3
# -*- 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
from dataclasses import dataclass
from enum import Enum
from typing import Optional, Any, Union, List, Literal, Type, TYPE_CHECKING
from evdev._ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.validation_errors import (
MacroError,
SymbolNotAvailableInTargetError,
)
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.variable import Variable
if TYPE_CHECKING:
from inputremapper.injection.macros.raw_value import RawValue
from inputremapper.configs.mapping import Mapping
class ArgumentFlags(Enum):
# No default value is set, and the user has to provide one when using the macro
required = "required"
# If used, acts like foo(*bar)
spread = "spread"
@dataclass
class ArgumentConfig:
"""Definition what kind of arguments a task may take."""
position: Union[int, Literal[ArgumentFlags.spread]]
name: str
types: List[Optional[Type]]
is_symbol: bool = False
default: Any = ArgumentFlags.required
# If True, then the value (which should be a string), is the name of a non-constant
# variable. Tasks that overwrite their value need this, like `set`. The specified
# types are those that the current value of that variable may have. For `set` this
# doesn't matter, but something like `add` requires them to be numbers.
is_variable_name: bool = False
def is_required(self) -> bool:
return self.default == ArgumentFlags.required
def is_spread(self):
"""Does this Argument store all remaining Variables of a Task as a list?"""
return self.position == ArgumentFlags.spread
class Argument(ArgumentConfig):
"""Validation of variables and access to their value for Tasks during runtime."""
_variable: Optional[Variable] = None
# If the position is set to ArgumentFlags.spread, then _variables will be filled
# with all remaining positional arguments that were passed to a task.
_variables: List[Variable]
_mapping: Optional[Mapping] = None
def __init__(
self,
argument_config: ArgumentConfig,
mapping: Mapping,
) -> None:
# If a default of None is specified, but None is not an allowed type, then
# input-remapper has a bug here. Add "None" to your ArgumentConfig.types
assert not (
argument_config.default is None and None not in argument_config.types
)
self.position = argument_config.position
self.name = argument_config.name
self.types = argument_config.types
self.is_symbol = argument_config.is_symbol
self.default = argument_config.default
self.is_variable_name = argument_config.is_variable_name
self._mapping = mapping
self._variables = []
def initialize_variables(self, raw_values: List[RawValue]) -> None:
"""If the macro is supposed to contain multiple variables, set them.
Should be done during parsing."""
assert len(self._variables) == 0
assert self._variable is None
assert self.is_spread()
for raw_value in raw_values:
variable = self._parse_raw_value(raw_value)
self._variables.append(variable)
def initialize_variable(self, raw_value: RawValue) -> None:
"""Set the Arguments Variable. Done during parsing."""
assert len(self._variables) == 0
assert self._variable is None
assert not self.is_spread()
variable = self._parse_raw_value(raw_value)
self._variable = variable
def initialize_default(self) -> None:
"""Set the Arguments to its default value. Done during parsing."""
assert len(self._variables) == 0
assert self._variable is None
assert not self.is_spread()
variable = Variable(value=self.default, const=True)
self._variable = variable
def get_value(self) -> Any:
"""To ask for the current value of the variable during runtime."""
assert not self.is_spread(), f"Use .{self.get_values.__name__}()"
# If a user passed None as value, it should be a Variable(None, const=True) here.
# If not, a test or input-remapper is broken.
assert self._variable is not None
value = self._variable.get_value()
if not self._variable.const:
# Dynamic value. Hasn't been validated yet
value = self._validate_dynamic_value(self._variable)
return value
def get_values(self) -> List[Any]:
"""To ask for the current values of the variables during runtime."""
assert self.is_spread(), f"Use .{self.get_value.__name__}()"
values = []
for variable in self._variables:
if not variable.const:
values.append(self._validate_dynamic_value(variable))
else:
values.append(variable.get_value())
return values
def get_variable_name(self) -> str:
"""If the variable is not const, return its name."""
assert self._variable is not None
return self._variable.get_name()
def contains_macro(self) -> bool:
"""Does the underlying Variable contain another child-macro?"""
assert self._variable is not None
return isinstance(self._variable.get_value(), Macro)
def set_value(self, value: Any) -> Any:
"""To set the value of the underlying Variable during runtime.
Fails for constants."""
assert self._variable is not None
if self._variable.const:
raise Exception("Can't set value of a constant")
self._variable.set_value(value)
def assert_is_symbol(self, symbol: str) -> None:
"""Checks if the key/symbol-name is valid. Like "KEY_A" or "escape".
Using `is_symbol` on the ArgumentConfig is prefered, which causes it to
automatically do this for you. But some macros may be a bit more flexible,
and there we want to assert this ourselves only in certain cases."""
symbol = str(symbol)
code = keyboard_layout.get(symbol)
if code is None:
raise MacroError(msg=f'Unknown key "{symbol}"')
if self._mapping is not None:
target = self._mapping.target_uinput
if target is not None and not GlobalUInputs.can_default_uinput_emit(
target, EV_KEY, code
):
raise SymbolNotAvailableInTargetError(symbol, target)
def _parse_raw_value(self, raw_value: RawValue) -> Variable:
"""Validate and parse."""
value = raw_value.value
# The order of steps below matters.
if isinstance(value, Macro):
return Variable(value=value, const=True)
if self.is_variable_name:
# Treat this as a non-constant variable,
# even without a `$` in front of its name
if value.startswith('"'):
# Remove quotes from the string
value = value[1:-1]
return Variable(value=value, const=False)
if value.startswith("$"):
# Will be resolved during the macros runtime
return Variable(value=value[1:], const=False)
if self.is_symbol:
if value.startswith('"'):
value = value[1:-1]
self.assert_is_symbol(value)
return Variable(value=value, const=True)
if (value == "" or value == "None") and None in self.types:
# I think "" is the deprecated alternative to "None"
return Variable(value=None, const=True)
if value.startswith('"') and str in self.types:
# Something with explicit quotes should never be parsed as a number.
# Treat it as a string no matter the content.
value = value[1:-1]
return Variable(value=value, const=True)
if float in self.types and "." in value:
try:
return Variable(value=float(value), const=True)
except (ValueError, TypeError):
pass
if int in self.types:
try:
return Variable(value=int(value), const=True)
except (ValueError, TypeError):
pass
if not value.startswith('"') and ("(" in value or ")" in value):
# Looks like something that should have been a macro. It is not explicitly
# wrapped in quotes. Most likely an error. If it was a valid macro, the
# parser would have parsed it as such.
raise MacroError(
msg=f"A broken macro was passed as parameter to {self.name}"
)
if str in self.types:
# Treat as a string. Something like KEY_A in key(KEY_A)
return Variable(value=value, const=True)
raise self._type_error_factory(value)
def _validate_dynamic_value(self, variable: Variable) -> Any:
"""To make sure the value of a non-const variable, asked for at runtime, is
fitting for the given ArgumentConfig."""
# Most of the stuff has already been taken care of when, for example,
# the "1" of set(foo, 1), or the '"bar"' or set(foo, "bar") was parsed the
# first time. In the first case we get a number 1, and in the second a string
# `bar` without quotes
assert not variable.const
value = variable.get_value()
if self.is_symbol:
# value might be int `1`, which is a valid symbol for `key(1)`
value = str(value)
self.assert_is_symbol(value)
return value
if None in self.types and value is None:
return value
if type(value) in self.types:
return value
if type(value) not in self.types and str in self.types:
# `set` cannot make predictions where the variable will be used. Make sure
# the type is compatible, and turn numbers back into strings if need be.
return str(value)
# If the value is "1", we don't attempt to parse it as a number. This being a
# string means that something like `set(foo, "1")` was used, which enforces a
# string datatype. Otherwise, `set` would have already turned it into an int.
raise self._type_error_factory(value)
def _is_numeric_string(self, value: str) -> bool:
"""Check if the value can be turned into a number."""
try:
float(value)
return True
except ValueError:
return False
def _type_error_factory(self, value: Any) -> MacroError:
formatted_types: List[str] = []
for type_ in self.types:
if type_ is None:
formatted_types.append("None")
else:
formatted_types.append(type_.__name__)
return MacroError(
msg=(
f'Expected "{self.name}" to be one of {formatted_types}, but got '
f'{type(value).__name__} "{value}"'
)
)

View File

@ -0,0 +1,126 @@
# -*- 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/>.
"""Executes more complex patterns of keystrokes."""
from __future__ import annotations
import asyncio
from typing import List, Callable, Optional, TYPE_CHECKING
from inputremapper.ipc.shared_dict import SharedDict
from inputremapper.logging.logger import logger
if TYPE_CHECKING:
from inputremapper.injection.macros.task import Task
from inputremapper.injection.context import Context
from inputremapper.configs.mapping import Mapping
InjectEventCallback = Callable[[int, int, int], None]
macro_variables = SharedDict()
class Macro:
"""Chains tasks (like `modify` or `repeat`).
Tasks may have child_macros. Running a Macro runs Tasks, which in turn may run
their child_macros based on certain conditions (depending on the Task).
"""
def __init__(
self,
code: Optional[str],
context: Optional[Context] = None,
mapping: Optional[Mapping] = None,
):
"""Create a macro instance that can be populated with tasks.
Parameters
----------
code
The original parsed code, for logging purposes.
context : Context
mapping : UIMapping
"""
self.code = code
self.context = context
self.mapping = mapping
# List of coroutines that will be called sequentially.
# This is the compiled code
self.tasks: List[Task] = []
self.running = False
self.keystroke_sleep_ms = None
async def run(self, callback: InjectEventCallback):
"""Run the macro.
Parameters
----------
callback
Will receive int type, code and value for an event to write
"""
if not callable(callback):
raise ValueError("handler is not callable")
if self.running:
logger.error('Tried to run already running macro "%s"', self.code)
return
self.keystroke_sleep_ms = self.mapping.macro_key_sleep_ms
self.running = True
try:
for task in self.tasks:
coroutine = task.run(callback)
if asyncio.iscoroutine(coroutine):
await coroutine
except Exception:
raise
finally:
# done
self.running = False
def press_trigger(self):
"""The user pressed the trigger key down."""
for task in self.tasks:
task.press_trigger()
def release_trigger(self):
"""The user released the trigger key."""
for task in self.tasks:
task.release_trigger()
async def _keycode_pause(self, _=None):
"""To add a pause between keystrokes.
This was needed at some point because it appeared that injecting keys too
fast will prevent them from working. It probably depends on the environment.
"""
await asyncio.sleep(self.keystroke_sleep_ms / 1000)
def __repr__(self):
return f'<Macro "{self.code}" at {hex(id(self))}>'
def add_task(self, task):
self.tasks.append(task)

View File

@ -0,0 +1,476 @@
# -*- 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/>.
"""Parse macro code"""
from __future__ import annotations
import re
from typing import Optional, Any, Type, TYPE_CHECKING, Dict, List
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.raw_value import RawValue
from inputremapper.injection.macros.task import Task
from inputremapper.injection.macros.tasks.add import AddTask
from inputremapper.injection.macros.tasks.event import EventTask
from inputremapper.injection.macros.tasks.hold import HoldTask
from inputremapper.injection.macros.tasks.hold_keys import HoldKeysTask
from inputremapper.injection.macros.tasks.if_eq import IfEqTask
from inputremapper.injection.macros.tasks.if_led import IfNumlockTask, IfCapslockTask
from inputremapper.injection.macros.tasks.if_single import IfSingleTask
from inputremapper.injection.macros.tasks.if_tap import IfTapTask
from inputremapper.injection.macros.tasks.ifeq import DeprecatedIfEqTask
from inputremapper.injection.macros.tasks.key import KeyTask
from inputremapper.injection.macros.tasks.key_down import KeyDownTask
from inputremapper.injection.macros.tasks.key_up import KeyUpTask
from inputremapper.injection.macros.tasks.mod_tap import ModTapTask
from inputremapper.injection.macros.tasks.modify import ModifyTask
from inputremapper.injection.macros.tasks.mouse import MouseTask
from inputremapper.injection.macros.tasks.parallel import ParallelTask
from inputremapper.injection.macros.tasks.mouse_xy import MouseXYTask
from inputremapper.injection.macros.tasks.repeat import RepeatTask
from inputremapper.injection.macros.tasks.set import SetTask
from inputremapper.injection.macros.tasks.toggle import ToggleTask
from inputremapper.injection.macros.tasks.wait import WaitTask
from inputremapper.injection.macros.tasks.wheel import WheelTask
from inputremapper.logging.logger import logger
if TYPE_CHECKING:
from inputremapper.injection.context import Context
from inputremapper.configs.mapping import Mapping
class Parser:
TASK_CLASSES: dict[str, type[Task]] = {
"modify": ModifyTask,
"repeat": RepeatTask,
"toggle": ToggleTask,
"key": KeyTask,
"key_down": KeyDownTask,
"key_up": KeyUpTask,
"event": EventTask,
"wait": WaitTask,
"hold": HoldTask,
"hold_keys": HoldKeysTask,
"mouse": MouseTask,
"mouse_xy": MouseXYTask,
"wheel": WheelTask,
"if_eq": IfEqTask,
"if_numlock": IfNumlockTask,
"if_capslock": IfCapslockTask,
"set": SetTask,
"if_tap": IfTapTask,
"if_single": IfSingleTask,
"add": AddTask,
"mod_tap": ModTapTask,
"parallel": ParallelTask,
# Those are only kept for backwards compatibility with old macros. The space for
# writing macro was very constrained in the past, so shorthands were introduced:
"m": ModifyTask,
"r": RepeatTask,
"k": KeyTask,
"e": EventTask,
"w": WaitTask,
"h": HoldTask,
# It was not possible to adjust ifeq to support variables without breaking old
# macros, so this function is deprecated and if_eq introduced. Kept for backwards
# compatibility:
"ifeq": DeprecatedIfEqTask,
}
@staticmethod
def is_this_a_macro(output: Any):
"""Figure out if this is a macro."""
if not isinstance(output, str):
return False
if "+" in output.strip():
# for example "a + b"
return True
return "(" in output and ")" in output and len(output) >= 4
@staticmethod
def _extract_args(inner: str):
"""Extract parameters from the inner contents of a call.
This does not parse them.
Parameters
----------
inner
for example '1, r, r(2, k(a))' should result in ['1', 'r', 'r(2, k(a))']
"""
inner = inner.strip()
brackets = 0
params = []
start = 0
string = False
for position, char in enumerate(inner):
# ignore anything between string quotes
if char == '"':
string = not string
if string:
continue
# ignore commas inside child macros
if char == "(":
brackets += 1
if char == ")":
brackets -= 1
if char == "," and brackets == 0:
# , potentially starts another parameter, but only if
# the current brackets are all closed.
params.append(inner[start:position].strip())
# skip the comma
start = position + 1
# one last parameter
params.append(inner[start:].strip())
return params
@staticmethod
def _count_brackets(macro):
"""Find where the first opening bracket closes."""
openings = macro.count("(")
closings = macro.count(")")
if openings != closings:
raise MacroError(
macro, f"Found {openings} opening and {closings} closing brackets"
)
brackets = 0
position = 0
for char in macro:
position += 1
if char == "(":
brackets += 1
continue
if char == ")":
brackets -= 1
if brackets == 0:
# the closing bracket of the call
break
return position
@staticmethod
def _split_keyword_arg(param):
"""Split "foo=bar" into "foo" and "bar".
If not a keyward param, return None and the param.
"""
if re.match(r"[a-zA-Z_][a-zA-Z_\d]*=.+", param):
split = param.split("=", 1)
return split[0], split[1]
return None, param
@staticmethod
def _validate_keyword_argument_names(
keyword_args: Dict[str, Any],
task_class: Type[Task],
) -> None:
for keyword_arg in keyword_args:
for argument in task_class.argument_configs:
if argument.name == keyword_arg:
break
else:
raise MacroError(msg=f"Unknown keyword argument {keyword_arg}")
@staticmethod
def _parse_recurse(
code: str,
context: Optional[Context],
mapping: Mapping,
verbose: bool,
macro_instance: Optional[Macro] = None,
depth: int = 0,
) -> RawValue:
"""Handle a subset of the macro, e.g. one parameter or function call.
Not using eval for security reasons.
Parameters
----------
code
Just like parse. A single parameter or the complete macro as string.
Comments and redundant whitespace characters are expected to be removed already.
Example:
- "parallel(key(a),key(b).key($foo))"
- "key(a)"
- "a"
- "key(b).key($foo)"
- "b"
- "key($foo)"
- "$foo"
context : Context
macro_instance
A macro instance to add tasks to. This is the output of the parser, and is
organized like a tree.
depth
For logging porposes
"""
assert isinstance(code, str)
assert isinstance(depth, int)
def debug(*args, **kwargs):
if verbose:
logger.debug(*args, **kwargs)
space = " " * depth
code = code.strip()
# is it another macro?
task_call_match = re.match(r"^(\w+)\(", code)
task_name = task_call_match[1] if task_call_match else None
if task_name is None:
# It is probably either a key name like KEY_A or a variable name as in `set(var,1)`,
# both won't contain special characters that can break macro syntax so they don't
# have to be wrapped in quotes. The argument configuration of the tasks will
# detemrine how to parse it.
debug("%svalue %s", space, code)
return RawValue(value=code)
if macro_instance is None:
# start a new chain
macro_instance = Macro(code, context, mapping)
else:
# chain this call to the existing instance
assert isinstance(macro_instance, Macro)
task_class = Parser.TASK_CLASSES.get(task_name)
if task_class is None:
raise MacroError(code, f"Unknown function {task_name}")
# get all the stuff inbetween
closing_bracket_position = Parser._count_brackets(code) - 1
inner = code[code.index("(") + 1 : closing_bracket_position]
debug("%scalls %s with %s", space, task_name, inner)
# split "3, foo=a(2, k(a).w(10))" into arguments
raw_string_args = Parser._extract_args(inner)
# parse and sort the params
positional_args: List[RawValue] = []
keyword_args: Dict[str, RawValue] = {}
for param in raw_string_args:
key, value = Parser._split_keyword_arg(param)
parsed = Parser._parse_recurse(
value.strip(),
context,
mapping,
verbose,
None,
depth + 1,
)
if key is None:
if len(keyword_args) > 0:
msg = f'Positional argument "{key}" follows keyword argument'
raise MacroError(code, msg)
positional_args.append(parsed)
else:
if key in keyword_args:
raise MacroError(code, f'The "{key}" argument was specified twice')
keyword_args[key] = parsed
debug(
"%sadd call to %s with %s, %s",
space,
task_name,
positional_args,
keyword_args,
)
Parser._validate_keyword_argument_names(
keyword_args,
task_class,
)
Parser._validate_num_args(
code,
task_name,
task_class,
raw_string_args,
)
try:
task = task_class(
positional_args,
keyword_args,
context,
mapping,
)
macro_instance.add_task(task)
except TypeError as exception:
raise MacroError(msg=str(exception)) from exception
# is after this another call? Chain it to the macro_instance
more_code_exists = len(code) > closing_bracket_position + 1
if more_code_exists:
next_char = code[closing_bracket_position + 1]
statement_closed = next_char == "."
if statement_closed:
# skip over the ")."
chain = code[closing_bracket_position + 2 :]
debug("%sfollowed by %s", space, chain)
Parser._parse_recurse(
chain,
context,
mapping,
verbose,
macro_instance,
depth,
)
elif re.match(r"[a-zA-Z_]", next_char):
# something like foo()bar
raise MacroError(
code,
f'Expected a "." to follow after '
f"{code[:closing_bracket_position + 1]}",
)
return RawValue(value=macro_instance)
@staticmethod
def _validate_num_args(
code: str,
task_name: str,
task_class: Type[Task],
raw_string_args: List[str],
) -> None:
min_args, max_args = task_class.get_num_parameters()
num_provided_args = len(raw_string_args)
if num_provided_args < min_args or num_provided_args > max_args:
if min_args != max_args:
msg = (
f"{task_name} takes between {min_args} and {max_args}, "
f"not {num_provided_args} parameters"
)
else:
msg = (
f"{task_name} takes {min_args}, not {num_provided_args} parameters"
)
raise MacroError(code, msg)
@staticmethod
def handle_plus_syntax(macro):
"""Transform a + b + c to hold_keys(a,b,c)."""
if "+" not in macro:
return macro
if "(" in macro or ")" in macro:
raise MacroError(macro, f'Mixing "+" and macros is unsupported: "{ macro}"')
chunks = [chunk.strip() for chunk in macro.split("+")]
if "" in chunks:
raise MacroError(f'Invalid syntax for "{macro}"')
output = f"hold_keys({','.join(chunks)})"
logger.debug('Transformed "%s" to "%s"', macro, output)
return output
@staticmethod
def remove_whitespaces(macro, delimiter='"'):
"""Remove whitespaces, tabs, newlines and such outside of string quotes."""
result = ""
for i, chunk in enumerate(macro.split(delimiter)):
# every second chunk is inside string quotes
if i % 2 == 0:
result += re.sub(r"\s", "", chunk)
else:
result += chunk
result += delimiter
# one extra delimiter was added
return result[: -len(delimiter)]
@staticmethod
def remove_comments(macro):
"""Remove comments from the macro and return the resulting code."""
# keep hashtags inside quotes intact
result = ""
for i, line in enumerate(macro.split("\n")):
for j, chunk in enumerate(line.split('"')):
if j > 0:
# add back the string quote
chunk = f'"{chunk}'
# every second chunk is inside string quotes
if j % 2 == 0 and "#" in chunk:
# everything from now on is a comment and can be ignored
result += chunk.split("#")[0]
break
else:
result += chunk
if i < macro.count("\n"):
result += "\n"
return result
@staticmethod
def clean(code):
"""Remove everything irrelevant for the macro."""
return Parser.remove_whitespaces(
Parser.remove_comments(code),
'"',
)
@staticmethod
def parse(macro: str, context=None, mapping=None, verbose: bool = True) -> Macro:
"""Parse and generate a Macro that can be run as often as you want.
Parameters
----------
macro
"repeat(3, key(a).wait(10))"
"repeat(2, key(a).key(KEY_A)).key(b)"
"wait(1000).modify(Shift_L, repeat(2, k(a))).wait(10, 20).key(b)"
context : Context, or None for use in Frontend
mapping
the mapping for the macro, or None for use in Frontend
verbose
log the parsing True by default
"""
# TODO pass mapping in frontend and do the target check for keys?
logger.debug("parsing macro %s", macro.replace("\n", ""))
macro = Parser.clean(macro)
macro = Parser.handle_plus_syntax(macro)
macro_obj = Parser._parse_recurse(
macro,
context,
mapping,
verbose,
).value
if not isinstance(macro_obj, Macro):
raise MacroError(macro, "The provided code was not a macro")
return macro_obj

View File

@ -0,0 +1,35 @@
#!/usr/bin/env python3
# -*- 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 dataclasses import dataclass
from typing import Union
from inputremapper.injection.macros.macro import Macro
@dataclass
class RawValue:
"""Store a value exactly as it is in the macro-code. Values still have to be
validated according to the ArgumentConfig of the Tasks.
Child-macros are passed as Macro objects though.
"""
value: Union[str, Macro]

View File

@ -0,0 +1,248 @@
#!/usr/bin/env python3
# -*- 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
import asyncio
from itertools import chain
from typing import List, Dict, TYPE_CHECKING, Optional, Tuple, Union
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.argument import (
Argument,
ArgumentConfig,
ArgumentFlags,
)
from inputremapper.injection.macros.macro import Macro, InjectEventCallback
from inputremapper.logging.logger import logger
if TYPE_CHECKING:
from inputremapper.injection.mapping_handlers.mapping_handler import EventListener
from inputremapper.injection.macros.raw_value import RawValue
from inputremapper.injection.context import Context
from inputremapper.configs.mapping import Mapping
class Task:
"""Base Class for functions like `if_eq` or `key`
A macro like `key(a).key(b)` will contain two instances of this class.
"""
argument_configs: List[ArgumentConfig]
arguments: Dict[str, Argument]
mapping: Mapping
# The context is None during frontend-parsing/validation I believe
context: Optional[Context]
child_macros: List[Macro]
def __init__(
self,
positional_args: List[RawValue],
keyword_args: Dict[str, RawValue],
context: Optional[Context],
mapping: Mapping,
) -> None:
self.context = context
self.mapping = mapping
self.child_macros = []
self._validate_argument_configs()
self.arguments = {
argument_config.name: Argument(argument_config, mapping)
for argument_config in self.argument_configs
}
self._setup_asyncio_events()
for argument in self.arguments.values():
self._initialize_argument(argument, keyword_args, positional_args)
self._initialize_spread_arg(positional_args)
for raw_value in chain(keyword_args.values(), positional_args):
if isinstance(raw_value.value, Macro):
self.child_macros.append(raw_value.value)
async def run(self, callback: InjectEventCallback) -> None:
"""Macro logic goes here.
Call the callback with the type, code and value that should be injected.
"""
raise NotImplementedError()
def add_event_listener(self, listener: EventListener) -> None:
"""Listeners get each event from the source device.
After all listeners are done, the event will go into the mapping handlers.
Make sure to remove your event_listener once you are done.
"""
# The context will be there when the macro is parsed by the service
assert self.context is not None
self.context.listeners.add(listener)
def remove_event_listener(self, listener: EventListener) -> None:
assert self.context is not None
self.context.listeners.remove(listener)
@classmethod
def get_macro_argument_names(cls):
return [argument_config.name for argument_config in cls.argument_configs]
@classmethod
def get_num_parameters(cls) -> Tuple[int, Union[int, float]]:
"""Get the number of required parameters and the maximum number of parameters."""
min_num_args = 0
argument_configs = cls.argument_configs
max_num_args: Union[int, float] = len(argument_configs)
for argument_config in argument_configs:
if argument_config.position == ArgumentFlags.spread:
# 0 or more
max_num_args = float("inf")
continue
if argument_config.is_required():
min_num_args += 1
return min_num_args, max_num_args
def get_argument(self, argument_name) -> Argument:
return self.arguments[argument_name]
def press_trigger(self) -> None:
"""The user pressed the trigger key down."""
for macro in self.child_macros:
macro.press_trigger()
if self.is_holding():
logger.error("Already holding")
return
self._trigger_release_event.clear()
self._trigger_press_event.set()
def release_trigger(self) -> None:
"""The user released the trigger key."""
if not self.is_holding():
return
self._trigger_release_event.set()
self._trigger_press_event.clear()
for macro in self.child_macros:
macro.release_trigger()
def is_holding(self) -> bool:
"""Check if the macro is waiting for a key to be released."""
return not self._trigger_release_event.is_set()
async def keycode_pause(self, _=None) -> None:
"""To add a pause between keystrokes.
This was needed at some point because it appeared that injecting keys too
fast will prevent them from working. It probably depends on the environment.
"""
await asyncio.sleep(self.mapping.macro_key_sleep_ms / 1000)
def _initialize_spread_arg(
self,
positional_args: List[RawValue],
) -> None:
"""Put all positional arguments that aren't used into the spread argument."""
spread_argument: Optional[Argument] = None
for argument in self.arguments.values():
if argument.position == ArgumentFlags.spread:
spread_argument = argument
break
if spread_argument is None:
return
remaining_positional_args = [*positional_args]
for argument in self.arguments.values():
if argument.position != ArgumentFlags.spread and argument.position < len(
remaining_positional_args
):
del remaining_positional_args[argument.position]
spread_argument.initialize_variables(remaining_positional_args)
def _find_argument_by_position(self, position: int) -> Optional[Argument]:
for argument in self.arguments.values():
if argument.position == position:
return argument
return None
def _setup_asyncio_events(self) -> None:
# Can be used to wait for the press and release of the input event/key, that is
# configured as the trigger of the macro, via asyncio.
self._trigger_release_event = asyncio.Event()
self._trigger_press_event = asyncio.Event()
# released by default
self._trigger_release_event.set()
self._trigger_press_event.clear()
def _initialize_argument(
self,
argument: Argument,
keyword_args: Dict[str, RawValue],
positional_args: List[RawValue],
) -> None:
if argument.position == ArgumentFlags.spread:
# Will get all the remaining positional arguments afterward.
return
for name, value in keyword_args.items():
if argument.name == name:
argument.initialize_variable(value)
return
if argument.position < len(positional_args):
argument.initialize_variable(positional_args[argument.position])
return
if not argument.is_required():
argument.initialize_default()
return
# This shouldn't be possible, the parser should have ensured things are valid
# already.
raise MacroError(f"Could not initialize argument {argument.name}")
def _validate_argument_configs(self):
# Might help during development
positions = set()
names = set()
for argument_config in self.argument_configs:
position = argument_config.position
if position in positions:
raise MacroError(f"Duplicate position {positions} in ArgumentConfig")
positions.add(position)
name = argument_config.name
if name in names:
raise MacroError(f"Duplicate name {name} in ArgumentConfig")
names.add(name)

View File

@ -0,0 +1,72 @@
#!/usr/bin/env python3
# -*- 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
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
from inputremapper.logging.logger import logger
class AddTask(Task):
"""Add a number to a variable."""
argument_configs = [
ArgumentConfig(
name="variable",
position=0,
types=[int, float, None],
is_variable_name=True,
),
ArgumentConfig(
name="value",
position=1,
types=[int, float],
),
]
async def run(self, callback) -> None:
argument = self.get_argument("variable")
try:
current = argument.get_value()
except MacroError:
return
if current is None:
logger.debug(
'"%s" initialized with 0',
self.arguments["variable"].get_variable_name(),
)
argument.set_value(0)
current = 0
addend = self.get_argument("value").get_value()
if not isinstance(current, (int, float)):
logger.error(
'Expected variable "%s" to contain a number, but got "%s"',
argument.get_value(),
current,
)
return
logger.debug("%s += %s", current, addend)
argument.set_value(current + addend)

View File

@ -0,0 +1,64 @@
#!/usr/bin/env python3
# -*- 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
from evdev.ecodes import ecodes
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
class EventTask(Task):
"""Write any event.
For example event(EV_KEY, KEY_A, 1)
"""
argument_configs = [
ArgumentConfig(
name="type",
position=0,
types=[str, int],
),
ArgumentConfig(
name="code",
position=1,
types=[str, int],
),
ArgumentConfig(
name="value",
position=2,
types=[int],
),
]
async def run(self, callback) -> None:
type_ = self.get_argument("type").get_value()
code = self.get_argument("code").get_value()
value = self.get_argument("value").get_value()
if isinstance(type_, str):
type_ = ecodes[type_.upper()]
if isinstance(code, str):
code = ecodes[code.upper()]
callback(type_, code, value)
await self.keycode_pause()

View File

@ -0,0 +1,71 @@
#!/usr/bin/env python3
# -*- 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
import asyncio
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class HoldTask(Task):
"""Loop the macro until the trigger-key is released."""
argument_configs = [
ArgumentConfig(
name="macro",
position=0,
types=[Macro, str, None],
)
]
async def run(self, callback) -> None:
macro = self.get_argument("macro").get_value()
if macro is None:
await self._trigger_release_event.wait()
return
if isinstance(macro, str):
# if macro is a key name, hold down the key while the
# keyboard key is physically held down
symbol = macro
self.get_argument("macro").assert_is_symbol(symbol)
code = keyboard_layout.get(symbol)
callback(EV_KEY, code, 1)
await self._trigger_release_event.wait()
callback(EV_KEY, code, 0)
if isinstance(macro, Macro):
# repeat the macro forever while the key is held down
while self.is_holding():
# run the child macro completely to avoid
# not-releasing any key
await macro.run(callback)
# prevent input-remapper from freezing up and making the system hard
# to control, by adding 1ms of asyncio.sleep during which the event
# loop can do other things
await asyncio.sleep(1 / 1000)

View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
# -*- 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
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.argument import ArgumentConfig, ArgumentFlags
from inputremapper.injection.macros.task import Task
class HoldKeysTask(Task):
"""Hold down multiple keys, equivalent to `a + b + c + ...`."""
argument_configs = [
ArgumentConfig(
name="*symbols",
position=ArgumentFlags.spread,
types=[str],
is_symbol=True,
)
]
async def run(self, callback) -> None:
symbols = self.get_argument("*symbols").get_values()
codes = [keyboard_layout.get(symbol) for symbol in symbols]
for code in codes:
callback(EV_KEY, code, 1)
await self.keycode_pause()
await self._trigger_release_event.wait()
for code in codes[::-1]:
callback(EV_KEY, code, 0)
await self.keycode_pause()

View File

@ -0,0 +1,66 @@
#!/usr/bin/env python3
# -*- 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
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class IfEqTask(Task):
"""Compare two values."""
argument_configs = [
ArgumentConfig(
name="value_1",
position=0,
types=[str, int, float],
),
ArgumentConfig(
name="value_2",
position=1,
types=[str, int, float],
),
ArgumentConfig(
name="then",
position=2,
types=[Macro, None],
default=None,
),
ArgumentConfig(
name="else",
position=3,
types=[Macro, None],
default=None,
),
]
async def run(self, callback) -> None:
value_1 = self.get_argument("value_1").get_value()
value_2 = self.get_argument("value_2").get_value()
then = self.get_argument("then").get_value()
else_ = self.get_argument("else").get_value()
if value_1 == value_2:
if then is not None:
await then.run(callback)
elif else_ is not None:
await else_.run(callback)

View File

@ -0,0 +1,71 @@
#!/usr/bin/env python3
# -*- 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
from evdev.ecodes import (
LED_NUML,
LED_CAPSL,
)
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class IfLedTask(Task):
argument_configs = [
ArgumentConfig(
name="then",
position=0,
types=[Macro, None],
default=None,
),
ArgumentConfig(
name="else",
position=1,
types=[Macro, None],
default=None,
),
]
led_code = None
async def run(self, callback) -> None:
then = self.get_argument("then").get_value()
else_ = self.get_argument("else").get_value()
# self.context is only None when the frontend is parsing the macro
assert self.context is not None
led_on = self.led_code in self.context.get_leds()
if led_on:
if then is not None:
await then.run(callback)
elif else_ is not None:
await else_.run(callback)
class IfNumlockTask(IfLedTask):
led_code = LED_NUML
class IfCapslockTask(IfLedTask):
led_code = LED_CAPSL

View File

@ -0,0 +1,96 @@
#!/usr/bin/env python3
# -*- 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
import asyncio
from evdev.ecodes import EV_KEY
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
from inputremapper.input_event import InputEvent
class IfSingleTask(Task):
"""If a key was pressed without combining it."""
argument_configs = [
ArgumentConfig(
name="then",
position=0,
types=[Macro, None],
),
ArgumentConfig(
name="else",
position=1,
types=[Macro, None],
),
ArgumentConfig(
name="timeout",
position=2,
types=[int, float, None],
default=None,
),
]
async def run(self, callback) -> None:
assert self.context is not None
another_key_pressed_event = asyncio.Event()
then = self.get_argument("then").get_value()
else_ = self.get_argument("else").get_value()
async def listener(event: InputEvent) -> None:
if event.type != EV_KEY:
# Ignore anything that is not a key
return
if event.is_pressed():
# Another key was pressed
another_key_pressed_event.set()
return
self.add_event_listener(listener)
timeout = self.get_argument("timeout").get_value()
# Wait for anything of importance to happen, that would determine the
# outcome of the if_single macro.
await asyncio.wait(
[
asyncio.Task(another_key_pressed_event.wait()),
asyncio.Task(self._trigger_release_event.wait()),
],
timeout=timeout / 1000 if timeout else None,
return_when=asyncio.FIRST_COMPLETED,
)
self.remove_event_listener(listener)
if not self.is_holding():
if then:
await then.run(callback)
else:
# If the trigger has not been released, then `await asyncio.wait` above
# could only have finished waiting due to a timeout, or because another
# key was pressed.
if else_:
await else_.run(callback)

View File

@ -0,0 +1,79 @@
#!/usr/bin/env python3
# -*- 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
import asyncio
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class IfTapTask(Task):
"""If a key was pressed quickly.
macro key pressed -> if_tap starts -> key released -> then
macro key pressed -> released (does other stuff in the meantime)
-> if_tap starts -> pressed -> released -> then
"""
argument_configs = [
ArgumentConfig(
name="then",
position=0,
types=[Macro, None],
default=None,
),
ArgumentConfig(
name="else",
position=1,
types=[Macro, None],
default=None,
),
ArgumentConfig(
name="timeout",
position=2,
types=[int, float],
default=300,
),
]
async def run(self, callback) -> None:
then = self.get_argument("then").get_value()
else_ = self.get_argument("else").get_value()
timeout = self.get_argument("timeout").get_value() / 1000
try:
await asyncio.wait_for(self._wait(), timeout)
if then:
await then.run(callback)
except asyncio.TimeoutError:
if else_:
await else_.run(callback)
async def _wait(self):
"""Wait for a release, or if nothing pressed yet, a press and release."""
if self.is_holding():
await self._trigger_release_event.wait()
else:
await self._trigger_press_event.wait()
await self._trigger_release_event.wait()

View File

@ -0,0 +1,72 @@
#!/usr/bin/env python3
# -*- 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
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class DeprecatedIfEqTask(Task):
"""Old version of if_eq, kept for compatibility reasons.
This can't support a comparison like ifeq("foo", $blub) with blub containing
"foo" without breaking old functionality, because "foo" is treated as a
variable name.
"""
argument_configs = [
ArgumentConfig(
name="variable",
position=0,
types=[str, float, int, None],
is_variable_name=True,
),
ArgumentConfig(
name="value",
position=1,
types=[str, float, int, None],
),
ArgumentConfig(
name="then",
position=2,
types=[Macro, None],
),
ArgumentConfig(
name="else",
position=3,
types=[Macro, None],
),
]
async def run(self, callback) -> None:
actual_value = self.get_argument("variable").get_value()
value = self.get_argument("value").get_value()
then = self.get_argument("then").get_value()
else_ = self.get_argument("else").get_value()
# The old ifeq function became somewhat incompatible with the new macro code.
# I need to compare them as strings to keep this working.
if str(actual_value) == str(value):
if then is not None:
await then.run(callback)
elif else_ is not None:
await else_.run(callback)

View File

@ -0,0 +1,49 @@
#!/usr/bin/env python3
# -*- 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
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
class KeyTask(Task):
"""Write the symbol."""
argument_configs = [
ArgumentConfig(
name="symbol",
position=0,
types=[str],
is_symbol=True,
)
]
async def run(self, callback) -> None:
symbol = self.get_argument("symbol").get_value()
code = keyboard_layout.get(symbol)
callback(EV_KEY, code, 1)
await self.keycode_pause()
callback(EV_KEY, code, 0)
await self.keycode_pause()

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
# -*- 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
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
class KeyDownTask(Task):
"""Press the symbol."""
argument_configs = [
ArgumentConfig(
name="symbol",
position=0,
types=[str],
is_symbol=True,
)
]
async def run(self, callback) -> None:
symbol = self.get_argument("symbol").get_value()
code = keyboard_layout.get(symbol)
callback(EV_KEY, code, 1)

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
# -*- 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
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
class KeyUpTask(Task):
"""Release the symbol."""
argument_configs = [
ArgumentConfig(
name="symbol",
position=0,
types=[str],
is_symbol=True,
)
]
async def run(self, callback) -> None:
symbol = self.get_argument("symbol").get_value()
code = keyboard_layout.get(symbol)
callback(EV_KEY, code, 0)

View File

@ -0,0 +1,140 @@
#!/usr/bin/env python3
# -*- 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
import asyncio
from collections import deque
from typing import Deque
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
from inputremapper.input_event import InputEvent
from inputremapper.logging.logger import logger
class ModTapTask(Task):
"""If pressed long enough in combination with other keys, it turns into a modifier.
Can be used to make home-row-modifiers.
Works similar to the default of
https://github.com/qmk/qmk_firmware/blob/78a0adfbb4d2c4e12f93f2a62ded0020d406243e/docs/tap_hold.md#comparison-comparison
"""
argument_configs = [
ArgumentConfig(
name="default",
position=0,
types=[str],
is_symbol=True,
),
ArgumentConfig(
name="modifier",
position=1,
types=[str],
is_symbol=True,
),
ArgumentConfig(
name="tapping_term",
position=2,
types=[int, float],
default=200,
),
]
async def run(self, callback) -> None:
tapping_term = self.get_argument("tapping_term").get_value() / 1000
jamming_asyncio_events: Deque[asyncio.Event] = deque()
async def listener(event: InputEvent) -> None:
trigger = self.mapping.input_combination[-1]
if event.type_and_code == trigger.type_and_code:
# We don't block the event that would set _trigger_release_event.
return
if event.type != EV_KEY:
return
asyncio_event = asyncio.Event()
jamming_asyncio_events.append(asyncio_event)
# Make the EventReader wait until the mod_tap macro allows it to continue
# processing the event. Because we want to wait until mod_tap injected the
# modifier.
await asyncio_event.wait()
self.add_event_listener(listener)
timeout = asyncio.Task(asyncio.sleep(tapping_term))
await asyncio.wait(
[asyncio.Task(self._trigger_release_event.wait()), timeout],
return_when=asyncio.FIRST_COMPLETED,
)
has_timed_out = timeout.done()
if has_timed_out:
# The timeout happened before the trigger got released.
# We therefore modify stuff.
symbol = self.get_argument("modifier").get_value()
logger.debug("Modifying with %s", symbol)
else:
# The trigger got released before the timeout.
# We therefore do not modify stuff.
symbol = self.get_argument("default").get_value()
logger.debug("Writing default %s", symbol)
code = keyboard_layout.get(symbol)
callback(EV_KEY, code, 1)
await self.keycode_pause()
# Now that we know if the key was pressed with the intention of modifying other
# keys, we can let the jammed keys go on their journey through the handlers.
# Those other handlers may map them to other keys and stuff.
while len(jamming_asyncio_events) > 0:
asyncio_event = jamming_asyncio_events.popleft()
asyncio_event.set()
await self.keycode_pause()
await self.throttle()
# While we are emptying the queue, more events might still arrive and add
# to the queue.
# We remove this as late as possible, because if more keys are pressed while
# jamming_asyncio_events is still being taken care of, they should wait until
# all is done. This ensures the order of all events that are pressed, until
# mod_tap is completely finished.
self.remove_event_listener(listener)
# Keep the modifier pressed until the input/trigger is released
await self._trigger_release_event.wait()
callback(EV_KEY, code, 0)
await self.keycode_pause()
async def throttle(self) -> None:
# In case the keycode_pause ist set to 0ms, we need to give the event handlers
# a chance to inject the withheld events, before we go on. This ensures the
# correct order of injections. Since we are using asyncio, something like
# `callback(EV_KEY, code, 0)` might be faster than the event handlers, even if
# it is the last step of the macro.
if self.mapping.macro_key_sleep_ms == 0:
await asyncio.sleep(0.01)

View File

@ -0,0 +1,57 @@
#!/usr/bin/env python3
# -*- 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
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class ModifyTask(Task):
"""Do stuff while a modifier is activated."""
argument_configs = [
ArgumentConfig(
name="modifier",
position=0,
types=[str],
is_symbol=True,
),
ArgumentConfig(
name="macro",
position=1,
types=[Macro],
),
]
async def run(self, callback) -> None:
modifier = self.get_argument("modifier").get_value()
code = keyboard_layout.get(modifier)
macro = self.get_argument("macro").get_value()
callback(EV_KEY, code, 1)
await self.keycode_pause()
await macro.run(callback)
callback(EV_KEY, code, 0)
await self.keycode_pause()

View File

@ -0,0 +1,69 @@
#!/usr/bin/env python3
# -*- 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
from evdev._ecodes import REL_Y, REL_X
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import InjectEventCallback
from inputremapper.injection.macros.tasks.mouse_xy import MouseXYTask
class MouseTask(MouseXYTask):
"""Move the mouse cursor."""
argument_configs = [
ArgumentConfig(
name="direction",
position=0,
types=[str],
),
ArgumentConfig(
name="speed",
position=1,
types=[int, float],
),
ArgumentConfig(
name="acceleration",
position=2,
types=[int, float],
default=1,
),
]
async def run(self, callback: InjectEventCallback) -> None:
direction = self.get_argument("direction").get_value()
speed = self.get_argument("speed").get_value()
acceleration = self.get_argument("acceleration").get_value()
code, direction = {
"up": (REL_Y, -1),
"down": (REL_Y, 1),
"left": (REL_X, -1),
"right": (REL_X, 1),
}[direction.lower()]
await self.axis(
code,
direction * speed,
acceleration,
callback,
)

View File

@ -0,0 +1,99 @@
#!/usr/bin/env python3
# -*- 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
import asyncio
from typing import Union
from evdev._ecodes import REL_Y, REL_X
from evdev.ecodes import EV_REL
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import InjectEventCallback
from inputremapper.injection.macros.task import Task
from inputremapper.injection.macros.tasks.util import precise_iteration_frequency
class MouseXYTask(Task):
"""Move the mouse cursor."""
argument_configs = [
ArgumentConfig(
name="x",
position=0,
types=[int, float],
default=0,
),
ArgumentConfig(
name="y",
position=1,
types=[int, float],
default=0,
),
ArgumentConfig(
name="acceleration",
position=2,
types=[int, float],
default=1,
),
]
async def run(self, callback: InjectEventCallback) -> None:
x = self.get_argument("x").get_value()
y = self.get_argument("y").get_value()
acceleration = self.get_argument("acceleration").get_value()
await asyncio.gather(
self.axis(REL_X, x, acceleration, callback),
self.axis(REL_Y, y, acceleration, callback),
)
async def axis(
self,
code: int,
speed: Union[int, float],
fractional_acceleration: Union[int, float],
callback: InjectEventCallback,
) -> None:
acceleration = speed * fractional_acceleration
direction = -1 if speed < 0 else 1
current_speed = 0.0
displacement_accumulator = 0.0
displacement = 0
if acceleration <= 0:
displacement = int(speed)
async for _ in precise_iteration_frequency(self.mapping.rel_rate):
if not self.is_holding():
return
# Cursors can only move by integers. To get smooth acceleration for
# small acceleration values, the cursor needs to move by a pixel every
# few iterations. This can be achieved by remembering the decimal
# places that were cast away, and using them for the next iteration.
if acceleration:
current_speed += acceleration
current_speed = direction * min(abs(current_speed), abs(speed))
displacement_accumulator += current_speed
displacement = int(displacement_accumulator)
displacement_accumulator -= displacement
if displacement != 0:
callback(EV_REL, code, displacement)

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
# -*- 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
import asyncio
from typing import List
from inputremapper.injection.macros.argument import ArgumentConfig, ArgumentFlags
from inputremapper.injection.macros.macro import Macro, InjectEventCallback
from inputremapper.injection.macros.task import Task
class ParallelTask(Task):
"""Run all provided macros in parallel."""
argument_configs = [
ArgumentConfig(
name="*macros",
position=ArgumentFlags.spread,
types=[Macro],
),
]
async def run(self, callback: InjectEventCallback) -> None:
macros: List[Macro] = self.get_argument("*macros").get_values()
coroutines = [macro.run(callback) for macro in macros]
await asyncio.gather(*coroutines)

View File

@ -0,0 +1,49 @@
#!/usr/bin/env python3
# -*- 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
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class RepeatTask(Task):
"""Repeat macros."""
argument_configs = [
ArgumentConfig(
name="repeats",
position=0,
types=[int],
),
ArgumentConfig(
name="macro",
position=1,
types=[Macro],
),
]
async def run(self, callback) -> None:
repeats = self.get_argument("repeats").get_value()
macro = self.get_argument("macro").get_value()
for _ in range(repeats):
await macro.run(callback)

View File

@ -0,0 +1,49 @@
#!/usr/bin/env python3
# -*- 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
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import macro_variables
from inputremapper.injection.macros.task import Task
class SetTask(Task):
"""Set a variable to a certain value."""
argument_configs = [
ArgumentConfig(
name="variable",
position=0,
types=[str, float, int, None],
is_variable_name=True,
),
ArgumentConfig(
name="value",
position=1,
types=[str, float, int, None],
default=None,
),
]
async def run(self, callback) -> None:
value = self.get_argument("value").get_value()
assert macro_variables.is_alive()
self.arguments["variable"].set_value(value)

View File

@ -0,0 +1,56 @@
#!/usr/bin/env python3
# -*- 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
import asyncio
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class ToggleTask(Task):
"""Toggle macros."""
argument_configs = [
ArgumentConfig(
name="macro",
position=0,
types=[Macro],
),
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.trigger_count = 0
def press_trigger(self) -> None:
Task.press_trigger(self)
self.trigger_count += 1
async def run(self, callback) -> None:
macro = self.get_argument("macro").get_value()
while self.trigger_count <= 1:
await macro.run(callback)
await asyncio.sleep(1 / 1000)
self.trigger_count = 0

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
# -*- 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
import asyncio
import time
from typing import AsyncIterator
async def precise_iteration_frequency(frequency: float) -> AsyncIterator[None]:
"""A generator to iterate over in a fixed frequency.
asyncio.sleep might end up sleeping too long, for whatever reason. Maybe there are
other async function calls that take longer than expected in the background.
"""
sleep = 1 / frequency
corrected_sleep = sleep
error = 0.0
while True:
start = time.time()
yield
corrected_sleep -= error
await asyncio.sleep(corrected_sleep)
error = (time.time() - start) - sleep

View File

@ -0,0 +1,54 @@
#!/usr/bin/env python3
# -*- 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
import asyncio
import random
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
class WaitTask(Task):
"""Wait time in milliseconds."""
argument_configs = [
ArgumentConfig(
name="time",
position=0,
types=[float, int],
),
ArgumentConfig(
name="max_time",
position=1,
types=[float, int, None],
default=None,
),
]
async def run(self, callback) -> None:
time = self.get_argument("time").get_value()
max_time = self.get_argument("max_time").get_value()
if max_time is not None and max_time > time:
time = random.uniform(time, max_time)
await asyncio.sleep(time / 1000)

View File

@ -0,0 +1,76 @@
#!/usr/bin/env python3
# -*- 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
import math
from evdev.ecodes import (
EV_REL,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
REL_WHEEL,
REL_HWHEEL,
)
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
from inputremapper.injection.macros.tasks.util import precise_iteration_frequency
class WheelTask(Task):
"""Move the scroll wheel."""
argument_configs = [
ArgumentConfig(
name="direction",
position=0,
types=[str],
),
ArgumentConfig(
name="speed",
position=1,
types=[int, float],
),
]
async def run(self, callback) -> None:
direction = self.get_argument("direction").get_value()
# 120, see https://www.kernel.org/doc/html/latest/input/event-codes.html#ev-rel
code, value = {
"up": ([REL_WHEEL, REL_WHEEL_HI_RES], [1 / 120, 1]),
"down": ([REL_WHEEL, REL_WHEEL_HI_RES], [-1 / 120, -1]),
"left": ([REL_HWHEEL, REL_HWHEEL_HI_RES], [1 / 120, 1]),
"right": ([REL_HWHEEL, REL_HWHEEL_HI_RES], [-1 / 120, -1]),
}[direction.lower()]
speed = self.get_argument("speed").get_value()
remainder = [0.0, 0.0]
async for _ in precise_iteration_frequency(self.mapping.rel_rate):
if not self.is_holding():
return
for i in range(0, 2):
float_value = value[i] * speed + remainder[i]
remainder[i] = math.fmod(float_value, 1)
if abs(float_value) >= 1:
callback(EV_REL, code[i], int(float_value))

View File

@ -0,0 +1,90 @@
#!/usr/bin/env python3
# -*- 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 re
from typing import Any
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.macro import macro_variables
class Variable:
"""Something that the user passed into a macro function as parameter.
The value should already be parsed and validated, if const=True, according to the
argument_configs of a given Task.
Examples:
- const string "KEY_A" in `key(KEY_A)`
- non-const string "foo" in `repeat($foo, key(KEY_A))` (`value` is the name here)
- const Macro `key(a)` in `repeat(1, key(a))`
- const int 1 in `repeat(1, key(a))
"""
def __init__(self, value: Any, const: bool) -> None:
if not const and not isinstance(value, str):
raise MacroError(f"Variables require a string name, not {value}")
self.value = value
self.const = const
if not const:
self.validate_variable_name()
def get_name(self) -> str:
"""If the variable is not const, return its name."""
assert not self.const
assert isinstance(self.value, str)
return self.value
def get_value(self) -> Any:
"""Get the variables value from the common variable storage process."""
if self.const:
return self.value
return macro_variables.get(self.value)
def set_value(self, value: Any) -> None:
"""Set the variables value across all macros."""
assert not self.const
macro_variables[self.value] = value
def validate_variable_name(self) -> None:
"""Check if this is a legit variable name.
Because they could clash with language features. If the macro can be
parsed at all due to a problematic choice of a variable name.
Allowed examples: "foo", "Foo1234_", "_foo_1234"
Not allowed: "1_foo", "foo=blub", "$foo", "foo,1234", "foo()"
"""
if not isinstance(self.value, str) or not re.match(
r"^[A-Za-z_][A-Za-z_0-9]*$", self.value
):
raise MacroError(msg=f'"{self.value}" is not a legit variable name')
def __repr__(self) -> str:
return f'<Variable "{self.value}" const={self.const} at {hex(id(self))}>'
def __eq__(self, other) -> bool:
if not isinstance(other, Variable):
return False
return self.const == other.const and self.value == other.value

View 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/>.

View 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 {}

View 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()

View 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

View 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

View 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}

View 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")

View 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

View 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

View 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}

View 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

View 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 []

View 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)

View 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

View 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 {}

View 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()

View 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 {}

View File

@ -0,0 +1,83 @@
# -*- 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 handle numlocks.
For unknown reasons the numlock status can change when starting injections,
which is why these functions exist.
"""
import re
import subprocess
from inputremapper.logging.logger import logger
def is_numlock_on():
"""Get the current state of the numlock."""
try:
xset_q = subprocess.check_output(
["xset", "q"],
stderr=subprocess.STDOUT,
).decode()
num_lock_status = re.search(r"Num Lock:\s+(.+?)\s", xset_q)
if num_lock_status is not None:
return num_lock_status[1] == "on"
return False
except (FileNotFoundError, subprocess.CalledProcessError):
# tty
return None
def set_numlock(state):
"""Set the numlock to a given state of True or False."""
if state is None:
return
value = {True: "on", False: "off"}[state]
try:
subprocess.check_output(["numlockx", value])
except subprocess.CalledProcessError:
# might be in a tty
pass
except FileNotFoundError:
# doesn't seem to be installed everywhere
logger.debug("numlockx not found")
def ensure_numlock(func):
"""Decorator to reset the numlock to its initial state afterwards."""
def wrapped(*args, **kwargs):
# for some reason, grabbing a device can modify the num lock state.
# remember it and apply back later
numlock_before = is_numlock_on()
result = func(*args, **kwargs)
set_numlock(numlock_before)
return result
return wrapped