chore(repo): baseline du fork input-remapper (upstream v2.2.1)

Le dépôt ne contenait que README.md ; ajout de l'intégralité du code
fonctionnel du fork comme socle stable de la branche de release.
L'état runtime d'orchestration (.ideai/) est exclu du suivi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 00:47:12 +02:00
parent 1d790a78fb
commit 0ab77d2a64
257 changed files with 51859 additions and 0 deletions

View File

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

View File

@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Executes more complex patterns of keystrokes."""
from __future__ import annotations
import asyncio
from typing import List, Callable, Optional, TYPE_CHECKING
from inputremapper.ipc.shared_dict import SharedDict
from inputremapper.logging.logger import logger
if TYPE_CHECKING:
from inputremapper.injection.macros.task import Task
from inputremapper.injection.context import Context
from inputremapper.configs.mapping import Mapping
InjectEventCallback = Callable[[int, int, int], None]
macro_variables = SharedDict()
class Macro:
"""Chains tasks (like `modify` or `repeat`).
Tasks may have child_macros. Running a Macro runs Tasks, which in turn may run
their child_macros based on certain conditions (depending on the Task).
"""
def __init__(
self,
code: Optional[str],
context: Optional[Context] = None,
mapping: Optional[Mapping] = None,
):
"""Create a macro instance that can be populated with tasks.
Parameters
----------
code
The original parsed code, for logging purposes.
context : Context
mapping : UIMapping
"""
self.code = code
self.context = context
self.mapping = mapping
# List of coroutines that will be called sequentially.
# This is the compiled code
self.tasks: List[Task] = []
self.running = False
self.keystroke_sleep_ms = None
async def run(self, callback: InjectEventCallback):
"""Run the macro.
Parameters
----------
callback
Will receive int type, code and value for an event to write
"""
if not callable(callback):
raise ValueError("handler is not callable")
if self.running:
logger.error('Tried to run already running macro "%s"', self.code)
return
self.keystroke_sleep_ms = self.mapping.macro_key_sleep_ms
self.running = True
try:
for task in self.tasks:
coroutine = task.run(callback)
if asyncio.iscoroutine(coroutine):
await coroutine
except Exception:
raise
finally:
# done
self.running = False
def press_trigger(self):
"""The user pressed the trigger key down."""
for task in self.tasks:
task.press_trigger()
def release_trigger(self):
"""The user released the trigger key."""
for task in self.tasks:
task.release_trigger()
async def _keycode_pause(self, _=None):
"""To add a pause between keystrokes.
This was needed at some point because it appeared that injecting keys too
fast will prevent them from working. It probably depends on the environment.
"""
await asyncio.sleep(self.keystroke_sleep_ms / 1000)
def __repr__(self):
return f'<Macro "{self.code}" at {hex(id(self))}>'
def add_task(self, task):
self.tasks.append(task)

View File

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

View File

@ -0,0 +1,35 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from dataclasses import dataclass
from typing import Union
from inputremapper.injection.macros.macro import Macro
@dataclass
class RawValue:
"""Store a value exactly as it is in the macro-code. Values still have to be
validated according to the ArgumentConfig of the Tasks.
Child-macros are passed as Macro objects though.
"""
value: Union[str, Macro]

View File

@ -0,0 +1,248 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import asyncio
from itertools import chain
from typing import List, Dict, TYPE_CHECKING, Optional, Tuple, Union
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.argument import (
Argument,
ArgumentConfig,
ArgumentFlags,
)
from inputremapper.injection.macros.macro import Macro, InjectEventCallback
from inputremapper.logging.logger import logger
if TYPE_CHECKING:
from inputremapper.injection.mapping_handlers.mapping_handler import EventListener
from inputremapper.injection.macros.raw_value import RawValue
from inputremapper.injection.context import Context
from inputremapper.configs.mapping import Mapping
class Task:
"""Base Class for functions like `if_eq` or `key`
A macro like `key(a).key(b)` will contain two instances of this class.
"""
argument_configs: List[ArgumentConfig]
arguments: Dict[str, Argument]
mapping: Mapping
# The context is None during frontend-parsing/validation I believe
context: Optional[Context]
child_macros: List[Macro]
def __init__(
self,
positional_args: List[RawValue],
keyword_args: Dict[str, RawValue],
context: Optional[Context],
mapping: Mapping,
) -> None:
self.context = context
self.mapping = mapping
self.child_macros = []
self._validate_argument_configs()
self.arguments = {
argument_config.name: Argument(argument_config, mapping)
for argument_config in self.argument_configs
}
self._setup_asyncio_events()
for argument in self.arguments.values():
self._initialize_argument(argument, keyword_args, positional_args)
self._initialize_spread_arg(positional_args)
for raw_value in chain(keyword_args.values(), positional_args):
if isinstance(raw_value.value, Macro):
self.child_macros.append(raw_value.value)
async def run(self, callback: InjectEventCallback) -> None:
"""Macro logic goes here.
Call the callback with the type, code and value that should be injected.
"""
raise NotImplementedError()
def add_event_listener(self, listener: EventListener) -> None:
"""Listeners get each event from the source device.
After all listeners are done, the event will go into the mapping handlers.
Make sure to remove your event_listener once you are done.
"""
# The context will be there when the macro is parsed by the service
assert self.context is not None
self.context.listeners.add(listener)
def remove_event_listener(self, listener: EventListener) -> None:
assert self.context is not None
self.context.listeners.remove(listener)
@classmethod
def get_macro_argument_names(cls):
return [argument_config.name for argument_config in cls.argument_configs]
@classmethod
def get_num_parameters(cls) -> Tuple[int, Union[int, float]]:
"""Get the number of required parameters and the maximum number of parameters."""
min_num_args = 0
argument_configs = cls.argument_configs
max_num_args: Union[int, float] = len(argument_configs)
for argument_config in argument_configs:
if argument_config.position == ArgumentFlags.spread:
# 0 or more
max_num_args = float("inf")
continue
if argument_config.is_required():
min_num_args += 1
return min_num_args, max_num_args
def get_argument(self, argument_name) -> Argument:
return self.arguments[argument_name]
def press_trigger(self) -> None:
"""The user pressed the trigger key down."""
for macro in self.child_macros:
macro.press_trigger()
if self.is_holding():
logger.error("Already holding")
return
self._trigger_release_event.clear()
self._trigger_press_event.set()
def release_trigger(self) -> None:
"""The user released the trigger key."""
if not self.is_holding():
return
self._trigger_release_event.set()
self._trigger_press_event.clear()
for macro in self.child_macros:
macro.release_trigger()
def is_holding(self) -> bool:
"""Check if the macro is waiting for a key to be released."""
return not self._trigger_release_event.is_set()
async def keycode_pause(self, _=None) -> None:
"""To add a pause between keystrokes.
This was needed at some point because it appeared that injecting keys too
fast will prevent them from working. It probably depends on the environment.
"""
await asyncio.sleep(self.mapping.macro_key_sleep_ms / 1000)
def _initialize_spread_arg(
self,
positional_args: List[RawValue],
) -> None:
"""Put all positional arguments that aren't used into the spread argument."""
spread_argument: Optional[Argument] = None
for argument in self.arguments.values():
if argument.position == ArgumentFlags.spread:
spread_argument = argument
break
if spread_argument is None:
return
remaining_positional_args = [*positional_args]
for argument in self.arguments.values():
if argument.position != ArgumentFlags.spread and argument.position < len(
remaining_positional_args
):
del remaining_positional_args[argument.position]
spread_argument.initialize_variables(remaining_positional_args)
def _find_argument_by_position(self, position: int) -> Optional[Argument]:
for argument in self.arguments.values():
if argument.position == position:
return argument
return None
def _setup_asyncio_events(self) -> None:
# Can be used to wait for the press and release of the input event/key, that is
# configured as the trigger of the macro, via asyncio.
self._trigger_release_event = asyncio.Event()
self._trigger_press_event = asyncio.Event()
# released by default
self._trigger_release_event.set()
self._trigger_press_event.clear()
def _initialize_argument(
self,
argument: Argument,
keyword_args: Dict[str, RawValue],
positional_args: List[RawValue],
) -> None:
if argument.position == ArgumentFlags.spread:
# Will get all the remaining positional arguments afterward.
return
for name, value in keyword_args.items():
if argument.name == name:
argument.initialize_variable(value)
return
if argument.position < len(positional_args):
argument.initialize_variable(positional_args[argument.position])
return
if not argument.is_required():
argument.initialize_default()
return
# This shouldn't be possible, the parser should have ensured things are valid
# already.
raise MacroError(f"Could not initialize argument {argument.name}")
def _validate_argument_configs(self):
# Might help during development
positions = set()
names = set()
for argument_config in self.argument_configs:
position = argument_config.position
if position in positions:
raise MacroError(f"Duplicate position {positions} in ArgumentConfig")
positions.add(position)
name = argument_config.name
if name in names:
raise MacroError(f"Duplicate name {name} in ArgumentConfig")
names.add(name)

