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