View File

@ -0,0 +1,72 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
from inputremapper.logging.logger import logger
class AddTask(Task):
"""Add a number to a variable."""
argument_configs = [
ArgumentConfig(
name="variable",
position=0,
types=[int, float, None],
is_variable_name=True,
),
ArgumentConfig(
name="value",
position=1,
types=[int, float],
),
]
async def run(self, callback) -> None:
argument = self.get_argument("variable")
try:
current = argument.get_value()
except MacroError:
return
if current is None:
logger.debug(
'"%s" initialized with 0',
self.arguments["variable"].get_variable_name(),
)
argument.set_value(0)
current = 0
addend = self.get_argument("value").get_value()
if not isinstance(current, (int, float)):
logger.error(
'Expected variable "%s" to contain a number, but got "%s"',
argument.get_value(),
current,
)
return
logger.debug("%s += %s", current, addend)
argument.set_value(current + addend)

View File

@ -0,0 +1,64 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from evdev.ecodes import ecodes
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
class EventTask(Task):
"""Write any event.
For example event(EV_KEY, KEY_A, 1)
"""
argument_configs = [
ArgumentConfig(
name="type",
position=0,
types=[str, int],
),
ArgumentConfig(
name="code",
position=1,
types=[str, int],
),
ArgumentConfig(
name="value",
position=2,
types=[int],
),
]
async def run(self, callback) -> None:
type_ = self.get_argument("type").get_value()
code = self.get_argument("code").get_value()
value = self.get_argument("value").get_value()
if isinstance(type_, str):
type_ = ecodes[type_.upper()]
if isinstance(code, str):
code = ecodes[code.upper()]
callback(type_, code, value)
await self.keycode_pause()

View File

@ -0,0 +1,71 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import asyncio
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class HoldTask(Task):
"""Loop the macro until the trigger-key is released."""
argument_configs = [
ArgumentConfig(
name="macro",
position=0,
types=[Macro, str, None],
)
]
async def run(self, callback) -> None:
macro = self.get_argument("macro").get_value()
if macro is None:
await self._trigger_release_event.wait()
return
if isinstance(macro, str):
# if macro is a key name, hold down the key while the
# keyboard key is physically held down
symbol = macro
self.get_argument("macro").assert_is_symbol(symbol)
code = keyboard_layout.get(symbol)
callback(EV_KEY, code, 1)
await self._trigger_release_event.wait()
callback(EV_KEY, code, 0)
if isinstance(macro, Macro):
# repeat the macro forever while the key is held down
while self.is_holding():
# run the child macro completely to avoid
# not-releasing any key
await macro.run(callback)
# prevent input-remapper from freezing up and making the system hard
# to control, by adding 1ms of asyncio.sleep during which the event
# loop can do other things
await asyncio.sleep(1 / 1000)

View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.argument import ArgumentConfig, ArgumentFlags
from inputremapper.injection.macros.task import Task
class HoldKeysTask(Task):
"""Hold down multiple keys, equivalent to `a + b + c + ...`."""
argument_configs = [
ArgumentConfig(
name="*symbols",
position=ArgumentFlags.spread,
types=[str],
is_symbol=True,
)
]
async def run(self, callback) -> None:
symbols = self.get_argument("*symbols").get_values()
codes = [keyboard_layout.get(symbol) for symbol in symbols]
for code in codes:
callback(EV_KEY, code, 1)
await self.keycode_pause()
await self._trigger_release_event.wait()
for code in codes[::-1]:
callback(EV_KEY, code, 0)
await self.keycode_pause()

View File

@ -0,0 +1,66 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class IfEqTask(Task):
"""Compare two values."""
argument_configs = [
ArgumentConfig(
name="value_1",
position=0,
types=[str, int, float],
),
ArgumentConfig(
name="value_2",
position=1,
types=[str, int, float],
),
ArgumentConfig(
name="then",
position=2,
types=[Macro, None],
default=None,
),
ArgumentConfig(
name="else",
position=3,
types=[Macro, None],
default=None,
),
]
async def run(self, callback) -> None:
value_1 = self.get_argument("value_1").get_value()
value_2 = self.get_argument("value_2").get_value()
then = self.get_argument("then").get_value()
else_ = self.get_argument("else").get_value()
if value_1 == value_2:
if then is not None:
await then.run(callback)
elif else_ is not None:
await else_.run(callback)

View File

@ -0,0 +1,71 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from evdev.ecodes import (
LED_NUML,
LED_CAPSL,
)
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class IfLedTask(Task):
argument_configs = [
ArgumentConfig(
name="then",
position=0,
types=[Macro, None],
default=None,
),
ArgumentConfig(
name="else",
position=1,
types=[Macro, None],
default=None,
),
]
led_code = None
async def run(self, callback) -> None:
then = self.get_argument("then").get_value()
else_ = self.get_argument("else").get_value()
# self.context is only None when the frontend is parsing the macro
assert self.context is not None
led_on = self.led_code in self.context.get_leds()
if led_on:
if then is not None:
await then.run(callback)
elif else_ is not None:
await else_.run(callback)
class IfNumlockTask(IfLedTask):
led_code = LED_NUML
class IfCapslockTask(IfLedTask):
led_code = LED_CAPSL

View File

@ -0,0 +1,96 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import asyncio
from evdev.ecodes import EV_KEY
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
from inputremapper.input_event import InputEvent
class IfSingleTask(Task):
"""If a key was pressed without combining it."""
argument_configs = [
ArgumentConfig(
name="then",
position=0,
types=[Macro, None],
),
ArgumentConfig(
name="else",
position=1,
types=[Macro, None],
),
ArgumentConfig(
name="timeout",
position=2,
types=[int, float, None],
default=None,
),
]
async def run(self, callback) -> None:
assert self.context is not None
another_key_pressed_event = asyncio.Event()
then = self.get_argument("then").get_value()
else_ = self.get_argument("else").get_value()
async def listener(event: InputEvent) -> None:
if event.type != EV_KEY:
# Ignore anything that is not a key
return
if event.is_pressed():
# Another key was pressed
another_key_pressed_event.set()
return
self.add_event_listener(listener)
timeout = self.get_argument("timeout").get_value()
# Wait for anything of importance to happen, that would determine the
# outcome of the if_single macro.
await asyncio.wait(
[
asyncio.Task(another_key_pressed_event.wait()),
asyncio.Task(self._trigger_release_event.wait()),
],
timeout=timeout / 1000 if timeout else None,
return_when=asyncio.FIRST_COMPLETED,
)
self.remove_event_listener(listener)
if not self.is_holding():
if then:
await then.run(callback)
else:
# If the trigger has not been released, then `await asyncio.wait` above
# could only have finished waiting due to a timeout, or because another
# key was pressed.
if else_:
await else_.run(callback)

View File

@ -0,0 +1,79 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import asyncio
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class IfTapTask(Task):
"""If a key was pressed quickly.
macro key pressed -> if_tap starts -> key released -> then
macro key pressed -> released (does other stuff in the meantime)
-> if_tap starts -> pressed -> released -> then
"""
argument_configs = [
ArgumentConfig(
name="then",
position=0,
types=[Macro, None],
default=None,
),
ArgumentConfig(
name="else",
position=1,
types=[Macro, None],
default=None,
),
ArgumentConfig(
name="timeout",
position=2,
types=[int, float],
default=300,
),
]
async def run(self, callback) -> None:
then = self.get_argument("then").get_value()
else_ = self.get_argument("else").get_value()
timeout = self.get_argument("timeout").get_value() / 1000
try:
await asyncio.wait_for(self._wait(), timeout)
if then:
await then.run(callback)
except asyncio.TimeoutError:
if else_:
await else_.run(callback)
async def _wait(self):
"""Wait for a release, or if nothing pressed yet, a press and release."""
if self.is_holding():
await self._trigger_release_event.wait()
else:
await self._trigger_press_event.wait()
await self._trigger_release_event.wait()

View File

@ -0,0 +1,72 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class DeprecatedIfEqTask(Task):
"""Old version of if_eq, kept for compatibility reasons.
This can't support a comparison like ifeq("foo", $blub) with blub containing
"foo" without breaking old functionality, because "foo" is treated as a
variable name.
"""
argument_configs = [
ArgumentConfig(
name="variable",
position=0,
types=[str, float, int, None],
is_variable_name=True,
),
ArgumentConfig(
name="value",
position=1,
types=[str, float, int, None],
),
ArgumentConfig(
name="then",
position=2,
types=[Macro, None],
),
ArgumentConfig(
name="else",
position=3,
types=[Macro, None],
),
]
async def run(self, callback) -> None:
actual_value = self.get_argument("variable").get_value()
value = self.get_argument("value").get_value()
then = self.get_argument("then").get_value()
else_ = self.get_argument("else").get_value()
# The old ifeq function became somewhat incompatible with the new macro code.
# I need to compare them as strings to keep this working.
if str(actual_value) == str(value):
if then is not None:
await then.run(callback)
elif else_ is not None:
await else_.run(callback)

View File

@ -0,0 +1,49 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
class KeyTask(Task):
"""Write the symbol."""
argument_configs = [
ArgumentConfig(
name="symbol",
position=0,
types=[str],
is_symbol=True,
)
]
async def run(self, callback) -> None:
symbol = self.get_argument("symbol").get_value()
code = keyboard_layout.get(symbol)
callback(EV_KEY, code, 1)
await self.keycode_pause()
callback(EV_KEY, code, 0)
await self.keycode_pause()

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
class KeyDownTask(Task):
"""Press the symbol."""
argument_configs = [
ArgumentConfig(
name="symbol",
position=0,
types=[str],
is_symbol=True,
)
]
async def run(self, callback) -> None:
symbol = self.get_argument("symbol").get_value()
code = keyboard_layout.get(symbol)
callback(EV_KEY, code, 1)

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
class KeyUpTask(Task):
"""Release the symbol."""
argument_configs = [
ArgumentConfig(
name="symbol",
position=0,
types=[str],
is_symbol=True,
)
]
async def run(self, callback) -> None:
symbol = self.get_argument("symbol").get_value()
code = keyboard_layout.get(symbol)
callback(EV_KEY, code, 0)

View File

@ -0,0 +1,140 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import asyncio
from collections import deque
from typing import Deque
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
from inputremapper.input_event import InputEvent
from inputremapper.logging.logger import logger
class ModTapTask(Task):
"""If pressed long enough in combination with other keys, it turns into a modifier.
Can be used to make home-row-modifiers.
Works similar to the default of
https://github.com/qmk/qmk_firmware/blob/78a0adfbb4d2c4e12f93f2a62ded0020d406243e/docs/tap_hold.md#comparison-comparison
"""
argument_configs = [
ArgumentConfig(
name="default",
position=0,
types=[str],
is_symbol=True,
),
ArgumentConfig(
name="modifier",
position=1,
types=[str],
is_symbol=True,
),
ArgumentConfig(
name="tapping_term",
position=2,
types=[int, float],
default=200,
),
]
async def run(self, callback) -> None:
tapping_term = self.get_argument("tapping_term").get_value() / 1000
jamming_asyncio_events: Deque[asyncio.Event] = deque()
async def listener(event: InputEvent) -> None:
trigger = self.mapping.input_combination[-1]
if event.type_and_code == trigger.type_and_code:
# We don't block the event that would set _trigger_release_event.
return
if event.type != EV_KEY:
return
asyncio_event = asyncio.Event()
jamming_asyncio_events.append(asyncio_event)
# Make the EventReader wait until the mod_tap macro allows it to continue
# processing the event. Because we want to wait until mod_tap injected the
# modifier.
await asyncio_event.wait()
self.add_event_listener(listener)
timeout = asyncio.Task(asyncio.sleep(tapping_term))
await asyncio.wait(
[asyncio.Task(self._trigger_release_event.wait()), timeout],
return_when=asyncio.FIRST_COMPLETED,
)
has_timed_out = timeout.done()
if has_timed_out:
# The timeout happened before the trigger got released.
# We therefore modify stuff.
symbol = self.get_argument("modifier").get_value()
logger.debug("Modifying with %s", symbol)
else:
# The trigger got released before the timeout.
# We therefore do not modify stuff.
symbol = self.get_argument("default").get_value()
logger.debug("Writing default %s", symbol)
code = keyboard_layout.get(symbol)
callback(EV_KEY, code, 1)
await self.keycode_pause()
# Now that we know if the key was pressed with the intention of modifying other
# keys, we can let the jammed keys go on their journey through the handlers.
# Those other handlers may map them to other keys and stuff.
while len(jamming_asyncio_events) > 0:
asyncio_event = jamming_asyncio_events.popleft()
asyncio_event.set()
await self.keycode_pause()
await self.throttle()
# While we are emptying the queue, more events might still arrive and add
# to the queue.
# We remove this as late as possible, because if more keys are pressed while
# jamming_asyncio_events is still being taken care of, they should wait until
# all is done. This ensures the order of all events that are pressed, until
# mod_tap is completely finished.
self.remove_event_listener(listener)
# Keep the modifier pressed until the input/trigger is released
await self._trigger_release_event.wait()
callback(EV_KEY, code, 0)
await self.keycode_pause()
async def throttle(self) -> None:
# In case the keycode_pause ist set to 0ms, we need to give the event handlers
# a chance to inject the withheld events, before we go on. This ensures the
# correct order of injections. Since we are using asyncio, something like
# `callback(EV_KEY, code, 0)` might be faster than the event handlers, even if
# it is the last step of the macro.
if self.mapping.macro_key_sleep_ms == 0:
await asyncio.sleep(0.01)

View File

@ -0,0 +1,57 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class ModifyTask(Task):
"""Do stuff while a modifier is activated."""
argument_configs = [
ArgumentConfig(
name="modifier",
position=0,
types=[str],
is_symbol=True,
),
ArgumentConfig(
name="macro",
position=1,
types=[Macro],
),
]
async def run(self, callback) -> None:
modifier = self.get_argument("modifier").get_value()
code = keyboard_layout.get(modifier)
macro = self.get_argument("macro").get_value()
callback(EV_KEY, code, 1)
await self.keycode_pause()
await macro.run(callback)
callback(EV_KEY, code, 0)
await self.keycode_pause()

View File

@ -0,0 +1,69 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from evdev._ecodes import REL_Y, REL_X
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import InjectEventCallback
from inputremapper.injection.macros.tasks.mouse_xy import MouseXYTask
class MouseTask(MouseXYTask):
"""Move the mouse cursor."""
argument_configs = [
ArgumentConfig(
name="direction",
position=0,
types=[str],
),
ArgumentConfig(
name="speed",
position=1,
types=[int, float],
),
ArgumentConfig(
name="acceleration",
position=2,
types=[int, float],
default=1,
),
]
async def run(self, callback: InjectEventCallback) -> None:
direction = self.get_argument("direction").get_value()
speed = self.get_argument("speed").get_value()
acceleration = self.get_argument("acceleration").get_value()
code, direction = {
"up": (REL_Y, -1),
"down": (REL_Y, 1),
"left": (REL_X, -1),
"right": (REL_X, 1),
}[direction.lower()]
await self.axis(
code,
direction * speed,
acceleration,
callback,
)

View File

@ -0,0 +1,99 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import asyncio
from typing import Union
from evdev._ecodes import REL_Y, REL_X
from evdev.ecodes import EV_REL
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import InjectEventCallback
from inputremapper.injection.macros.task import Task
from inputremapper.injection.macros.tasks.util import precise_iteration_frequency
class MouseXYTask(Task):
"""Move the mouse cursor."""
argument_configs = [
ArgumentConfig(
name="x",
position=0,
types=[int, float],
default=0,
),
ArgumentConfig(
name="y",
position=1,
types=[int, float],
default=0,
),
ArgumentConfig(
name="acceleration",
position=2,
types=[int, float],
default=1,
),
]
async def run(self, callback: InjectEventCallback) -> None:
x = self.get_argument("x").get_value()
y = self.get_argument("y").get_value()
acceleration = self.get_argument("acceleration").get_value()
await asyncio.gather(
self.axis(REL_X, x, acceleration, callback),
self.axis(REL_Y, y, acceleration, callback),
)
async def axis(
self,
code: int,
speed: Union[int, float],
fractional_acceleration: Union[int, float],
callback: InjectEventCallback,
) -> None:
acceleration = speed * fractional_acceleration
direction = -1 if speed < 0 else 1
current_speed = 0.0
displacement_accumulator = 0.0
displacement = 0
if acceleration <= 0:
displacement = int(speed)
async for _ in precise_iteration_frequency(self.mapping.rel_rate):
if not self.is_holding():
return
# Cursors can only move by integers. To get smooth acceleration for
# small acceleration values, the cursor needs to move by a pixel every
# few iterations. This can be achieved by remembering the decimal
# places that were cast away, and using them for the next iteration.
if acceleration:
current_speed += acceleration
current_speed = direction * min(abs(current_speed), abs(speed))
displacement_accumulator += current_speed
displacement = int(displacement_accumulator)
displacement_accumulator -= displacement
if displacement != 0:
callback(EV_REL, code, displacement)

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import asyncio
from typing import List
from inputremapper.injection.macros.argument import ArgumentConfig, ArgumentFlags
from inputremapper.injection.macros.macro import Macro, InjectEventCallback
from inputremapper.injection.macros.task import Task
class ParallelTask(Task):
"""Run all provided macros in parallel."""
argument_configs = [
ArgumentConfig(
name="*macros",
position=ArgumentFlags.spread,
types=[Macro],
),
]
async def run(self, callback: InjectEventCallback) -> None:
macros: List[Macro] = self.get_argument("*macros").get_values()
coroutines = [macro.run(callback) for macro in macros]
await asyncio.gather(*coroutines)

View File

@ -0,0 +1,49 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class RepeatTask(Task):
"""Repeat macros."""
argument_configs = [
ArgumentConfig(
name="repeats",
position=0,
types=[int],
),
ArgumentConfig(
name="macro",
position=1,
types=[Macro],
),
]
async def run(self, callback) -> None:
repeats = self.get_argument("repeats").get_value()
macro = self.get_argument("macro").get_value()
for _ in range(repeats):
await macro.run(callback)

View File

@ -0,0 +1,49 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import macro_variables
from inputremapper.injection.macros.task import Task
class SetTask(Task):
"""Set a variable to a certain value."""
argument_configs = [
ArgumentConfig(
name="variable",
position=0,
types=[str, float, int, None],
is_variable_name=True,
),
ArgumentConfig(
name="value",
position=1,
types=[str, float, int, None],
default=None,
),
]
async def run(self, callback) -> None:
value = self.get_argument("value").get_value()
assert macro_variables.is_alive()
self.arguments["variable"].set_value(value)

View File

@ -0,0 +1,56 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import asyncio
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.task import Task
class ToggleTask(Task):
"""Toggle macros."""
argument_configs = [
ArgumentConfig(
name="macro",
position=0,
types=[Macro],
),
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.trigger_count = 0
def press_trigger(self) -> None:
Task.press_trigger(self)
self.trigger_count += 1
async def run(self, callback) -> None:
macro = self.get_argument("macro").get_value()
while self.trigger_count <= 1:
await macro.run(callback)
await asyncio.sleep(1 / 1000)
self.trigger_count = 0

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import asyncio
import time
from typing import AsyncIterator
async def precise_iteration_frequency(frequency: float) -> AsyncIterator[None]:
"""A generator to iterate over in a fixed frequency.
asyncio.sleep might end up sleeping too long, for whatever reason. Maybe there are
other async function calls that take longer than expected in the background.
"""
sleep = 1 / frequency
corrected_sleep = sleep
error = 0.0
while True:
start = time.time()
yield
corrected_sleep -= error
await asyncio.sleep(corrected_sleep)
error = (time.time() - start) - sleep

View File

@ -0,0 +1,54 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import asyncio
import random
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
class WaitTask(Task):
"""Wait time in milliseconds."""
argument_configs = [
ArgumentConfig(
name="time",
position=0,
types=[float, int],
),
ArgumentConfig(
name="max_time",
position=1,
types=[float, int, None],
default=None,
),
]
async def run(self, callback) -> None:
time = self.get_argument("time").get_value()
max_time = self.get_argument("max_time").get_value()
if max_time is not None and max_time > time:
time = random.uniform(time, max_time)
await asyncio.sleep(time / 1000)

View File

@ -0,0 +1,76 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import math
from evdev.ecodes import (
EV_REL,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
REL_WHEEL,
REL_HWHEEL,
)
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.task import Task
from inputremapper.injection.macros.tasks.util import precise_iteration_frequency
class WheelTask(Task):
"""Move the scroll wheel."""
argument_configs = [
ArgumentConfig(
name="direction",
position=0,
types=[str],
),
ArgumentConfig(
name="speed",
position=1,
types=[int, float],
),
]
async def run(self, callback) -> None:
direction = self.get_argument("direction").get_value()
# 120, see https://www.kernel.org/doc/html/latest/input/event-codes.html#ev-rel
code, value = {
"up": ([REL_WHEEL, REL_WHEEL_HI_RES], [1 / 120, 1]),
"down": ([REL_WHEEL, REL_WHEEL_HI_RES], [-1 / 120, -1]),
"left": ([REL_HWHEEL, REL_HWHEEL_HI_RES], [1 / 120, 1]),
"right": ([REL_HWHEEL, REL_HWHEEL_HI_RES], [-1 / 120, -1]),
}[direction.lower()]
speed = self.get_argument("speed").get_value()
remainder = [0.0, 0.0]
async for _ in precise_iteration_frequency(self.mapping.rel_rate):
if not self.is_holding():
return
for i in range(0, 2):
float_value = value[i] * speed + remainder[i]
remainder[i] = math.fmod(float_value, 1)
if abs(float_value) >= 1:
callback(EV_REL, code[i], int(float_value))

View File

@ -0,0 +1,90 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import re
from typing import Any
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.macro import macro_variables
class Variable:
"""Something that the user passed into a macro function as parameter.
The value should already be parsed and validated, if const=True, according to the
argument_configs of a given Task.
Examples:
- const string "KEY_A" in `key(KEY_A)`
- non-const string "foo" in `repeat($foo, key(KEY_A))` (`value` is the name here)
- const Macro `key(a)` in `repeat(1, key(a))`
- const int 1 in `repeat(1, key(a))
"""
def __init__(self, value: Any, const: bool) -> None:
if not const and not isinstance(value, str):
raise MacroError(f"Variables require a string name, not {value}")
self.value = value
self.const = const
if not const:
self.validate_variable_name()
def get_name(self) -> str:
"""If the variable is not const, return its name."""
assert not self.const
assert isinstance(self.value, str)
return self.value
def get_value(self) -> Any:
"""Get the variables value from the common variable storage process."""
if self.const:
return self.value
return macro_variables.get(self.value)
def set_value(self, value: Any) -> None:
"""Set the variables value across all macros."""
assert not self.const
macro_variables[self.value] = value
def validate_variable_name(self) -> None:
"""Check if this is a legit variable name.
Because they could clash with language features. If the macro can be
parsed at all due to a problematic choice of a variable name.
Allowed examples: "foo", "Foo1234_", "_foo_1234"
Not allowed: "1_foo", "foo=blub", "$foo", "foo,1234", "foo()"
"""
if not isinstance(self.value, str) or not re.match(
r"^[A-Za-z_][A-Za-z_0-9]*$", self.value
):
raise MacroError(msg=f'"{self.value}" is not a legit variable name')
def __repr__(self) -> str:
return f'<Variable "{self.value}" const={self.const} at {hex(id(self))}>'
def __eq__(self, other) -> bool:
if not isinstance(other, Variable):
return False
return self.const == other.const and self.value == other.value