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

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

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

View File

View File

View File

@ -0,0 +1,429 @@
# -*- 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/>.
"""Control the dbus service from the command line."""
import argparse
import logging
import os
import subprocess
import sys
from enum import Enum
from typing import Optional
import gi
gi.require_version("GLib", "2.0")
from gi.repository import GLib
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.configs.migrations import Migrations
from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput
from inputremapper.logging.logger import logger
from inputremapper.user import UserUtils
class Commands(Enum):
AUTOLOAD = "autoload"
START = "start"
STOP = "stop"
STOP_ALL = "stop-all"
HELLO = "hello"
QUIT = "quit"
class Internals(Enum):
# internal stuff that the gui uses
START_DAEMON = "start-daemon"
START_READER_SERVICE = "start-reader-service"
class Options:
command: str
config_dir: str
preset: str
device: str
list_devices: bool
key_names: str
debug: bool
version: str
class InputRemapperControlBin:
def __init__(
self,
global_config: GlobalConfig,
migrations: Migrations,
):
self.global_config = global_config
self.migrations = migrations
@staticmethod
def main(options: Options) -> None:
global_config = GlobalConfig()
global_uinputs = GlobalUInputs(FrontendUInput)
migrations = Migrations(global_uinputs)
input_remapper_control = InputRemapperControlBin(
global_config,
migrations,
)
if options.debug:
logger.update_verbosity(True)
if options.version:
logger.log_info()
return
logger.debug('Call for "%s"', sys.argv)
boot_finished_ = input_remapper_control.boot_finished()
is_root = UserUtils.user == "root"
is_autoload = options.command == Commands.AUTOLOAD
config_dir_set = options.config_dir is not None
if is_autoload and not boot_finished_ and is_root and not config_dir_set:
# this is probably happening during boot time and got
# triggered by udev. There is no need to try to inject anything if the
# service doesn't know where to look for a config file. This avoids a lot
# of confusing service logs. And also avoids potential for problems when
# input-remapper-control stresses about evdev, dbus and multiprocessing already
# while the system hasn't even booted completely.
logger.warning("Skipping autoload command without a logged in user")
return
if options.command is not None:
if options.command in [command.value for command in Internals]:
input_remapper_control.internals(options.command, options.debug)
elif options.command in [command.value for command in Commands]:
from inputremapper.daemon import Daemon
daemon = Daemon.connect(fallback=False)
input_remapper_control.set_daemon(daemon)
input_remapper_control.communicate(
options.command,
options.device,
options.config_dir,
options.preset,
)
else:
logger.error('Unknown command "%s"', options.command)
else:
if options.list_devices:
input_remapper_control.list_devices()
if options.key_names:
input_remapper_control.list_key_names()
if options.command:
logger.info("Done")
def list_devices(self):
logger.setLevel(logging.ERROR)
from inputremapper.groups import groups
for group in groups.get_groups():
print(group.key)
def list_key_names(self):
from inputremapper.configs.keyboard_layout import keyboard_layout
print("\n".join(keyboard_layout.list_names()))
def communicate(
self,
command: str,
device: str,
config_dir: Optional[str],
preset: str,
) -> None:
"""Commands that require a running daemon."""
if self.daemon is None:
# probably broken tests
logger.error("Daemon missing")
sys.exit(5)
if config_dir is not None:
self._load_config(config_dir)
self.ensure_migrated()
if command == Commands.AUTOLOAD.value:
self._autoload(device)
if command == Commands.START.value:
self._start(device, preset)
if command == Commands.STOP.value:
self._stop(device)
if command == Commands.STOP_ALL.value:
self.daemon.stop_all()
if command == Commands.HELLO.value:
self._hello()
if command == Commands.QUIT.value:
self._quit()
def _hello(self):
response = self.daemon.hello("hello")
logger.info('Daemon answered with "%s"', response)
def _load_config(self, config_dir: str) -> None:
path = os.path.abspath(
os.path.expanduser(os.path.join(config_dir, "config.json"))
)
if not os.path.exists(path):
logger.error('"%s" does not exist', path)
sys.exit(6)
logger.info('Using config from "%s" instead', path)
self.global_config.load_config(path)
def ensure_migrated(self) -> None:
# import stuff late to make sure the correct log level is applied
# before anything is logged
# TODO since imports shouldn't run any code, this is fixed by moving towards DI
from inputremapper.user import UserUtils
if UserUtils.user != "root":
# Might be triggered by udev, so skip the root user.
# This will also refresh the config of the daemon if the user changed
# it in the meantime.
# config_dir is either the cli arg or the default path in home
config_dir = os.path.dirname(self.global_config.path)
self.daemon.set_config_dir(config_dir)
self.migrations.migrate()
def _stop(self, device: str) -> None:
group = self._load_group(device)
self.daemon.stop_injecting(group.key)
def _quit(self) -> None:
try:
self.daemon.quit()
except GLib.GError as error:
if "NoReply" in str(error):
# The daemon is expected to terminate, so there won't be a reply.
return
raise
def _start(self, device: str, preset: str) -> None:
group = self._load_group(device)
logger.info(
'Starting injection: "%s", "%s"',
device,
preset,
)
self.daemon.start_injecting(group.key, preset)
def _load_group(self, device: str):
"""Load groups, check if a group exists for the given device and return it.
Crash if it can't be found.
device can be either a path like "/dev/input/event1" or a group key like
"USB Optical Mouse",
"""
# import stuff late to make sure the correct log level is applied
# before anything is logged
# TODO since imports shouldn't run any code, this is fixed by moving towards DI
from inputremapper.groups import groups
if device is None:
logger.error("--device missing")
sys.exit(3)
# groups.find would also refresh it, but explicit is better than implicit
groups.refresh()
if device.startswith("/dev"):
group = groups.find(path=device)
else:
group = groups.find(key=device)
if group is None:
logger.error(
'Device "%s" is unknown, not an appropriate input device or you are '
"missing permissions",
device,
)
sys.exit(4)
return group
def _autoload(self, device: str) -> None:
# if device was specified, autoload for that one. if None autoload
# for all devices.
if device is None:
logger.info("Autoloading all")
self.daemon.autoload(timeout=10000)
else:
group = self._load_group(device)
logger.info(
'Asking daemon to autoload for device "%s", group "%s"',
device,
group.key,
)
self.daemon.autoload_single(group.key, timeout=2000)
def internals(self, command: str, debug: bool) -> None:
"""Methods that are needed to get the gui to work and that require root.
input-remapper-control should be started with sudo or pkexec for this.
"""
debug_flag = " -d" if debug else ""
if command == Internals.START_READER_SERVICE.value:
cmd = f"input-remapper-reader-service{debug_flag}"
elif command == Internals.START_DAEMON.value:
cmd = f"input-remapper-service --hide-info{debug_flag}"
else:
return
# daemonize
cmd = f"{cmd} &"
logger.debug(f"Running `{cmd}`")
os.system(cmd)
def _num_logged_in_users(self) -> int:
"""Check how many users are logged in."""
who = subprocess.run(["who"], stdout=subprocess.PIPE).stdout.decode()
return len([user for user in who.split("\n") if user.strip() != ""])
def _is_systemd_finished(self) -> bool:
"""Check if systemd finished booting."""
try:
systemd_analyze = subprocess.run(
["systemd-analyze"], stdout=subprocess.PIPE
)
except FileNotFoundError:
# probably not systemd, lets assume true to not block input-remapper for good
# on certain installations
return True
if "finished" in systemd_analyze.stdout.decode():
# it writes into stderr otherwise or something
return True
return False
def boot_finished(self) -> bool:
"""Check if booting is completed."""
# Get as much information as needed to really safely determine if booting up is
# complete.
# - `who` returns an empty list on some system for security purposes
# - something might be broken and might make systemd_analyze fail:
# Bootup is not yet finished
# (org.freedesktop.systemd1.Manager.FinishTimestampMonotonic=0).
# Please try again later.
# Hint: Use 'systemctl list-jobs' to see active jobs
if self._is_systemd_finished():
logger.debug("System is booted")
return True
if self._num_logged_in_users() > 0:
logger.debug("User(s) logged in")
return True
return False
def set_daemon(self, daemon):
# TODO DI?
self.daemon = daemon
@staticmethod
def parse_args() -> Options:
parser = argparse.ArgumentParser()
parser.add_argument(
"--command",
action="store",
dest="command",
help=(
"Communicate with the daemon. Available commands are "
f"{', '.join([command.value for command in Commands])}"
),
default=None,
metavar="NAME",
)
parser.add_argument(
"--config-dir",
action="store",
dest="config_dir",
help=(
"path to the config directory containing config.json, "
"xmodmap.json and the presets folder. "
"defaults to ~/.config/input-remapper/"
),
default=None,
metavar="PATH",
)
parser.add_argument(
"--preset",
action="store",
dest="preset",
help="The filename of the preset without the .json extension.",
default=None,
metavar="NAME",
)
parser.add_argument(
"--device",
action="store",
dest="device",
help="One of the device keys from --list-devices",
default=None,
metavar="NAME",
)
parser.add_argument(
"--list-devices",
action="store_true",
dest="list_devices",
help="List available device keys and exit",
default=False,
)
parser.add_argument(
"--symbol-names",
action="store_true",
dest="key_names",
help="Print all available names for the preset",
default=False,
)
parser.add_argument(
"-d",
"--debug",
action="store_true",
dest="debug",
help="Displays additional debug information",
default=False,
)
parser.add_argument(
"-v",
"--version",
action="store_true",
dest="version",
help="Print the version and exit",
default=False,
)
return parser.parse_args(sys.argv[1:]) # type: ignore

View File

@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Starts the user interface."""
from __future__ import annotations
import atexit
import sys
from argparse import ArgumentParser
from typing import Tuple
import gi
from inputremapper.bin.process_utils import ProcessUtils
gi.require_version("Gtk", "3.0")
gi.require_version("GLib", "2.0")
gi.require_version("GtkSource", "4")
from gi.repository import Gtk
# https://github.com/Nuitka/Nuitka/issues/607#issuecomment-650217096
Gtk.init()
from inputremapper.gui.gettext import _, LOCALE_DIR
from inputremapper.gui.reader_service import ReaderService
from inputremapper.daemon import DaemonProxy, Daemon
from inputremapper.logging.logger import logger
from inputremapper.gui.messages.message_broker import MessageBroker, MessageType
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.gui.data_manager import DataManager
from inputremapper.gui.user_interface import UserInterface
from inputremapper.gui.controller import Controller
from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput
from inputremapper.groups import _Groups
from inputremapper.gui.reader_client import ReaderClient
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.configs.migrations import Migrations
class InputRemapperGtkBin:
@staticmethod
def main() -> Tuple[
UserInterface,
Controller,
DataManager,
MessageBroker,
DaemonProxy,
GlobalConfig,
]:
parser = ArgumentParser()
parser.add_argument(
"-d",
"--debug",
action="store_true",
dest="debug",
help=_("Displays additional debug information"),
default=False,
)
options = parser.parse_args(sys.argv[1:])
logger.update_verbosity(options.debug)
logger.log_info("input-remapper-gtk")
logger.debug("Using locale directory: {}".format(LOCALE_DIR))
global_uinputs = GlobalUInputs(FrontendUInput)
migrations = Migrations(global_uinputs)
migrations.migrate()
message_broker = MessageBroker()
global_config = GlobalConfig()
# Create the ReaderClient before we start the reader-service, otherwise the
# privileged service creates and owns those pipes, and then they cannot be accessed
# by the user.
reader_client = ReaderClient(message_broker, _Groups())
if ProcessUtils.count_python_processes("input-remapper-gtk") >= 2:
logger.warning(
"Another input-remapper GUI is already running. "
"This can cause problems while recording keys"
)
InputRemapperGtkBin.start_reader_service()
daemon = Daemon.connect()
data_manager = DataManager(
message_broker,
global_config,
reader_client,
daemon,
global_uinputs,
keyboard_layout,
)
controller = Controller(message_broker, data_manager)
user_interface = UserInterface(message_broker, controller)
controller.set_gui(user_interface)
message_broker.signal(MessageType.init)
atexit.register(lambda: InputRemapperGtkBin.stop(daemon, controller))
Gtk.main()
# For tests:
return (
user_interface,
controller,
data_manager,
message_broker,
daemon,
global_config,
)
@staticmethod
def start_reader_service():
if ProcessUtils.count_python_processes("input-remapper-reader-service") >= 1:
logger.info("Found an input-remapper-reader-service to already be running")
return
try:
ReaderService.pkexec_reader_service()
except Exception as e:
logger.error(e)
sys.exit(11)
@staticmethod
def stop(daemon, controller):
if isinstance(daemon, Daemon):
# have fun debugging completely unrelated tests if you remove this
daemon.stop_all()
controller.close()

View File

@ -0,0 +1,80 @@
# -*- 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/>.
"""Starts the root reader-service."""
import asyncio
import atexit
import os
import signal
import sys
import time
from argparse import ArgumentParser
from inputremapper.bin.process_utils import ProcessUtils
from inputremapper.groups import _Groups
from inputremapper.gui.reader_service import ReaderService
from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput
from inputremapper.logging.logger import logger
class InputRemapperReaderServiceBin:
@staticmethod
def main() -> None:
parser = ArgumentParser()
parser.add_argument(
"-d",
"--debug",
action="store_true",
dest="debug",
help="Displays additional debug information",
default=False,
)
options = parser.parse_args(sys.argv[1:])
logger.update_verbosity(options.debug)
if ProcessUtils.count_python_processes("input-remapper-reader-service") >= 2:
logger.warning(
"Another input-remapper-reader-service process is already running. "
"This can cause problems while recording keys"
)
if os.getuid() != 0:
logger.warning("The reader-service usually needs elevated privileges")
if not ReaderService.pipes_exist():
logger.info("Waiting for pipes to be created by the GUI")
while not ReaderService.pipes_exist():
time.sleep(0.5)
logger.debug("Waiting...")
def on_exit():
"""Don't remain idle and alive when the GUI exits via ctrl+c."""
# makes no sense to me, but after the keyboard interrupt it is still
# waiting for an event to complete (`S` in `ps ax`), even when using
# sys.exit
os.kill(os.getpid(), signal.SIGKILL)
atexit.register(on_exit)
groups = _Groups()
global_uinputs = GlobalUInputs(FrontendUInput)
reader_service = ReaderService(groups, global_uinputs)
asyncio.run(reader_service.run())

View File

@ -0,0 +1,71 @@
# -*- 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/>.
"""Starts injecting keycodes based on the configuration."""
import sys
import multiprocessing
from argparse import ArgumentParser
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.injection.global_uinputs import GlobalUInputs, UInput
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
from inputremapper.logging.logger import logger
class InputRemapperServiceBin:
@staticmethod
def main() -> None:
parser = ArgumentParser()
parser.add_argument(
"-d",
"--debug",
action="store_true",
dest="debug",
help="Displays additional debug information",
default=False,
)
parser.add_argument(
"--hide-info",
action="store_true",
dest="hide_info",
help="Don't display version information",
default=False,
)
options = parser.parse_args(sys.argv[1:])
# Python 3.14 compatibility
multiprocessing.set_start_method("fork")
logger.update_verbosity(options.debug)
# import input-remapper stuff after setting the log verbosity
from inputremapper.daemon import Daemon
if not options.hide_info:
logger.log_info("input-remapper-service")
global_config = GlobalConfig()
global_uinputs = GlobalUInputs(UInput)
mapping_parser = MappingParser(global_uinputs)
daemon = Daemon(global_config, global_uinputs, mapping_parser)
daemon.publish()
daemon.run()

View File

@ -0,0 +1,39 @@
# -*- 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 psutil
class ProcessUtils:
@staticmethod
def count_python_processes(name: str) -> int:
# This is somewhat complicated, because there might also be a "sudo <name>"
# process.
count = 0
pids = psutil.pids()
for pid in pids:
try:
process = psutil.Process(pid)
cmdline = process.cmdline()
if len(cmdline) >= 2 and "python" in cmdline[0] and name in cmdline[1]:
count += 1
except Exception:
pass
return count

View File

View File

@ -0,0 +1,33 @@
# -*- 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/>.
"""Get stuff from /usr/share/input-remapper, depending on the prefix."""
import os
from inputremapper.installation_info import DATA_DIR
def get_data_path(filename=""):
"""Depending on the installation prefix, return the data dir."""
# This was more complicated in the past. This wrapper is kept for now, but feel
# free to remove it at a later point.
return os.path.join(DATA_DIR, filename)

View File

@ -0,0 +1,142 @@
# -*- 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/>.
"""Store which presets should be enabled for which device on login."""
from __future__ import annotations
import copy
import json
import os
from typing import Optional
from inputremapper.configs.paths import PathUtils
from inputremapper.logging.logger import logger, VERSION
from inputremapper.user import UserUtils
MOUSE = "mouse"
WHEEL = "wheel"
BUTTONS = "buttons"
NONE = "none"
INITIAL_CONFIG = {
"version": VERSION,
"autoload": {},
}
class GlobalConfig:
"""Configures stuff like autoloading in ~/.config/input-remapper-2/config.json."""
def __init__(self):
self.path = os.path.join(PathUtils.config_path(), "config.json")
self._config = copy.deepcopy(INITIAL_CONFIG)
def get_dir(self) -> str:
"""The folder containing this config."""
return os.path.split(self.path)[0]
def get_autoload_preset(self, group_key: str) -> Optional[str]:
# modifications are only allowed via the setter, because it needs to write
# the config file too. Therefore return a copy to prevent inconsistencies.
return copy.deepcopy(self._config["autoload"].get(group_key))
def set_autoload_preset(self, group_key: str, preset: Optional[str]):
"""Set a preset to be automatically applied on start.
Parameters
----------
group_key
the unique identifier of the group. This is used instead of the
name to enable autoloading two different presets when two similar
devices are connected.
preset
if None, don't autoload something for this device.
"""
if preset is not None:
self._config["autoload"][group_key] = preset
else:
logger.info('Not injecting for "%s" automatically anmore', group_key)
del self._config["autoload"][group_key]
self._save_config()
def iterate_autoload_presets(self):
"""Get tuples of (device, preset)."""
return self._config.get("autoload", {}).items()
def is_autoloaded(self, group_key: Optional[str], preset: Optional[str]):
"""Should this preset be loaded automatically?"""
if group_key is None or preset is None:
raise ValueError("Expected group_key and preset to not be None")
return self._config.get("autoload", {}).get(group_key) == preset
def load_config(self, path: Optional[str] = None):
"""Load the config from the file system.
Parameters
----------
path
If set, will change the path to load from and save to.
"""
if path is not None:
if not os.path.exists(path):
logger.error('Config at "%s" not found', path)
return
self.path = path
self._clear_config()
if not os.path.exists(self.path):
# treated like an empty config
logger.debug('Config "%s" doesn\'t exist yet', self.path)
self._clear_config()
self._config = copy.deepcopy(INITIAL_CONFIG)
self._save_config()
return
with open(self.path, "r") as file:
try:
self._config.update(json.load(file))
logger.info('Loaded config from "%s"', self.path)
except json.decoder.JSONDecodeError as error:
logger.error(
'Failed to parse config "%s": %s. Using defaults',
self.path,
str(error),
)
# uses the default configuration when the config object
# is empty automatically
def _save_config(self):
"""Save the config to the file system."""
if UserUtils.user == "root":
logger.debug("Skipping config file creation for the root user")
return
PathUtils.touch(self.path)
with open(self.path, "w") as file:
json.dump(self._config, file, indent=4)
logger.info("Saved config to %s", self.path)
file.write("\n")
def _clear_config(self):
"""Remove all configurations in memory."""
self._config = {}

View File

@ -0,0 +1,485 @@
# -*- 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 itertools
from typing import Tuple, Iterable, Union, List, Dict, Optional, Hashable
from evdev import ecodes
from inputremapper.configs.paths import PathUtils
from inputremapper.input_event import InputEvent
try:
from pydantic.v1 import BaseModel, root_validator, validator
except ImportError:
from pydantic import BaseModel, root_validator, validator
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.gui.messages.message_types import MessageType
from inputremapper.logging.logger import logger
from inputremapper.utils import get_evdev_constant_name, DeviceHash
# having shift in combinations modifies the configured output,
# ctrl might not work at all
DIFFICULT_COMBINATIONS = [
ecodes.KEY_LEFTSHIFT,
ecodes.KEY_RIGHTSHIFT,
ecodes.KEY_LEFTCTRL,
ecodes.KEY_RIGHTCTRL,
ecodes.KEY_LEFTALT,
ecodes.KEY_RIGHTALT,
]
EMPTY_TYPE = 99
# "Button 11" (10 + 0x110 "BTN_LEFT") is the highest mouse button simulatable
# at time of writing using Piper 0.8 and a Logi G502X.
# Please update if a way to simulate higher button-presses is found.
MAX_BTN_MOUSE_ECODE = 0x11A
# Beware, this is without sign! Movement to the left needs this to be negative
DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE = 30
class InputConfig(BaseModel):
"""Describes a single input within a combination, to configure mappings."""
message_type = MessageType.selected_event
type: int
code: int
# origin_hash is a hash to identify a specific /dev/input/eventXX device.
# This solves a number of bugs when multiple devices have overlapping capabilities.
# see utils.get_device_hash for the exact hashing function
origin_hash: Optional[DeviceHash] = None
# At which point is an analog input treated as "pressed". In percent (-99 to 99)
# of the delta between a resting joystick and a maxed-out joystick.
# Should be None if no analog input is configured.
analog_threshold: Optional[int] = None
def __str__(self):
return f"InputConfig {get_evdev_constant_name(self.type, self.code)}"
def __repr__(self):
return (
f"<InputConfig {self.type_and_code} "
f"{get_evdev_constant_name(*self.type_and_code)}, "
f"{self.analog_threshold}, "
f"{self.origin_hash}, "
f"at {hex(id(self))}>"
)
@property
def input_match_hash(self) -> Hashable:
"""a Hashable object which is intended to match the InputConfig with a
InputEvent.
InputConfig itself is hashable, but can not be used to match InputEvent's
because its hash includes the analog_threshold
"""
return self.type, self.code, self.origin_hash
@property
def is_empty(self) -> bool:
return self.type == EMPTY_TYPE
@property
def defines_analog_input(self) -> bool:
"""Whether this defines an analog input."""
return not self.analog_threshold and self.type != ecodes.EV_KEY
@property
def type_and_code(self) -> Tuple[int, int]:
"""Event type, code."""
return self.type, self.code
@classmethod
def btn_left(cls):
return cls(type=ecodes.EV_KEY, code=ecodes.BTN_LEFT)
@classmethod
def from_input_event(cls, event: InputEvent) -> InputConfig:
"""create an input confing from the given InputEvent, uses the value as
analog threshold"""
return cls(
type=event.type,
code=event.code,
origin_hash=event.origin_hash,
analog_threshold=event.value,
)
def description(self, exclude_threshold=False, exclude_direction=False) -> str:
"""Get a human-readable description of the event."""
return (
f"{self._get_name()} "
f"{self._get_direction() if not exclude_direction else ''} "
f"{self._get_threshold_value() if not exclude_threshold else ''}".strip()
)
def _get_mouse_button_name(self) -> Optional[str]:
"""Get a human-readable description of a mouse-button. Only the first 7
mouse buttons are in evdev and they often have misleading names there
(eg it calls buttons 6 & 7 forward/back but usually that's buttons 5 & 4).
Returns None if not a mouse button."""
if self.type == ecodes.EV_KEY:
if ecodes.BTN_MOUSE <= self.code <= ecodes.BTN_MIDDLE:
# button is left/right/middle button
key_name: str = get_evdev_constant_name(self.type, self.code)
return key_name.replace(
"BTN_", "Mouse Button "
) # eg "Mouse Button LEFT"
elif ecodes.BTN_MIDDLE < self.code <= MAX_BTN_MOUSE_ECODE:
# button is a higher-number mouse button like side-buttons.
# This calculation assumes left mouse button is button 1, so side buttons start at 4.
button_number: int = self.code - ecodes.BTN_MOUSE + 1
return f"Mouse Button {button_number}" # eg "Mouse Button 7"
return None
def _get_name(self) -> Optional[str]:
"""Human-readable name (e.g. KEY_A) of the specified input event."""
# prevent logging warnings for new/empty configs
if self.is_empty:
return None
# must check if it's a mouse button *before* ecodes
# because not all mouse buttons are in ecodes.
mouse_button_name: Optional[str] = self._get_mouse_button_name()
if mouse_button_name is not None:
return mouse_button_name
if self.type not in ecodes.bytype:
logger.warning("Unknown type for %s", self)
return f"unknown {self.type, self.code}"
if self.code not in ecodes.bytype[self.type]:
logger.warning("Unknown code for %s", self)
return f"unknown {self.type, self.code}"
key_name = None
# first try to find the name in xmodmap to not display wrong
# names due to the keyboard layout
if self.type == ecodes.EV_KEY:
key_name = keyboard_layout.get_name(self.code)
if key_name is None:
# if no result, look in the linux combination constants. On a german
# keyboard for example z and y are switched, which will therefore
# cause the wrong letter to be displayed.
key_name = get_evdev_constant_name(self.type, self.code)
key_name = key_name.replace("ABS_Z", "Trigger Left")
key_name = key_name.replace("ABS_RZ", "Trigger Right")
key_name = key_name.replace("ABS_HAT0X", "DPad-X")
key_name = key_name.replace("ABS_HAT0Y", "DPad-Y")
key_name = key_name.replace("ABS_HAT1X", "DPad-2-X")
key_name = key_name.replace("ABS_HAT1Y", "DPad-2-Y")
key_name = key_name.replace("ABS_HAT2X", "DPad-3-X")
key_name = key_name.replace("ABS_HAT2Y", "DPad-3-Y")
key_name = key_name.replace("ABS_X", "Joystick-X")
key_name = key_name.replace("ABS_Y", "Joystick-Y")
key_name = key_name.replace("ABS_RX", "Joystick-RX")
key_name = key_name.replace("ABS_RY", "Joystick-RY")
key_name = key_name.replace("BTN_", "Button ")
key_name = key_name.replace("KEY_", "")
key_name = key_name.replace("REL_", "")
key_name = key_name.replace("HWHEEL", "Wheel")
key_name = key_name.replace("WHEEL", "Wheel")
key_name = key_name.replace("_", " ")
key_name = key_name.replace(" ", " ")
return key_name
def _get_direction(self) -> str:
"""human-readable direction description for the analog_threshold"""
if self.type == ecodes.EV_KEY or self.defines_analog_input:
return ""
assert self.analog_threshold
threshold_direction = self.analog_threshold // abs(self.analog_threshold)
return {
# D-Pad
(ecodes.ABS_HAT0X, -1): "Left",
(ecodes.ABS_HAT0X, 1): "Right",
(ecodes.ABS_HAT0Y, -1): "Up",
(ecodes.ABS_HAT0Y, 1): "Down",
(ecodes.ABS_HAT1X, -1): "Left",
(ecodes.ABS_HAT1X, 1): "Right",
(ecodes.ABS_HAT1Y, -1): "Up",
(ecodes.ABS_HAT1Y, 1): "Down",
(ecodes.ABS_HAT2X, -1): "Left",
(ecodes.ABS_HAT2X, 1): "Right",
(ecodes.ABS_HAT2Y, -1): "Up",
(ecodes.ABS_HAT2Y, 1): "Down",
# joystick
(ecodes.ABS_X, 1): "Right",
(ecodes.ABS_X, -1): "Left",
(ecodes.ABS_Y, 1): "Down",
(ecodes.ABS_Y, -1): "Up",
(ecodes.ABS_RX, 1): "Right",
(ecodes.ABS_RX, -1): "Left",
(ecodes.ABS_RY, 1): "Down",
(ecodes.ABS_RY, -1): "Up",
# wheel
(ecodes.REL_WHEEL, -1): "Down",
(ecodes.REL_WHEEL, 1): "Up",
(ecodes.REL_HWHEEL, -1): "Left",
(ecodes.REL_HWHEEL, 1): "Right",
}.get((self.code, threshold_direction)) or (
"+" if threshold_direction > 0 else "-"
)
def _get_threshold_value(self) -> str:
"""human-readable value of the analog_threshold e.g. '20%'"""
if self.analog_threshold is None:
return ""
return {
ecodes.EV_REL: f"{abs(self.analog_threshold)}",
ecodes.EV_ABS: f"{abs(self.analog_threshold)}%",
}.get(self.type) or ""
def modify(
self,
type_: Optional[int] = None,
code: Optional[int] = None,
origin_hash: Optional[str] = None,
analog_threshold: Optional[int] = None,
) -> InputConfig:
"""Return a new modified event."""
return InputConfig(
type=type_ if type_ is not None else self.type,
code=code if code is not None else self.code,
origin_hash=origin_hash if origin_hash is not None else self.origin_hash,
analog_threshold=(
analog_threshold
if analog_threshold is not None
else self.analog_threshold
),
)
def __hash__(self):
return hash((self.type, self.code, self.origin_hash, self.analog_threshold))
@validator("analog_threshold")
def _ensure_analog_threshold_is_none(cls, analog_threshold):
"""ensure the analog threshold is None, not zero."""
if analog_threshold == 0 or analog_threshold is None:
# the sign of the threshold defines the direction the joystick has to move.
# 0 is invalid, it has no sign. And for triggers, what does a threshold of
# 0 mean? Would it always be active?
return None
return analog_threshold
@root_validator
def _remove_analog_threshold_for_key_input(cls, values):
"""remove the analog threshold if the type is a EV_KEY"""
type_ = values.get("type")
if type_ == ecodes.EV_KEY:
values["analog_threshold"] = None
return values
@root_validator(pre=True)
def validate_origin_hash(cls, values):
origin_hash = values.get("origin_hash")
if origin_hash is None:
# For new presets, origin_hash should be set. For old ones, it can
# be still missing. A lot of tests didn't set an origin_hash.
if values.get("type") != EMPTY_TYPE:
logger.warning("No origin_hash set for %s", values)
return values
values["origin_hash"] = origin_hash.lower()
return values
class Config:
allow_mutation = False
underscore_attrs_are_private = True
InputCombinationInit = Union[
Iterable[Dict[str, Union[str, int]]],
Iterable[InputConfig],
]
class InputCombination(Tuple[InputConfig, ...]):
"""One or more InputConfigs used to trigger a mapping."""
# tuple is immutable, therefore we need to override __new__()
# https://jfine-python-classes.readthedocs.io/en/latest/subclass-tuple.html
def __new__(cls, configs: InputCombinationInit) -> InputCombination:
"""Create a new InputCombination.
Examples
--------
InputCombination([InputConfig, ...])
InputCombination([{type: ..., code: ..., value: ...}, ...])
"""
if not isinstance(configs, Iterable):
raise TypeError("InputCombination requires a list of InputConfigs.")
if isinstance(configs, InputConfig):
# wrap the argument in square brackets
raise TypeError("InputCombination requires a list of InputConfigs.")
validated_configs = []
for config in configs:
if isinstance(configs, InputEvent):
raise TypeError("InputCombinations require InputConfigs, not Events.")
if isinstance(config, InputConfig):
validated_configs.append(config)
elif isinstance(config, dict):
validated_configs.append(InputConfig(**config))
else:
raise TypeError(f'Can\'t handle "{config}"')
if len(validated_configs) == 0:
raise ValueError(f"failed to create InputCombination with {configs = }")
# mypy bug: https://github.com/python/mypy/issues/8957
# https://github.com/python/mypy/issues/8541
return super().__new__(cls, validated_configs) # type: ignore
def __str__(self):
return f'Combination ({" + ".join(str(event) for event in self)})'
def __repr__(self):
combination = ", ".join(repr(event) for event in self)
return f"<InputCombination ({combination}) at {hex(id(self))}>"
@classmethod
def __get_validators__(cls):
"""Used by pydantic to create InputCombination objects."""
yield cls.validate
@classmethod
def validate(cls, init_arg) -> InputCombination:
"""The only valid option is from_config"""
if isinstance(init_arg, InputCombination):
return init_arg
return cls(init_arg)
def to_config(self) -> Tuple[Dict[str, int], ...]:
"""Turn the object into a tuple of dicts."""
return tuple(input_config.dict(exclude_defaults=True) for input_config in self)
@classmethod
def empty_combination(cls) -> InputCombination:
"""A combination that has default invalid (to evdev) values.
Useful for the UI to indicate that this combination is not set
"""
return cls([{"type": EMPTY_TYPE, "code": 99, "analog_threshold": 99}])
@classmethod
def from_tuples(cls, *tuples):
"""Construct an InputCombination from (type, code, analog_threshold) tuples."""
dicts = []
for tuple_ in tuples:
if len(tuple_) == 3:
dicts.append(
{
"type": tuple_[0],
"code": tuple_[1],
"analog_threshold": tuple_[2],
}
)
elif len(tuple_) == 2:
dicts.append(
{
"type": tuple_[0],
"code": tuple_[1],
}
)
else:
raise TypeError
return cls(dicts)
def is_problematic(self) -> bool:
"""Is this combination going to work properly on all systems?"""
if len(self) <= 1:
return False
for input_config in self:
if input_config.type != ecodes.EV_KEY:
continue
if input_config.code in DIFFICULT_COMBINATIONS:
return True
return False
@property
def defines_analog_input(self) -> bool:
"""Check if there is any analog input in self."""
return True in tuple(i.defines_analog_input for i in self)
def find_analog_input_config(
self, type_: Optional[int] = None
) -> Optional[InputConfig]:
"""Return the first event that defines an analog input."""
for input_config in self:
if input_config.defines_analog_input and (
type_ is None or input_config.type == type_
):
return input_config
return None
def get_permutations(self) -> List[InputCombination]:
"""Get a list of EventCombinations representing all possible permutations.
combining a + b + c should have the same result as b + a + c.
Only the last combination remains the same in the returned result.
"""
if len(self) <= 2:
return [self]
if len(self) > 6:
logger.warning(
"Your input combination has a length of %d. Long combinations might "
'freeze the process. Edit the configuration files in "%s" to fix it.',
len(self),
PathUtils.get_config_path(),
)
permutations = []
for permutation in itertools.permutations(self[:-1]):
permutations.append(InputCombination((*permutation, self[-1])))
return permutations
def beautify(self) -> str:
"""Get a human-readable string representation."""
if self == InputCombination.empty_combination():
return "empty_combination"
return " + ".join(event.description(exclude_threshold=True) for event in self)

View File

@ -0,0 +1,221 @@
# -*- 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/>.
"""Make the systems/environments mapping of keys and codes accessible."""
import json
import re
import subprocess
from typing import Optional, List, Iterable, Tuple
import evdev
from inputremapper.configs.paths import PathUtils
from inputremapper.logging.logger import logger
from inputremapper.utils import is_service
DISABLE_NAME = "disable"
DISABLE_CODE = -1
# xkb uses keycodes that are 8 higher than those from evdev
XKB_KEYCODE_OFFSET = 8
XMODMAP_FILENAME = "xmodmap.json"
LAZY_LOAD = None
class KeyboardLayout:
"""Stores information about all available keycodes."""
_mapping: Optional[dict] = LAZY_LOAD
_xmodmap: Optional[List[Tuple[str, str]]] = LAZY_LOAD
_case_insensitive_mapping: Optional[dict] = LAZY_LOAD
def __getattribute__(self, wanted: str):
"""To lazy load keyboard_layout info only when needed.
For example, this helps to keep logs of input-remapper-control clear when it
doesn't need it the information.
"""
lazy_loaded_attributes = ["_mapping", "_xmodmap", "_case_insensitive_mapping"]
for lazy_loaded_attribute in lazy_loaded_attributes:
if wanted != lazy_loaded_attribute:
continue
if object.__getattribute__(self, lazy_loaded_attribute) is LAZY_LOAD:
# initialize _mapping and such with an empty dict, for populate
# to write into
object.__setattr__(self, lazy_loaded_attribute, {})
object.__getattribute__(self, "populate")()
return object.__getattribute__(self, wanted)
def list_names(self, codes: Optional[Iterable[int]] = None) -> List[str]:
"""Get all possible names in the mapping, optionally filtered by codes.
Parameters
----------
codes: list of event codes
"""
if not codes:
return self._mapping.keys()
return [name for name, code in self._mapping.items() if code in codes]
def correct_case(self, symbol: str):
"""Return the correct casing for a symbol."""
if symbol in self._mapping:
return symbol
# only if not e.g. both "a" and "A" are in the mapping
return self._case_insensitive_mapping.get(symbol.lower(), symbol)
def _use_xmodmap_symbols(self):
"""Look up xmodmap -pke, write xmodmap.json, and get the symbols."""
try:
xmodmap = subprocess.check_output(
["xmodmap", "-pke"],
stderr=subprocess.STDOUT,
).decode()
except FileNotFoundError:
logger.info("Optional `xmodmap` command not found. This is not critical.")
return
except subprocess.CalledProcessError as e:
logger.error('Call to `xmodmap -pke` failed with "%s"', e)
return
self._xmodmap = re.findall(r"(\d+) = (.+)\n", xmodmap + "\n")
xmodmap_dict = self._find_legit_mappings()
if len(xmodmap_dict) == 0:
logger.info("`xmodmap -pke` did not yield any symbol")
return
# Write this stuff into the input-remapper config directory, because
# the systemd service won't know the user sessions xmodmap.
path = PathUtils.get_config_path(XMODMAP_FILENAME)
PathUtils.touch(path)
with open(path, "w") as file:
logger.debug('Writing "%s"', path)
json.dump(xmodmap_dict, file, indent=4)
for name, code in xmodmap_dict.items():
self._set(name, code)
def _use_linux_evdev_symbols(self):
"""Look up the evdev constant names and use them."""
for name, ecode in evdev.ecodes.ecodes.items():
if name.startswith("KEY") or name.startswith("BTN"):
self._set(name, ecode)
def populate(self):
"""Get a mapping of all available names to their keycodes."""
logger.debug("Gathering available keycodes")
self.clear()
if not is_service():
# xmodmap is only available from within the login session.
# The service that runs via systemd can't use this.
self._use_xmodmap_symbols()
self._use_linux_evdev_symbols()
self._set(DISABLE_NAME, DISABLE_CODE)
def update(self, mapping: dict):
"""Update this with new keys.
Parameters
----------
mapping
maps from name to code. Make sure your keys are lowercase.
"""
len_before = len(self._mapping)
for name, code in mapping.items():
self._set(name, code)
logger.debug(
"Updated keycodes with %d new ones", len(self._mapping) - len_before
)
def _set(self, name: str, code: int):
"""Map name to code."""
self._mapping[str(name)] = code
self._case_insensitive_mapping[str(name).lower()] = name
def get(self, name: str) -> Optional[int]:
"""Return the code mapped to the key."""
# the correct casing should be shown when asking the keyboard_layout
# for stuff. indexing case insensitive to support old presets.
if name not in self._mapping:
# only if not e.g. both "a" and "A" are in the mapping
name = self._case_insensitive_mapping.get(str(name).lower())
return self._mapping.get(name)
def clear(self):
"""Remove all mapped keys. Only needed for tests."""
keys = list(self._mapping.keys())
for key in keys:
del self._mapping[key]
def get_name(self, code: int):
"""Get the first matching name for the code."""
for entry in self._xmodmap:
if int(entry[0]) - XKB_KEYCODE_OFFSET == code:
return entry[1].split()[0]
# Fall back to the linux constants
# This is especially important for BTN_LEFT and such
btn_name = evdev.ecodes.BTN.get(code, None)
if btn_name is not None:
if type(btn_name) in [list, tuple]:
# python-evdev >= 1.8.0 uses tuples
return btn_name[0]
return btn_name
key_name = evdev.ecodes.KEY.get(code, None)
if key_name is not None:
if type(key_name) in [list, tuple]:
# python-evdev >= 1.8.0 uses tuples
return key_name[0]
return key_name
return None
def _find_legit_mappings(self) -> dict:
"""From the parsed xmodmap list find usable symbols and their codes."""
xmodmap_dict = {}
for keycode, names in self._xmodmap:
# there might be multiple, like:
# keycode 64 = Alt_L Meta_L Alt_L Meta_L
# keycode 204 = NoSymbol Alt_L NoSymbol Alt_L
# Alt_L should map to code 64. Writing code 204 only works
# if a modifier is applied at the same time. So take the first
# one.
name = names.split()[0]
xmodmap_dict[name] = int(keycode) - XKB_KEYCODE_OFFSET
return xmodmap_dict
# TODO DI
# this mapping represents the xmodmap output, which stays constant
keyboard_layout = KeyboardLayout()

View File

@ -0,0 +1,525 @@
# -*- 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 enum
from collections import namedtuple
from typing import Optional, Callable, Tuple, TypeVar, Union, Any, Dict
from evdev.ecodes import (
EV_KEY,
EV_ABS,
EV_REL,
REL_WHEEL,
REL_HWHEEL,
REL_HWHEEL_HI_RES,
REL_WHEEL_HI_RES,
)
from packaging import version
from inputremapper.logging.logger import logger
try:
from pydantic.v1 import (
BaseModel,
PositiveInt,
confloat,
conint,
root_validator,
validator,
ValidationError,
PositiveFloat,
VERSION,
BaseConfig,
)
except ImportError:
from pydantic import (
BaseModel,
PositiveInt,
confloat,
conint,
root_validator,
validator,
ValidationError,
PositiveFloat,
VERSION,
BaseConfig,
)
from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.keyboard_layout import keyboard_layout, DISABLE_NAME
from inputremapper.configs.validation_errors import (
OutputSymbolUnknownError,
SymbolNotAvailableInTargetError,
OnlyOneAnalogInputError,
TriggerPointInRangeError,
OutputSymbolVariantError,
MacroButTypeOrCodeSetError,
SymbolAndCodeMismatchError,
WrongMappingTypeForKeyError,
MissingOutputAxisError,
MissingMacroOrKeyError,
)
from inputremapper.gui.gettext import _
from inputremapper.gui.messages.message_types import MessageType
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.macros.parse import Parser
from inputremapper.utils import get_evdev_constant_name
# TODO: remove pydantic VERSION check as soon as we no longer support
# Ubuntu 20.04 and with it the ancient pydantic 1.2
needs_workaround = version.parse(str(VERSION)) < version.parse("1.7.1")
EMPTY_MAPPING_NAME: str = _("Empty Mapping")
# If `1` is the default speed for EV_REL, how much does this value needs to be scaled
# up to get reasonable speeds for various EV_REL events?
# Mouse injection rates vary wildly, and so do the values.
REL_XY_SCALING: float = 60
WHEEL_SCALING: float = 1
# WHEEL_HI_RES always generates events with 120 times higher values than WHEEL
# https://www.kernel.org/doc/html/latest/input/event-codes.html?highlight=wheel_hi_res#ev-rel
WHEEL_HI_RES_SCALING: float = 120
# Those values are assuming a rate of 60hz
DEFAULT_REL_RATE: float = 60
class KnownUinput(str, enum.Enum):
"""The default targets."""
KEYBOARD = "keyboard"
MOUSE = "mouse"
GAMEPAD = "gamepad"
KEYBOARD_MOUSE = "keyboard + mouse"
class MappingType(str, enum.Enum):
"""What kind of output the mapping produces."""
KEY_MACRO = "key_macro"
ANALOG = "analog"
CombinationChangedCallback = Optional[
Callable[[InputCombination, InputCombination], None]
]
MappingModel = TypeVar("MappingModel", bound="UIMapping")
class Cfg(BaseConfig):
validate_assignment = True
use_enum_values = True
underscore_attrs_are_private = True
json_encoders = {InputCombination: lambda v: v.json_key()}
class ImmutableCfg(Cfg):
allow_mutation = False
class UIMapping(BaseModel):
"""Holds all the data for mapping an input action to an output action.
The Preset contains multiple UIMappings.
This mapping does not validate the structure of the mapping or macros, only basic
values. It is meant to be used in the GUI where invalid mappings are expected.
"""
if needs_workaround:
__slots__ = ("_combination_changed",)
# Required attributes
# The InputEvent or InputEvent combination which is mapped
input_combination: InputCombination = InputCombination.empty_combination()
# The UInput to which the mapped event will be sent
target_uinput: Optional[Union[str, KnownUinput]] = None
# Either `output_symbol` or `output_type` and `output_code` is required
# Only set if output is "Key or Macro":
output_symbol: Optional[str] = None # The symbol or macro string if applicable
# "Analog Axis" or if preset edited manually to inject a code instead of a symbol:
output_type: Optional[int] = None # The event type of the mapped event
output_code: Optional[int] = None # The event code of the mapped event
name: Optional[str] = None
mapping_type: Optional[MappingType] = None
# if release events will be sent to the forwarded device as soon as a combination
# triggers see also #229
release_combination_keys: bool = True
# macro settings
macro_key_sleep_ms: conint(ge=0) = 0 # type: ignore
# Optional attributes for mapping Axis to Axis
# The deadzone of the input axis
deadzone: confloat(ge=0, le=1) = 0.1 # type: ignore
gain: float = 1.0 # The scale factor for the transformation
# The expo factor for the transformation
expo: confloat(ge=-1, le=1) = 0 # type: ignore
# when mapping to relative axis
# The frequency [Hz] at which EV_REL events get generated
rel_rate: PositiveInt = 60
# when mapping from a relative axis:
# the relative value at which a EV_REL axis is considered at its maximum. Moving
# a mouse at 2x the regular speed would be considered max by default.
rel_to_abs_input_cutoff: PositiveInt = 2
# the time until a relative axis is considered stationary if no new events arrive
release_timeout: PositiveFloat = 0.05
# don't release immediately when a relative axis drops below the speed threshold
# instead wait until it dropped for loger than release_timeout below the threshold
force_release_timeout: bool = False
# callback which gets called if the input_combination is updated
if not needs_workaround:
_combination_changed: Optional[CombinationChangedCallback] = None
# use type: ignore, looks like a mypy bug related to:
# https://github.com/samuelcolvin/pydantic/issues/2949
def __init__(self, **kwargs): # type: ignore
super().__init__(**kwargs)
if needs_workaround:
object.__setattr__(self, "_combination_changed", None)
def __setattr__(self, key: str, value: Any):
"""Call the combination changed callback
if we are about to update the input_combination
"""
if key != "input_combination" or self._combination_changed is None:
if key == "_combination_changed" and needs_workaround:
object.__setattr__(self, "_combination_changed", value)
return
super().__setattr__(key, value)
return
# the new combination is not yet validated
try:
new_combi = InputCombination.validate(value)
except (ValueError, TypeError) as exception:
raise ValidationError(
f"failed to Validate {value} as InputCombination", UIMapping
) from exception
if new_combi == self.input_combination:
return
# raises a keyError if the combination or a permutation is already mapped
self._combination_changed(new_combi, self.input_combination)
super().__setattr__("input_combination", new_combi)
def __str__(self):
return str(
self.dict(
exclude_defaults=True, include={"input_combination", "target_uinput"}
)
)
if needs_workaround:
# https://github.com/samuelcolvin/pydantic/issues/1383
def copy(self: MappingModel, *args, **kwargs) -> MappingModel:
kwargs["deep"] = True
copy = super().copy(*args, **kwargs)
object.__setattr__(copy, "_combination_changed", self._combination_changed)
return copy
def format_name(self) -> str:
"""Get the custom-name or a readable representation of the combination."""
if self.name:
return self.name
if (
self.input_combination == InputCombination.empty_combination()
or self.input_combination is None
):
return EMPTY_MAPPING_NAME
return self.input_combination.beautify()
def has_input_defined(self) -> bool:
"""Whether this mapping defines an event-input."""
return self.input_combination != InputCombination.empty_combination()
def is_axis_mapping(self) -> bool:
"""Whether this mapping specifies an output axis."""
return self.output_type in [EV_ABS, EV_REL]
def is_wheel_output(self) -> bool:
"""Check if this maps to wheel output."""
return self.output_code in (
REL_WHEEL,
REL_HWHEEL,
)
def is_high_res_wheel_output(self) -> bool:
"""Check if this maps to high-res wheel output."""
return self.output_code in (
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
)
def is_analog_output(self):
return self.mapping_type == MappingType.ANALOG
def set_combination_changed_callback(self, callback: CombinationChangedCallback):
self._combination_changed = callback
def remove_combination_changed_callback(self):
self._combination_changed = None
def get_output_type_code(self) -> Optional[Tuple[int, int]]:
"""Returns the output_type and output_code if set,
otherwise looks the output_symbol up in the keyboard_layout
return None for unknown symbols and macros
"""
if self.output_code is not None and self.output_type is not None:
return self.output_type, self.output_code
if self.output_symbol and not Parser.is_this_a_macro(self.output_symbol):
return EV_KEY, keyboard_layout.get(self.output_symbol)
return None
def get_output_name_constant(self) -> str:
"""Get the evdev name costant for the output."""
return get_evdev_constant_name(self.output_type, self.output_code)
def is_valid(self) -> bool:
"""If the mapping is valid."""
return not self.get_error()
def get_error(self) -> Optional[ValidationError]:
"""The validation error or None."""
try:
Mapping(**self.dict())
except ValidationError as exception:
return exception
return None
def get_bus_message(self) -> MappingData:
"""Return an immutable copy for use in the message broker."""
return MappingData(**self.dict())
@root_validator
def validate_mapping_type(cls, values):
"""Overrides the mapping type if the output mapping type is obvious."""
output_type = values.get("output_type")
output_code = values.get("output_code")
output_symbol = values.get("output_symbol")
if output_type is not None and output_symbol is not None:
# This is currently only possible when someone edits the preset file by
# hand. A key-output mapping without an output_symbol, but type and code
# instead, is valid as well.
logger.debug("Both output_type and output_symbol are set")
if output_type != EV_KEY and output_code is not None and not output_symbol:
values["mapping_type"] = MappingType.ANALOG.value
if output_type is None and output_code is None and output_symbol:
values["mapping_type"] = MappingType.KEY_MACRO.value
if output_type == EV_KEY:
values["mapping_type"] = MappingType.KEY_MACRO.value
return values
Config = Cfg
class Mapping(UIMapping):
"""Holds all the data for mapping an input action to an output action.
This implements the missing validations from UIMapping.
"""
# Override Required attributes to enforce they are set
input_combination: InputCombination
target_uinput: KnownUinput
@classmethod
def from_combination(
cls,
input_combination=None,
target_uinput="keyboard",
output_symbol="a",
):
"""Convenient function to get a valid mapping."""
if not input_combination:
input_combination = [{"type": 99, "code": 99, "analog_threshold": 99}]
return cls(
input_combination=input_combination,
target_uinput=target_uinput,
output_symbol=output_symbol,
)
def is_valid(self) -> bool:
"""If the mapping is valid."""
return True
@root_validator(pre=True)
def validate_symbol(cls, values):
"""Parse a macro to check for syntax errors."""
symbol = values.get("output_symbol")
if symbol == "":
values["output_symbol"] = None
return values
if symbol is None:
return values
symbol = symbol.strip()
values["output_symbol"] = symbol
if symbol == DISABLE_NAME:
return values
if Parser.is_this_a_macro(symbol):
mapping_mock = namedtuple("Mapping", values.keys())(**values)
# raises MacroError
Parser.parse(symbol, mapping=mapping_mock, verbose=False)
return values
code = keyboard_layout.get(symbol)
if code is None:
raise OutputSymbolUnknownError(symbol)
target = values.get("target_uinput")
if target is not None and not GlobalUInputs.can_default_uinput_emit(
target, EV_KEY, code
):
raise SymbolNotAvailableInTargetError(symbol, target)
return values
@validator("input_combination")
def only_one_analog_input(cls, combination) -> InputCombination:
"""Check that the input_combination specifies a maximum of one
analog to analog mapping
"""
analog_events = [event for event in combination if event.defines_analog_input]
if len(analog_events) > 1:
raise OnlyOneAnalogInputError(analog_events)
return combination
@validator("input_combination")
def trigger_point_in_range(cls, combination: InputCombination) -> InputCombination:
"""Check if the trigger point for mapping analog axis to buttons is valid."""
for input_config in combination:
if (
input_config.type == EV_ABS
and input_config.analog_threshold
and abs(input_config.analog_threshold) >= 100
):
raise TriggerPointInRangeError(input_config)
return combination
@root_validator
def validate_output_symbol_variant(cls, values):
"""Validate that either type and code or symbol are set for key output."""
o_symbol = values.get("output_symbol")
o_type = values.get("output_type")
o_code = values.get("output_code")
if o_symbol is None and (o_type is None or o_code is None):
raise OutputSymbolVariantError()
return values
@root_validator
def validate_output_integrity(cls, values):
"""Validate the output key configuration."""
symbol = values.get("output_symbol")
type_ = values.get("output_type")
code = values.get("output_code")
if symbol is None:
# If symbol is "", then validate_symbol changes it to None
# type and code can be anything
return values
if type_ is None and code is None:
# we have a symbol: no type and code is fine
return values
if Parser.is_this_a_macro(symbol):
# disallow output type and code for macros
if type_ is not None or code is not None:
raise MacroButTypeOrCodeSetError()
if code is not None and code != keyboard_layout.get(symbol) or type_ != EV_KEY:
raise SymbolAndCodeMismatchError(symbol, code)
return values
@root_validator
def output_matches_input(cls, values: Dict[str, Any]) -> Dict[str, Any]:
"""Validate that an output type is an axis if we have an input axis.
And vice versa."""
assert isinstance(values.get("input_combination"), InputCombination)
combination: InputCombination = values["input_combination"]
analog_input_config = combination.find_analog_input_config()
defines_analog_input = analog_input_config is not None
output_type = values.get("output_type")
output_code = values.get("output_code")
mapping_type = values.get("mapping_type")
output_symbol = values.get("output_symbol")
output_key_set = output_symbol or (output_type == EV_KEY and output_code)
if mapping_type is None:
# Empty mapping most likely
return values
if not defines_analog_input and mapping_type != MappingType.KEY_MACRO.value:
raise WrongMappingTypeForKeyError()
if not defines_analog_input and not output_key_set:
raise MissingMacroOrKeyError()
if (
defines_analog_input
and output_type not in (EV_ABS, EV_REL)
and output_symbol != DISABLE_NAME
):
raise MissingOutputAxisError(analog_input_config, output_type)
return values
class MappingData(UIMapping):
"""Like UIMapping, but can be sent over the message broker."""
Config = ImmutableCfg
message_type = MessageType.mapping # allow this to be sent over the MessageBroker
def __str__(self):
return str(self.dict(exclude_defaults=True))
def dict(self, *args, **kwargs):
"""Will not include the message_type."""
dict_ = super().dict(*args, **kwargs)
if "message_type" in dict_:
del dict_["message_type"]
return dict_

View File

@ -0,0 +1,516 @@
# -*- 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/>.
"""Migration functions.
Only write changes to disk, if there actually are changes. Otherwise, file-modification
dates are destroyed.
"""
from __future__ import annotations
import copy
import json
import os
import re
import shutil
from pathlib import Path
from typing import Iterator, Tuple, Dict, List, Optional, TypedDict
from evdev.ecodes import (
EV_KEY,
EV_ABS,
EV_REL,
ABS_X,
ABS_Y,
ABS_RX,
ABS_RY,
REL_X,
REL_Y,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
)
from packaging import version
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.mapping import Mapping, UIMapping
from inputremapper.configs.paths import PathUtils
from inputremapper.configs.preset import Preset
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.macros.parse import Parser
from inputremapper.logging.logger import logger, VERSION
from inputremapper.user import UserUtils
class Config(TypedDict):
input_combination: Optional[InputCombination]
target_uinput: str
output_type: int
output_code: Optional[int]
class Migrations:
def __init__(self, global_uinputs: GlobalUInputs):
self.global_uinputs = global_uinputs
def migrate(self):
"""Migrate config files to the current release."""
self._rename_to_input_remapper()
self._copy_to_v2()
v = self.config_version()
if v < version.parse("0.4.0"):
self._config_suffix()
self._preset_path()
if v < version.parse("1.2.2"):
self._mapping_keys()
if v < version.parse("1.4.0"):
self.global_uinputs.prepare_all()
self._add_target()
if v < version.parse("1.4.1"):
self._otherwise_to_else()
if v < version.parse("1.5.0"):
self._remove_logs()
if v < version.parse("1.6.0-beta"):
self._convert_to_individual_mappings()
# add new migrations here
if v < version.parse(VERSION):
self._update_version()
def all_presets(
self,
) -> Iterator[Tuple[os.PathLike, Dict | List]]:
"""Get all presets for all groups as list."""
if not os.path.exists(PathUtils.get_preset_path()):
return
preset_path = Path(PathUtils.get_preset_path())
for folder in preset_path.iterdir():
if not folder.is_dir():
continue
for preset in folder.iterdir():
if preset.suffix != ".json":
continue
try:
with open(preset, "r") as f:
preset_structure = json.load(f)
yield preset, preset_structure
except json.decoder.JSONDecodeError:
logger.warning('Invalid json format in preset "%s"', preset)
continue
def config_version(self):
"""Get the version string in config.json as packaging.Version object."""
config_path = os.path.join(PathUtils.config_path(), "config.json")
if not os.path.exists(config_path):
return version.parse("0.0.0")
with open(config_path, "r") as file:
config = json.load(file)
if "version" in config.keys():
return version.parse(config["version"])
return version.parse("0.0.0")
def _config_suffix(self):
"""Append the .json suffix to the config file."""
deprecated_path = os.path.join(PathUtils.config_path(), "config")
config_path = os.path.join(PathUtils.config_path(), "config.json")
if os.path.exists(deprecated_path) and not os.path.exists(config_path):
logger.info('Moving "%s" to "%s"', deprecated_path, config_path)
os.rename(deprecated_path, config_path)
def _preset_path(self):
"""Migrate the folder structure from < 0.4.0.
Move existing presets into the new subfolder 'presets'
"""
new_preset_folder = os.path.join(PathUtils.config_path(), "presets")
if os.path.exists(PathUtils.get_preset_path()) or not os.path.exists(
PathUtils.config_path()
):
return
logger.info("Migrating presets from < 0.4.0...")
group_config_files = os.listdir(PathUtils.config_path())
PathUtils.mkdir(PathUtils.get_preset_path())
for group in group_config_files:
path = os.path.join(PathUtils.config_path(), group)
if os.path.isdir(path):
target = path.replace(PathUtils.config_path(), new_preset_folder)
logger.info('Moving "%s" to "%s"', path, target)
os.rename(path, target)
logger.info("done")
def _mapping_keys(self):
"""Update all preset mappings.
Update all keys in preset to include value e.g.: '1,5'->'1,5,1'
"""
for preset, preset_structure in self.all_presets():
if isinstance(preset_structure, list):
continue # the preset must be at least 1.6-beta version
changes = 0
if "mapping" in preset_structure.keys():
mapping = copy.deepcopy(preset_structure["mapping"])
for key in mapping.keys():
if key.count(",") == 1:
preset_structure["mapping"][f"{key},1"] = preset_structure[
"mapping"
].pop(key)
changes += 1
if changes:
with open(preset, "w") as file:
logger.info('Updating mapping keys of "%s"', preset)
json.dump(preset_structure, file, indent=4)
file.write("\n")
def _update_version(self):
"""Write the current version to the config file."""
config_file = os.path.join(PathUtils.config_path(), "config.json")
if not os.path.exists(config_file):
return
with open(config_file, "r") as file:
config = json.load(file)
config["version"] = VERSION
with open(config_file, "w") as file:
logger.info('Updating version in config to "%s"', VERSION)
json.dump(config, file, indent=4)
def _rename_to_input_remapper(self):
"""Rename .config/key-mapper to .config/input-remapper."""
old_config_path = os.path.join(UserUtils.home, ".config/key-mapper")
if not os.path.exists(PathUtils.config_path()) and os.path.exists(
old_config_path
):
logger.info("Moving %s to %s", old_config_path, PathUtils.config_path())
shutil.move(old_config_path, PathUtils.config_path())
def _find_target(self, symbol):
"""Try to find a uinput with the required capabilities for the symbol."""
capabilities = {EV_KEY: set(), EV_REL: set()}
if Parser.is_this_a_macro(symbol):
# deprecated mechanic, cannot figure this out anymore
# capabilities = parse(symbol).get_capabilities()
return None
capabilities[EV_KEY] = {keyboard_layout.get(symbol)}
if len(capabilities[EV_REL]) > 0:
return "mouse"
for name, uinput in self.global_uinputs.devices.items():
if capabilities[EV_KEY].issubset(uinput.capabilities()[EV_KEY]):
return name
logger.info('could not find a suitable target UInput for "%s"', symbol)
return None
def _add_target(self):
"""Add the target field to each preset mapping."""
for preset, preset_structure in self.all_presets():
if isinstance(preset_structure, list):
continue
if "mapping" not in preset_structure.keys():
continue
changed = False
for key, symbol in preset_structure["mapping"].copy().items():
if isinstance(symbol, list):
continue
target = self._find_target(symbol)
if target is None:
target = "keyboard"
symbol = (
f"{symbol}\n"
"# Broken mapping:\n"
"# No target can handle all specified keycodes"
)
logger.info(
'Changing target of mapping for "%s" in preset "%s" to "%s"',
key,
preset,
target,
)
symbol = [symbol, target]
preset_structure["mapping"][key] = symbol
changed = True
if not changed:
continue
with open(preset, "w") as file:
logger.info('Adding targets for "%s"', preset)
json.dump(preset_structure, file, indent=4)
file.write("\n")
def _otherwise_to_else(self):
"""Conditional macros should use an "else" parameter instead of "otherwise"."""
for preset, preset_structure in self.all_presets():
if isinstance(preset_structure, list):
continue
if "mapping" not in preset_structure.keys():
continue
changed = False
for key, symbol in preset_structure["mapping"].copy().items():
if not Parser.is_this_a_macro(symbol[0]):
continue
symbol_before = symbol[0]
symbol[0] = re.sub(r"otherwise\s*=\s*", "else=", symbol[0])
if symbol_before == symbol[0]:
continue
changed = changed or symbol_before != symbol[0]
logger.info(
'Changing mapping for "%s" in preset "%s" to "%s"',
key,
preset,
symbol[0],
)
preset_structure["mapping"][key] = symbol
if not changed:
continue
with open(preset, "w") as file:
logger.info('Changing otherwise to else for "%s"', preset)
json.dump(preset_structure, file, indent=4)
file.write("\n")
def _input_combination_from_string(
self, combination_string: str
) -> InputCombination:
configs = []
for event_str in combination_string.split("+"):
type_, code, analog_threshold = event_str.split(",")
configs.append(
{
"type": int(type_),
"code": int(code),
"analog_threshold": int(analog_threshold),
}
)
return InputCombination(configs)
def _convert_to_individual_mappings(
self,
) -> None:
"""Convert preset.json
from {key: [symbol, target]}
to [{input_combination: ..., output_symbol: symbol, ...}]
"""
for old_preset_path, old_preset in self.all_presets():
if isinstance(old_preset, list):
continue
migrated_preset = Preset(old_preset_path, UIMapping)
if "mapping" in old_preset.keys():
for combination, symbol_target in old_preset["mapping"].items():
logger.info(
'migrating from "%s: %s" to mapping dict',
combination,
symbol_target,
)
try:
combination = self._input_combination_from_string(combination)
except ValueError:
logger.error(
"unable to migrate mapping with invalid combination %s",
combination,
)
continue
mapping = UIMapping(
input_combination=combination,
target_uinput=symbol_target[1],
output_symbol=symbol_target[0],
)
migrated_preset.add(mapping)
if (
"gamepad" in old_preset.keys()
and "joystick" in old_preset["gamepad"].keys()
):
joystick_dict = old_preset["gamepad"]["joystick"]
left_purpose = joystick_dict.get("left_purpose")
right_purpose = joystick_dict.get("right_purpose")
# TODO if pointer_speed is migrated, why is it in my config?
pointer_speed = joystick_dict.get("pointer_speed")
if pointer_speed:
pointer_speed /= 100
# non_linearity = joystick_dict.get("non_linearity") # Todo
x_scroll_speed = joystick_dict.get("x_scroll_speed")
y_scroll_speed = joystick_dict.get("y_scroll_speed")
cfg: Config = {
"input_combination": None,
"target_uinput": "mouse",
"output_type": EV_REL,
"output_code": None,
}
if left_purpose == "mouse":
x_config = cfg.copy()
y_config = cfg.copy()
x_config["input_combination"] = InputCombination(
[InputConfig(type=EV_ABS, code=ABS_X)]
)
y_config["input_combination"] = InputCombination(
[InputConfig(type=EV_ABS, code=ABS_Y)]
)
x_config["output_code"] = REL_X
y_config["output_code"] = REL_Y
mapping_x = Mapping(**x_config)
mapping_y = Mapping(**y_config)
if pointer_speed:
mapping_x.gain = pointer_speed
mapping_y.gain = pointer_speed
migrated_preset.add(mapping_x)
migrated_preset.add(mapping_y)
if right_purpose == "mouse":
x_config = cfg.copy()
y_config = cfg.copy()
x_config["input_combination"] = InputCombination(
[InputConfig(type=EV_ABS, code=ABS_RX)]
)
y_config["input_combination"] = InputCombination(
[InputConfig(type=EV_ABS, code=ABS_RY)]
)
x_config["output_code"] = REL_X
y_config["output_code"] = REL_Y
mapping_x = Mapping(**x_config)
mapping_y = Mapping(**y_config)
if pointer_speed:
mapping_x.gain = pointer_speed
mapping_y.gain = pointer_speed
migrated_preset.add(mapping_x)
migrated_preset.add(mapping_y)
if left_purpose == "wheel":
x_config = cfg.copy()
y_config = cfg.copy()
x_config["input_combination"] = InputCombination(
[InputConfig(type=EV_ABS, code=ABS_X)]
)
y_config["input_combination"] = InputCombination(
[InputConfig(type=EV_ABS, code=ABS_Y)]
)
x_config["output_code"] = REL_HWHEEL_HI_RES
y_config["output_code"] = REL_WHEEL_HI_RES
mapping_x = Mapping(**x_config)
mapping_y = Mapping(**y_config)
if x_scroll_speed:
mapping_x.gain = x_scroll_speed
if y_scroll_speed:
mapping_y.gain = y_scroll_speed
migrated_preset.add(mapping_x)
migrated_preset.add(mapping_y)
if right_purpose == "wheel":
x_config = cfg.copy()
y_config = cfg.copy()
x_config["input_combination"] = InputCombination(
[InputConfig(type=EV_ABS, code=ABS_RX)]
)
y_config["input_combination"] = InputCombination(
[InputConfig(type=EV_ABS, code=ABS_RY)]
)
x_config["output_code"] = REL_HWHEEL_HI_RES
y_config["output_code"] = REL_WHEEL_HI_RES
mapping_x = Mapping(**x_config)
mapping_y = Mapping(**y_config)
if x_scroll_speed:
mapping_x.gain = x_scroll_speed
if y_scroll_speed:
mapping_y.gain = y_scroll_speed
migrated_preset.add(mapping_x)
migrated_preset.add(mapping_y)
migrated_preset.save()
def _copy_to_v2(self):
"""Move the beta config to the v2 path, or copy the v1 config to the v2 path."""
# TODO test
if os.path.exists(PathUtils.config_path()):
# don't copy to already existing folder
# users should delete the input-remapper-2 folder if they need to
return
# prioritize the v1 configs over beta configs
old_path = os.path.join(UserUtils.home, ".config/input-remapper")
if os.path.exists(os.path.join(old_path, "config.json")):
# no beta path, only old presets exist. COPY to v2 path, which will then be
# migrated by the various self.
logger.debug("copying all from %s to %s", old_path, PathUtils.config_path())
shutil.copytree(old_path, PathUtils.config_path())
return
# if v1 configs don't exist, try to find beta configs.
beta_path = os.path.join(
UserUtils.home, ".config/input-remapper/beta_1.6.0-beta"
)
if os.path.exists(beta_path):
# There has never been a different version than "1.6.0-beta" in beta, so we
# only need to check for that exact directory
# already migrated, possibly new presets in them, move to v2 path
logger.debug("moving %s to %s", beta_path, PathUtils.config_path())
shutil.move(beta_path, PathUtils.config_path())
def _remove_logs(self):
"""We will try to rely on journalctl for this in the future."""
try:
PathUtils.remove(f"{UserUtils.home}/.log/input-remapper")
PathUtils.remove("/var/log/input-remapper")
PathUtils.remove("/var/log/input-remapper-control")
except Exception as error:
logger.debug("Failed to remove deprecated logfiles: %s", str(error))
# this migration is not important. Continue
pass

View File

@ -0,0 +1,157 @@
# -*- 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/>.
# TODO: convert everything to use pathlib.Path
"""Path constants to be used."""
import os
import shutil
from typing import List, Union, Optional
from inputremapper.logging.logger import logger
from inputremapper.user import UserUtils
# TODO maybe this could become, idk, ConfigService and PresetService
class PathUtils:
@staticmethod
def config_path() -> str:
# TODO when proper DI is being done, construct PathUtils and configure it
# in the constructor. Then there is no need to recompute the config_path
# each time. Tests might have overwritten UserUtils.home.
xdg_config_home = os.getenv(
"XDG_CONFIG_HOME", os.path.join(UserUtils.home, ".config")
)
return os.path.join(xdg_config_home, "input-remapper-2")
@staticmethod
def chown(path):
"""Set the owner of a path to the user."""
try:
logger.debug('Chown "%s", "%s"', path, UserUtils.user)
shutil.chown(path, user=UserUtils.user, group=UserUtils.user)
except LookupError:
# the users group was unknown in one case for whatever reason
shutil.chown(path, user=UserUtils.user)
@staticmethod
def touch(path: Union[str, os.PathLike], log=True):
"""Create an empty file and all its parent dirs, give it to the user."""
if str(path).endswith("/"):
raise ValueError(f"Expected path to not end with a slash: {path}")
if os.path.exists(path):
return
if log:
logger.info('Creating file "%s"', path)
PathUtils.mkdir(os.path.dirname(path), log=False)
os.mknod(path)
PathUtils.chown(path)
@staticmethod
def mkdir(path, log=True):
"""Create a folder, give it to the user."""
if path == "" or path is None:
return
if os.path.exists(path):
return
if log:
logger.info('Creating dir "%s"', path)
# give all newly created folders to the user.
# e.g. if .config/input-remapper/mouse/ is created the latter two
base = os.path.split(path)[0]
PathUtils.mkdir(base, log=False)
os.makedirs(path)
PathUtils.chown(path)
@staticmethod
def split_all(path: Union[os.PathLike, str]) -> List[str]:
"""Split the path into its segments."""
parts = []
while True:
path, tail = os.path.split(path)
parts.append(tail)
if path == os.path.sep:
# we arrived at the root '/'
parts.append(path)
break
if not path:
# arrived at start of relative path
break
parts.reverse()
return parts
@staticmethod
def remove(path):
"""Remove whatever is at the path."""
if not os.path.exists(path):
return
if os.path.isdir(path):
shutil.rmtree(path)
else:
os.remove(path)
@staticmethod
def sanitize_path_component(group_name: str) -> str:
"""Replace characters listed in
https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words
with an underscore.
"""
for character in '/\\?%*:|"<>':
if character in group_name:
group_name = group_name.replace(character, "_")
return group_name
@staticmethod
def get_preset_path(group_name: Optional[str] = None, preset: Optional[str] = None):
"""Get a path to the stored preset, or to store a preset to."""
presets_base = os.path.join(PathUtils.config_path(), "presets")
if group_name is None:
return presets_base
group_name = PathUtils.sanitize_path_component(group_name)
if preset is not None:
# the extension of the preset should not be shown in the ui.
# if a .json extension arrives this place, it has not been
# stripped away properly prior to this.
if not preset.endswith(".json"):
preset = f"{preset}.json"
if preset is None:
return os.path.join(presets_base, group_name)
return os.path.join(presets_base, group_name, preset)
@staticmethod
def get_config_path(*paths) -> str:
"""Get a path in ~/.config/input-remapper/."""
return os.path.join(PathUtils.config_path(), *paths)

View File

@ -0,0 +1,325 @@
# -*- 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/>.
"""Contains and manages mappings."""
from __future__ import annotations
import json
import os
from typing import (
Tuple,
Dict,
List,
Optional,
Iterator,
Type,
TypeVar,
Generic,
overload,
)
from evdev import ecodes
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping, UIMapping
from inputremapper.configs.paths import PathUtils
from inputremapper.logging.logger import logger
MappingModel = TypeVar("MappingModel", bound=UIMapping)
class Preset(Generic[MappingModel]):
"""Contains and manages mappings of a single preset."""
# workaround for typing: https://github.com/python/mypy/issues/4236
@overload
def __init__(self: Preset[Mapping], path: Optional[os.PathLike] = None): ...
@overload
def __init__(
self,
path: Optional[os.PathLike] = None,
mapping_factory: Type[MappingModel] = ...,
): ...
def __init__(
self,
path: Optional[os.PathLike] = None,
mapping_factory=Mapping,
) -> None:
self._mappings: Dict[InputCombination, MappingModel] = {}
# a copy of mappings for keeping track of changes
self._saved_mappings: Dict[InputCombination, MappingModel] = {}
self._path: Optional[os.PathLike] = path
# the mapping class which is used by load()
self._mapping_factory: Type[MappingModel] = mapping_factory
def __iter__(self) -> Iterator[MappingModel]:
"""Iterate over Mapping objects."""
return iter(self._mappings.copy().values())
def __len__(self) -> int:
return len(self._mappings)
def __bool__(self):
# otherwise __len__ will be used which results in False for a preset
# without mappings
return True
def has_unsaved_changes(self) -> bool:
"""Check if there are unsaved changed."""
return self._mappings != self._saved_mappings
def remove(self, combination: InputCombination) -> None:
"""Remove a mapping from the preset by providing the InputCombination."""
if not isinstance(combination, InputCombination):
raise TypeError(
f"combination must by of type InputCombination, got {type(combination)}"
)
for permutation in combination.get_permutations():
if permutation in self._mappings.keys():
combination = permutation
break
try:
mapping = self._mappings.pop(combination)
mapping.remove_combination_changed_callback()
except KeyError:
logger.debug(
"unable to remove non-existing mapping with combination = %s",
combination,
)
pass
def add(self, mapping: MappingModel) -> None:
"""Add a mapping to the preset."""
for permutation in mapping.input_combination.get_permutations():
if permutation in self._mappings:
raise KeyError(
"A mapping with this input_combination: "
f"{permutation} already exists",
)
mapping.set_combination_changed_callback(self._combination_changed_callback)
self._mappings[mapping.input_combination] = mapping
def empty(self) -> None:
"""Remove all mappings and custom configs without saving.
note: self.has_unsaved_changes() will report True
"""
for mapping in self._mappings.values():
mapping.remove_combination_changed_callback()
self._mappings = {}
def clear(self) -> None:
"""Remove all mappings and also self.path."""
self.empty()
self._saved_mappings = {}
self.path = None
def load(self) -> None:
"""Load from the mapping from the disc, clears all existing mappings."""
logger.info('Loading preset from "%s"', self.path)
if not self.path or not os.path.exists(self.path):
raise FileNotFoundError(f'Tried to load non-existing preset "{self.path}"')
self._saved_mappings = self._get_mappings_from_disc()
self.empty()
for mapping in self._saved_mappings.values():
# use the public add method to make sure
# the _combination_changed_callback is attached
self.add(mapping.copy())
def _is_mapped_multiple_times(self, input_combination: InputCombination) -> bool:
"""Check if the event combination maps to multiple mappings."""
all_input_combinations = {mapping.input_combination for mapping in self}
permutations = set(input_combination.get_permutations())
union = permutations & all_input_combinations
# if there are more than one matches, then there is a duplicate
return len(union) > 1
def _has_valid_input_combination(self, mapping: UIMapping) -> bool:
"""Check if the mapping has a valid input event combination."""
is_a_combination = isinstance(mapping.input_combination, InputCombination)
is_empty = mapping.input_combination == InputCombination.empty_combination()
return is_a_combination and not is_empty
def save(self) -> None:
"""Dump as JSON to self.path."""
if not self.path:
logger.debug("unable to save preset without a path set Preset.path first")
return
PathUtils.touch(self.path)
if not self.has_unsaved_changes():
logger.debug("Not saving unchanged preset")
return
logger.info("Saving preset to %s", self.path)
preset_list = []
saved_mappings = {}
for mapping in self:
if not mapping.is_valid():
if not self._has_valid_input_combination(mapping):
# we save invalid mappings except for those with an invalid
# input_combination
logger.debug("Skipping invalid mapping %s", mapping)
continue
if self._is_mapped_multiple_times(mapping.input_combination):
# todo: is this ever executed? it should not be possible to
# reach this
logger.debug(
"skipping mapping with duplicate event combination %s",
mapping,
)
continue
mapping_dict = mapping.dict(exclude_defaults=True)
mapping_dict["input_combination"] = mapping.input_combination.to_config()
combination = mapping.input_combination
preset_list.append(mapping_dict)
saved_mappings[combination] = mapping.copy()
saved_mappings[combination].remove_combination_changed_callback()
with open(self.path, "w") as file:
json.dump(preset_list, file, indent=4)
file.write("\n")
self._saved_mappings = saved_mappings
def is_valid(self) -> bool:
return False not in [mapping.is_valid() for mapping in self]
def get_mapping(
self, combination: Optional[InputCombination]
) -> Optional[MappingModel]:
"""Return the Mapping that is mapped to this InputCombination."""
if not combination:
return None
if not isinstance(combination, InputCombination):
raise TypeError(
f"combination must by of type InputCombination, got {type(combination)}"
)
for permutation in combination.get_permutations():
existing = self._mappings.get(permutation)
if existing is not None:
return existing
return None
def dangerously_mapped_btn_left(self) -> bool:
"""Return True if this mapping disables BTN_Left."""
if (ecodes.EV_KEY, ecodes.BTN_LEFT) not in [
m.input_combination[0].type_and_code for m in self
]:
return False
values: List[str | Tuple[int, int] | None] = []
for mapping in self:
if mapping.output_symbol is None:
continue
values.append(mapping.output_symbol.lower())
values.append(mapping.get_output_type_code())
return (
"btn_left" not in values
or InputConfig.btn_left().type_and_code not in values
)
def _combination_changed_callback(
self, new: InputCombination, old: InputCombination
) -> None:
for permutation in new.get_permutations():
if permutation in self._mappings.keys() and permutation != old:
raise KeyError("combination already exists in the preset")
self._mappings[new] = self._mappings.pop(old)
def _update_saved_mappings(self) -> None:
if self.path is None:
return
if not os.path.exists(self.path):
self._saved_mappings = {}
return
self._saved_mappings = self._get_mappings_from_disc()
def _get_mappings_from_disc(self) -> Dict[InputCombination, MappingModel]:
mappings: Dict[InputCombination, MappingModel] = {}
if not self.path:
logger.debug("unable to read preset without a path set Preset.path first")
return mappings
if os.stat(self.path).st_size == 0:
logger.debug("got empty file")
return mappings
with open(self.path, "r") as file:
try:
preset_list = json.load(file)
except json.JSONDecodeError:
logger.error("unable to decode json file: %s", self.path)
return mappings
for mapping_dict in preset_list:
if not isinstance(mapping_dict, dict):
logger.error(
"Expected mapping to be a dict: %s %s",
type(mapping_dict),
mapping_dict,
)
continue
try:
mapping = self._mapping_factory(**mapping_dict)
except Exception as error:
logger.error(
"failed to Validate mapping for %s: %s",
mapping_dict.get("input_combination"),
error,
)
continue
mappings[mapping.input_combination] = mapping
return mappings
@property
def path(self) -> Optional[os.PathLike]:
return self._path
@path.setter
def path(self, path: Optional[os.PathLike]):
if path != self.path:
self._path = path
self._update_saved_mappings()
@property
def name(self) -> Optional[str]:
"""The name of the preset."""
if self.path:
return os.path.basename(self.path).split(".")[0]
return None

View File

@ -0,0 +1,137 @@
# -*- 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/>.
"""Exceptions that are thrown when configurations are incorrect."""
# can't merge this with exceptions.py, because I want error constructors here to
# be intelligent to avoid redundant code, and they need imports, which would cause
# circular imports.
# pydantic only catches ValueError, TypeError, and AssertionError
from __future__ import annotations
from typing import Optional
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.global_uinputs import GlobalUInputs
class OutputSymbolVariantError(ValueError):
def __init__(self):
super().__init__(
"Missing Argument: Mapping must either contain "
"`output_symbol` or `output_type` and `output_code`"
)
class TriggerPointInRangeError(ValueError):
def __init__(self, input_config):
super().__init__(
f"{input_config = } maps an absolute axis to a button, but the "
"trigger point (event.analog_threshold) is not between -100[%] "
"and 100[%]"
)
class OnlyOneAnalogInputError(ValueError):
def __init__(self, analog_events):
super().__init__(
f"Cannot map a combination of multiple analog inputs: {analog_events}"
"add trigger points (event.value != 0) to map as a button"
)
class SymbolNotAvailableInTargetError(ValueError):
def __init__(self, symbol, target):
code = keyboard_layout.get(symbol)
fitting_targets = GlobalUInputs.find_fitting_default_uinputs(EV_KEY, code)
fitting_targets_string = '", "'.join(fitting_targets)
super().__init__(
f'The output_symbol "{symbol}" is not available for the "{target}" '
+ f'target. Try "{fitting_targets_string}".'
)
class OutputSymbolUnknownError(ValueError):
def __init__(self, symbol: str):
super().__init__(
f'The output_symbol "{symbol}" is not a macro and not a valid '
+ "keycode-name"
)
class MacroButTypeOrCodeSetError(ValueError):
def __init__(self):
super().__init__(
"output_symbol is a macro: output_type " "and output_code must be None"
)
class SymbolAndCodeMismatchError(ValueError):
def __init__(self, symbol, code):
super().__init__(
"output_symbol and output_code mismatch: "
f"output macro is {symbol} -> {keyboard_layout.get(symbol)} "
f"but output_code is {code} -> {keyboard_layout.get_name(code)} "
)
class WrongMappingTypeForKeyError(ValueError):
def __init__(self):
super().__init__("Wrong mapping_type for key input")
class MissingMacroOrKeyError(ValueError):
def __init__(self):
super().__init__("Missing macro or key")
class MissingOutputAxisError(ValueError):
def __init__(self, analog_input_config, output_type):
super().__init__(
"Missing output axis: "
f'"{analog_input_config}" is used as analog input, '
f"but the {output_type = } is not an axis "
)
class MacroError(ValueError):
"""Macro syntax errors."""
def __init__(self, symbol: Optional[str] = None, msg="Error while parsing a macro"):
self.symbol = symbol
super().__init__(msg)
def pydantify(error: type):
"""Generate a string as it would appear IN pydantic error types.
This does not include the base class name, which is transformed to snake case in
pydantic. Example pydantic error type: "value_error.foobar" for FooBarError.
"""
# See https://github.com/pydantic/pydantic/discussions/5112
lower_classname = error.__name__.lower()
if lower_classname.endswith("error"):
return lower_classname[: -len("error")]
return lower_classname

548
inputremapper/daemon.py Normal file
View File

@ -0,0 +1,548 @@
# -*- 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/>.
"""Starts injecting keycodes based on the configuration.
https://github.com/dasbus-project/dasbus/tree/master/examples/03_helloworld
"""
import atexit
import json
import os
import sys
import time
from pathlib import PurePath
from typing import Dict, Optional, Protocol
import gi
from dasbus.error import DBusError
from dasbus.connection import SystemMessageBus
from dasbus.identifier import DBusServiceIdentifier
from dasbus.loop import EventLoop
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.paths import PathUtils
from inputremapper.configs.preset import Preset
from inputremapper.groups import groups
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.injector import Injector, InjectorState
from inputremapper.injection.macros.macro import macro_variables
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
from inputremapper.logging.logger import logger
from inputremapper.user import UserUtils
gi.require_version("GLib", "2.0")
SYSTEM_BUS = SystemMessageBus()
DAEMON = DBusServiceIdentifier(
namespace=("inputremapper", "Control"),
message_bus=SYSTEM_BUS,
)
# timeout in milliseconds
BUS_TIMEOUT = 10000
class AutoloadHistory:
"""Contains the autoloading history and constraints."""
def __init__(self):
"""Construct this with an empty history."""
# preset of device -> (timestamp, preset)
self._autoload_history = {}
def remember(self, group_key: str, preset: str):
"""Remember when this preset was autoloaded for the device."""
self._autoload_history[group_key] = (time.time(), preset)
def forget(self, group_key: str):
"""The injection was stopped or started by hand."""
if group_key in self._autoload_history:
del self._autoload_history[group_key]
def may_autoload(self, group_key: str, preset: str):
"""Check if this autoload would be redundant.
This is needed because udev triggers multiple times per hardware
device, and because it should be possible to stop the injection
by unplugging the device if the preset goes wrong or if input-remapper
has some bug that prevents the computer from being controlled.
For that unplug and reconnect the device twice within a 15 seconds
timeframe which will then not ask for autoloading again. Wait 3
seconds between replugging.
"""
if group_key not in self._autoload_history:
return True
if self._autoload_history[group_key][1] != preset:
return True
# bluetooth devices go to standby mode after some time. After a
# certain time of being disconnected it should be legit to autoload
# again. It takes 2.5 seconds for me when quickly replugging my usb
# mouse until the daemon is asked to autoload again. Redundant calls
# by udev to autoload for the device seem to happen within 0.2
# seconds in my case.
now = time.time()
threshold = 15 # seconds
if self._autoload_history[group_key][0] < now - threshold:
return True
return False
class DaemonProxy(Protocol): # pragma: no cover
"""The interface provided over the dbus."""
def stop_injecting(self, group_key: str) -> None: ...
def get_state(self, group_key: str) -> InjectorState: ...
def start_injecting(self, group_key: str, preset: str) -> bool: ...
def stop_all(self) -> None: ...
def set_config_dir(self, config_dir: str) -> None: ...
def autoload(self) -> None: ...
def autoload_single(self, group_key: str) -> None: ...
def hello(self, out: str) -> str: ...
def quit(self) -> None: ...
class Daemon:
"""Starts injecting keycodes based on the configuration.
Can be talked to either over dbus or by instantiating it.
The Daemon may not have any knowledge about the logged in user, so it
can't read any config files. It has to be told what to do and will
continue to do so afterwards, but it can't decide to start injecting
on its own.
"""
# https://dbus.freedesktop.org/doc/dbus-specification.html#type-system
__dbus_xml__ = f"""
<node>
<interface name='{DAEMON.interface_name}'>
<method name='stop_injecting'>
<arg type='s' name='group_key' direction='in'/>
</method>
<method name='get_state'>
<arg type='s' name='group_key' direction='in'/>
<arg type='s' name='response' direction='out'/>
</method>
<method name='start_injecting'>
<arg type='s' name='group_key' direction='in'/>
<arg type='s' name='preset' direction='in'/>
<arg type='b' name='response' direction='out'/>
</method>
<method name='stop_all'>
</method>
<method name='set_config_dir'>
<arg type='s' name='config_dir' direction='in'/>
</method>
<method name='autoload'>
</method>
<method name='autoload_single'>
<arg type='s' name='group_key' direction='in'/>
</method>
<method name='hello'>
<arg type='s' name='out' direction='in'/>
<arg type='s' name='response' direction='out'/>
</method>
<method name='quit'>
</method>
</interface>
</node>
"""
def __init__(
self,
global_config: GlobalConfig,
global_uinputs: GlobalUInputs,
mapping_parser: MappingParser,
) -> None:
"""Constructs the daemon."""
logger.debug("Creating daemon")
self.global_config = global_config
self.global_uinputs = global_uinputs
self.mapping_parser = mapping_parser
self.injectors: Dict[str, Injector] = {}
self.config_dir = None
if UserUtils.user != "root":
# If started via sudo, UserUtils.user is the actual user, and the config
# path is valid. If running as a systemd service, it is root, and the path
# invalid.
self.set_config_dir(PathUtils.get_config_path())
# check privileges
if os.getuid() != 0:
logger.warning("The service usually needs elevated privileges")
self.autoload_history = AutoloadHistory()
self.refreshed_devices_at = 0
atexit.register(self.stop_all)
# initialize stuff that is needed alongside the daemon process
macro_variables.start()
@classmethod
def connect(cls, fallback: bool = True) -> Optional[DaemonProxy]:
"""Get a proxy to start and stop injecting keystrokes.
Parameters
----------
fallback
If true, starts the daemon via pkexec if it cannot connect.
"""
try:
proxy = DAEMON.get_proxy()
# Calling proxy.Introspect to check if the proxy is running
# https://dbus.freedesktop.org/doc/dbus-tutorial.html#introspection
proxy.Introspect(timeout=BUS_TIMEOUT)
logger.info("Connected to the service")
except DBusError as error:
if not fallback:
logger.error("Service not running? %s", error)
return None
logger.info("Starting the service")
# Blocks until pkexec is done asking for the password.
# Runs via input-remapper-control so that auth_admin_keep works
# for all pkexec calls of the gui
debug = " -d" if logger.is_debug() else ""
cmd = f"pkexec input-remapper-control --command start-daemon {debug}"
# using pkexec will also cause the service to continue running in
# the background after the gui has been closed, which will keep
# the injections ongoing
logger.debug("Running `%s`", cmd)
os.system(cmd)
time.sleep(0.2)
# try a few times if the service was just started
for attempt in range(3):
try:
proxy = DAEMON.get_proxy()
proxy.Introspect(timeout=BUS_TIMEOUT)
break
except DBusError as error:
logger.debug("Attempt %d to reach the service failed:", attempt + 1)
logger.debug('"%s"', error)
time.sleep(0.2)
else:
logger.error("Failed to connect to the service")
sys.exit(8)
if UserUtils.user != "root":
config_path = PathUtils.get_config_path()
logger.debug('Telling service about "%s"', config_path)
proxy.set_config_dir(PathUtils.get_config_path(), timeout=2000)
return proxy
def publish(self) -> None:
"""Make the dbus interface available."""
try:
SYSTEM_BUS.publish_object(DAEMON.object_path, self)
SYSTEM_BUS.register_service(DAEMON.service_name)
except ConnectionError as error:
logger.error("Is the service already running? (%s)", str(error))
sys.exit(9)
def run(self) -> None:
"""Start the daemons loop. Blocks until the daemon stops."""
loop = EventLoop()
logger.debug("Running daemon")
loop.run()
def refresh(self, group_key: Optional[str] = None) -> None:
"""Refresh groups if the specified group is unknown.
Parameters
----------
group_key
unique identifier used by the groups object
"""
now = time.time()
if now - 10 > self.refreshed_devices_at:
logger.debug("Refreshing because last info is too old")
# it may take a bit of time until devices are visible after changes
time.sleep(0.1)
groups.refresh()
self.refreshed_devices_at = now
return
if not groups.find(key=group_key):
logger.debug('Refreshing because "%s" is unknown', group_key)
time.sleep(0.1)
groups.refresh()
self.refreshed_devices_at = now
def stop_injecting(self, group_key: str) -> None:
"""Stop injecting the preset mappings for a single device."""
if self.injectors.get(group_key) is None:
logger.debug(
'Tried to stop injector, but none is running for group "%s"',
group_key,
)
return
self.injectors[group_key].stop_injecting()
self.autoload_history.forget(group_key)
def get_state(self, group_key: str) -> InjectorState:
"""Get the injectors state."""
injector = self.injectors.get(group_key)
return injector.get_state() if injector else InjectorState.UNKNOWN
def set_config_dir(self, config_dir: str) -> None:
"""All future operations will use this config dir.
Existing injections (possibly of the previous user) will be kept
alive, call stop_all to stop them.
Parameters
----------
config_dir
This path contains config.json, xmodmap.json and the
presets directory
"""
config_path = PurePath(config_dir, "config.json")
if not os.path.exists(config_path):
logger.error('"%s" does not exist', config_path)
return
self.config_dir = config_dir
self.global_config.load_config(str(config_path))
def _autoload(self, group_key: str) -> None:
"""Check if autoloading is a good idea, and if so do it.
Parameters
----------
group_key
unique identifier used by the groups object
"""
self.refresh(group_key)
group = groups.find(key=group_key)
if group is None:
# even after groups.refresh, the device is unknown, so it's
# either not relevant for input-remapper, or not connected yet
return
preset = self.global_config.get_autoload_preset(group.key)
if preset is None:
# no autoloading is configured for this device
return
if not isinstance(preset, str):
# maybe another dict or something, who knows. Broken config
logger.error("Expected a string for autoload, but got %s", preset)
return
logger.info('Autoloading for "%s"', group.key)
if not self.autoload_history.may_autoload(group.key, preset):
logger.info(
'Not autoloading the same preset "%s" again for group "%s"',
preset,
group.key,
)
return
self.start_injecting(group.key, preset)
self.autoload_history.remember(group.key, preset)
def autoload_single(self, group_key: str) -> None:
"""Inject the configured autoload preset for the device.
If the preset is already being injected, it won't autoload it again.
Parameters
----------
group_key
unique identifier used by the groups object
"""
# avoid some confusing logs and filter obviously invalid requests
if group_key.startswith("input-remapper"):
return
logger.info('Request to autoload for "%s"', group_key)
if self.config_dir is None:
logger.error(
'Request to autoload "%s" before a user told the service about their '
"session using set_config_dir",
group_key,
)
return
self._autoload(group_key)
def autoload(self) -> None:
"""Load all autoloaded presets for the current config_dir.
If the preset is already being injected, it won't autoload it again.
"""
if self.config_dir is None:
logger.error(
"Request to autoload all before a user told the service about their "
"session using set_config_dir",
)
return
autoload_presets = list(self.global_config.iterate_autoload_presets())
logger.info("Autoloading for all devices")
if len(autoload_presets) == 0:
logger.error("No presets configured to autoload")
return
for group_key, _ in autoload_presets:
self._autoload(group_key)
def start_injecting(self, group_key: str, preset_name: str) -> bool:
"""Start injecting the preset for the device.
Returns True on success. If an injection is already ongoing for
the specified device it will stop it automatically first.
Parameters
----------
group_key
The unique key of the group
preset_name
The name of the preset
"""
logger.info('Request to start injecting for "%s"', group_key)
self.refresh(group_key)
if self.config_dir is None:
logger.error(
"Request to start an injectoin before a user told the service about "
"their session using set_config_dir",
)
return False
group = groups.find(key=group_key)
if group is None:
logger.error('Could not find group "%s"', group_key)
return False
preset_path = PurePath(
self.config_dir,
"presets",
PathUtils.sanitize_path_component(group.name),
f"{preset_name}.json",
)
# Path to a dump of the xkb mappings, to provide more human
# readable keys in the correct keyboard layout to the service.
# The service cannot use `xmodmap -pke` because it's running via
# systemd.
xmodmap_path = os.path.join(self.config_dir, "xmodmap.json")
try:
with open(xmodmap_path, "r") as file:
# do this for each injection to make sure it is up to
# date when the system layout changes.
xmodmap = json.load(file)
logger.debug('Using keycodes from "%s"', xmodmap_path)
# this creates the keyboard_layout._xmodmap, which we need to do now
# otherwise it might be created later which will override the changes
# we do here.
# Do we really need to lazyload in the keyboard_layout?
# this kind of bug is stupid to track down
keyboard_layout.get_name(0)
keyboard_layout.update(xmodmap)
# the service now has process wide knowledge of xmodmap
# keys of the users session
except FileNotFoundError:
logger.error('Could not find "%s"', xmodmap_path)
preset = Preset(preset_path)
try:
preset.load()
except FileNotFoundError as error:
logger.error(str(error))
return False
for mapping in preset:
# only create those uinputs that are required to avoid
# confusing the system. Seems to be especially important with
# gamepads, because some apps treat the first gamepad they found
# as the only gamepad they'll ever care about.
self.global_uinputs.prepare_single(mapping.target_uinput)
if self.injectors.get(group_key) is not None:
self.stop_injecting(group_key)
try:
injector = Injector(
group,
preset,
self.mapping_parser,
)
injector.start()
self.injectors[group.key] = injector
except OSError:
# I think this will never happen, probably leftover from
# some earlier version
return False
return True
def stop_all(self) -> None:
"""Stop all injections."""
logger.info("Stopping all injections")
for group_key in list(self.injectors.keys()):
self.stop_injecting(group_key)
def hello(self, out: str) -> str:
"""Used for tests."""
logger.info('Received "%s" from client', out)
return out
def quit(self) -> None:
"""Stop the process."""
# Beware, that stop_all will also be called via atexit.register(self.stop_all)
logger.info("Got command to stop the daemon process")
sys.exit(0)

View File

@ -0,0 +1,65 @@
# -*- 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/>.
"""Exceptions specific to inputremapper."""
class Error(Exception):
"""Base class for exceptions in inputremapper.
We can catch all inputremapper exceptions with this.
"""
class UinputNotAvailable(Error):
"""If an expected UInput is not found (anymore)."""
def __init__(self, name: str):
super().__init__(f"{name} is not defined or unplugged")
class EventNotHandled(Error):
"""For example mapping to BTN_LEFT on a keyboard target."""
def __init__(self, event):
super().__init__(f"Event {event} can not be handled by the configured target")
class MappingParsingError(Error):
"""Anything that goes wrong during the creation of handlers from the mapping."""
def __init__(self, msg: str, *, mapping=None, mapping_handler=None):
self.mapping_handler = mapping_handler
self.mapping = mapping
super().__init__(msg)
class InputEventCreationError(Error):
"""An input-event failed to be created due to broken factory/constructor calls."""
def __init__(self, msg: str):
super().__init__(msg)
class DataManagementError(Error):
"""Any error that happens in the DataManager."""
def __init__(self, msg: str):
super().__init__(msg)

567
inputremapper/groups.py Normal file
View File

@ -0,0 +1,567 @@
# -*- 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/>.
"""Find, classify and group devices.
Because usually connected devices pop up multiple times in /dev/input,
in order to provide multiple types of input devices (e.g. a keyboard and a
graphics-tablet at the same time)
Those groups are what is being displayed in the device dropdown, and
events are being read from all of the paths of an individual group in the gui
and the injector.
"""
from __future__ import annotations
import asyncio
import enum
import json
import multiprocessing
import os
import re
import threading
from typing import List, Optional
import evdev
from evdev.ecodes import (
EV_KEY,
EV_ABS,
KEY_CAMERA,
EV_REL,
BTN_STYLUS,
ABS_MT_POSITION_X,
REL_X,
KEY_A,
BTN_LEFT,
REL_Y,
REL_WHEEL,
)
from inputremapper.configs.paths import PathUtils
from inputremapper.logging.logger import logger
from inputremapper.utils import get_device_hash
TABLET_KEYS = [
evdev.ecodes.BTN_STYLUS,
evdev.ecodes.BTN_TOOL_BRUSH,
evdev.ecodes.BTN_TOOL_PEN,
evdev.ecodes.BTN_TOOL_RUBBER,
]
class DeviceType(str, enum.Enum):
GAMEPAD = "gamepad"
KEYBOARD = "keyboard"
MOUSE = "mouse"
TOUCHPAD = "touchpad"
GRAPHICS_TABLET = "graphics-tablet"
CAMERA = "camera"
UNKNOWN = "unknown"
if not hasattr(evdev.InputDevice, "path"):
# for evdev < 1.0.0 patch the path property
@property
def path(device):
return device.fn
evdev.InputDevice.path = path
def _is_gamepad(capabilities):
"""Check if joystick movements are available for preset."""
# A few buttons that indicate a gamepad
buttons = {
evdev.ecodes.BTN_BASE,
evdev.ecodes.BTN_A,
evdev.ecodes.BTN_THUMB,
evdev.ecodes.BTN_TOP,
evdev.ecodes.BTN_DPAD_DOWN,
evdev.ecodes.BTN_GAMEPAD,
}
if not buttons.intersection(capabilities.get(EV_KEY, [])):
# no button is in the key capabilities
return False
# joysticks
abs_capabilities = capabilities.get(EV_ABS, [])
if evdev.ecodes.ABS_X not in abs_capabilities:
return False
if evdev.ecodes.ABS_Y not in abs_capabilities:
return False
return True
def _is_mouse(capabilities):
"""Check if the capabilities represent those of a mouse."""
# Based on observation, those capabilities need to be present to get an
# UInput recognized as mouse
# mouse movements
if REL_X not in capabilities.get(EV_REL, []):
return False
if REL_Y not in capabilities.get(EV_REL, []):
return False
# at least the vertical mouse wheel
if REL_WHEEL not in capabilities.get(EV_REL, []):
return False
# and a mouse click button
if BTN_LEFT not in capabilities.get(EV_KEY, []):
return False
return True
def _is_graphics_tablet(capabilities):
"""Check if the capabilities represent those of a graphics tablet."""
if BTN_STYLUS in capabilities.get(EV_KEY, []):
return True
return False
def _is_touchpad(capabilities):
"""Check if the capabilities represent those of a touchpad."""
if ABS_MT_POSITION_X in capabilities.get(EV_ABS, []):
return True
return False
def _is_keyboard(capabilities):
"""Check if the capabilities represent those of a keyboard."""
if KEY_A in capabilities.get(EV_KEY, []):
return True
return False
def _is_camera(capabilities):
"""Check if the capabilities represent those of a camera."""
key_capa = capabilities.get(EV_KEY)
return key_capa and len(key_capa) == 1 and key_capa[0] == KEY_CAMERA
def classify(device) -> DeviceType:
"""Figure out what kind of device this is.
Use this instead of functions like _is_keyboard to avoid getting false
positives.
"""
capabilities = device.capabilities(absinfo=False)
if _is_graphics_tablet(capabilities):
# check this before is_gamepad to avoid classifying abs_x
# as joysticks when they are actually stylus positions
return DeviceType.GRAPHICS_TABLET
if _is_touchpad(capabilities):
return DeviceType.TOUCHPAD
if _is_gamepad(capabilities):
return DeviceType.GAMEPAD
if _is_mouse(capabilities):
return DeviceType.MOUSE
if _is_camera(capabilities):
return DeviceType.CAMERA
if _is_keyboard(capabilities):
# very low in the chain to avoid classifying most devices
# as keyboard, because there are many with ev_key capabilities
return DeviceType.KEYBOARD
return DeviceType.UNKNOWN
DENYLIST = [".*Yubico.*YubiKey.*", "Eee PC WMI hotkeys"]
def is_inputremapper_device(device: evdev.InputDevice) -> bool:
"""Return whether the device was created by input-remapper."""
name = str(device.name or "")
phys = str(device.phys or "")
return name.startswith("input-remapper") or phys.startswith("input-remapper")
def is_denylisted(device: evdev.InputDevice):
"""Check if a device should not be used in input-remapper.
Parameters
----------
device
"""
for name in DENYLIST:
if re.match(name, str(device.name), re.IGNORECASE):
return True
return False
def get_unique_key(device: evdev.InputDevice):
"""Find a string key that is unique for a single hardware device.
All InputDevices in /dev/input that originate from the same physical
hardware device should return the same key via this function.
"""
# Keys that should not be used:
# - device.phys is empty sometimes and varies across virtual
# subdevices
# - device.version varies across subdevices
return (
# device.info bustype, vendor and product are unique for
# a product, but multiple similar device models would be grouped
# in the same group
f"{device.info.bustype}_"
f"{device.info.vendor}_"
f"{device.info.product}_"
# device.uniq is empty most of the time. It seems to be the only way to
# distinguish multiple connected bluetooth gamepads
f"{device.uniq}_"
# deivce.phys if "/input..." is removed from it, because the first
# chunk seems to be unique per hardware (if it's not completely empty)
f'{device.phys.split("/")[0] or "-"}'
)
class _Group:
"""Groups multiple devnodes together.
For example, name could be "Logitech USB Keyboard", devices
might contain "Logitech USB Keyboard System Control" and "Logitech USB
Keyboard". paths is a list of files in /dev/input that belong to the
devices.
They are grouped by usb port.
Members
-------
name : str
A human readable name, generated from .names, that should always
look the same for a device model. It is used to generate the
presets folder structure
"""
def __init__(
self,
paths: List[os.PathLike],
names: List[str],
types: List[DeviceType | str],
key: str,
):
"""Specify a group
Parameters
----------
paths
Paths in /dev/input of the grouped devices
names
Names of the grouped devices
types
Types of the grouped devices
key
Unique identifier of the group.
It should be human readable and if possible equal to group.name.
To avoid multiple groups having the same key, a number starting
with 2 followed by a whitespace should be added to it:
"key", "key 2", "key 3", ...
This is important for the autoloading configuration. If the key
changed over reboots, then autoloading would break.
"""
# There might be multiple groups with the same name here when two
# similar devices are connected to the computer.
self.name: str = sorted(names, key=len)[0]
self.key = key
self.paths = paths
self.names = names
self.types = [DeviceType(type_) for type_ in types]
def get_preset_path(self, preset: Optional[str] = None):
"""Get a path to the stored preset, or to store a preset to.
This path is unique per device-model, not per group. Groups
of the same model share the same preset paths.
"""
return PathUtils.get_preset_path(self.name, preset)
def get_devices(self) -> List[evdev.InputDevice]:
devices: List[evdev.InputDevice] = []
for path in self.paths:
try:
devices.append(evdev.InputDevice(path))
except (FileNotFoundError, OSError):
logger.error('Could not find "%s"', path)
continue
return devices
def dumps(self):
"""Return a string representing this object."""
return json.dumps(
dict(paths=self.paths, names=self.names, types=self.types, key=self.key),
)
@classmethod
def loads(cls, serialized: str):
"""Load a serialized representation."""
group = cls(**json.loads(serialized))
return group
def __repr__(self):
return f"<Group ({self.key}) at {hex(id(self))}>"
class _FindGroups(threading.Thread):
"""Thread to get the devices that can be worked with.
Since InputDevice destructors take quite some time, do this
asynchronously so that they can take as much time as they want without
slowing down the initialization.
"""
def __init__(self, pipe: multiprocessing.Pipe):
"""Construct the process.
Parameters
----------
pipe
used to communicate the result
"""
self.pipe = pipe
super().__init__()
def run(self):
"""Do what get_groups describes."""
# evdev needs asyncio to work
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
logger.debug("Discovering device paths")
# group them together by usb device because there could be stuff like
# "Logitech USB Keyboard" and "Logitech USB Keyboard Consumer Control"
grouped = {}
# Sorting the paths is extremely important if two devices of the same make
# (two identical mice for example) are connected. Without sorting, the order
# depends on the order of plugging devices in. This causes the most recently
# plugged in device to get the first group.
# Without sorting:
# - mouse1 /input1 plugged in. group.key: "Mouse". Autoloads as configured.
# - mouse2 /input2 plugged in. group.key: "Mouse". mouse1 gets a different
# group.key: "Mouse 2", even though an injection is running there. Bug.
# - I believe this is what causes input-remapper to stop the injection for
# mouse1 and then start it for mouse2 instead. So plugging in a second
# device breaks autoloading. Sorting fixes it.
# With sorting, mouse1 always gets group.key "Mouse", and mouse2 always
# "Mouse 2" (Unless only mouse2 is plugged in, then it gets "Mouse")
for path in sorted(evdev.list_devices()):
try:
device = evdev.InputDevice(path)
except Exception as error:
# Observed exceptions in journalctl:
# - "SystemError: <built-in function ioctl_EVIOCGVERSION> returned NULL
# without setting an error"
# - "FileNotFoundError: [Errno 2] No such file or directory:
# '/dev/input/event12'"
logger.error(
'Failed to access path "%s": %s %s',
path,
error.__class__.__name__,
str(error),
)
continue
if device.name == "Power Button":
continue
if is_inputremapper_device(device):
logger.debug('Skipping input-remapper device "%s"', device.name)
continue
device_type = classify(device)
if device_type == DeviceType.CAMERA:
continue
# https://www.kernel.org/doc/html/latest/input/event-codes.html
capabilities = device.capabilities(absinfo=False)
key_capa = capabilities.get(EV_KEY)
abs_capa = capabilities.get(EV_ABS)
rel_capa = capabilities.get(EV_REL)
if key_capa is None and abs_capa is None and rel_capa is None:
# skip devices that don't provide buttons or axes that can be mapped
logger.debug('"%s" has no useful capabilities', device.name)
continue
if is_denylisted(device):
logger.debug('"%s" is denylisted', device.name)
continue
key = get_unique_key(device)
if grouped.get(key) is None:
grouped[key] = []
logger.debug(
'Found %s "%s" at "%s", hash "%s", key "%s"',
device_type.value,
device.name,
path,
get_device_hash(device),
key,
)
grouped[key].append((device.name, path, device_type))
# now write down all the paths of that group
result = []
used_keys = set()
for group in grouped.values():
names = [entry[0] for entry in group]
devs = [entry[1] for entry in group]
# generate a human readable key
shortest_name = sorted(names, key=len)[0]
key = shortest_name
i = 2
while key in used_keys:
key = f"{shortest_name} {i}"
i += 1
used_keys.add(key)
logger.debug('Creating group with key "%s", paths "%s"', key, devs)
group = _Group(
key=key,
paths=devs,
names=names,
types=sorted(
list({item[2] for item in group if item[2] != DeviceType.UNKNOWN})
),
)
result.append(group.dumps())
self.pipe.send(json.dumps(result))
loop.close() # avoid resource allocation warnings
# now that everything is sent via the pipe, the InputDevice
# destructors can go on and take ages to complete in the thread
# without blocking anything
class _Groups:
"""Contains and manages all groups."""
def __init__(self):
self._groups: List[_Group] = None
def refresh(self):
"""Look for devices and group them together.
Since this needs to do some stuff with /dev and spawn processes the
result is cached. Use refresh_groups if you need up to date
devices.
"""
pipe = multiprocessing.Pipe()
_FindGroups(pipe[1]).start()
# block until groups are available
message = pipe[0].recv()
self.loads(message)
if len(self._groups) == 0:
logger.error(
"Did not find any input device, possibly due to missing permissions"
)
else:
keys = [f'"{group.key}"' for group in self._groups]
logger.info("Found %s", ", ".join(keys))
def get_groups(self) -> List[_Group]:
"""Load groups and return them."""
if self._groups is None:
# To lazy load group info only when needed.
# For example, this helps to keep logs of input-remapper-control clear when
# it doesn't need it the information.
self.refresh()
return list(self._groups)
def set_groups(self, new_groups: List[_Group]):
"""Overwrite all groups."""
logger.debug("Overwriting groups with %s", new_groups)
self._groups = new_groups
def list_group_names(self) -> List[str]:
"""Return a list of all 'name' properties of the groups."""
return [
group.name
for group in self._groups
if not group.name.startswith("input-remapper")
]
def dumps(self):
"""Create a deserializable string representation."""
groups = self.get_groups()
return json.dumps([group.dumps() for group in groups])
def loads(self, dump: str):
"""Load a serialized representation created via dumps."""
self._groups = [_Group.loads(group) for group in json.loads(dump)]
def find(
self,
name: Optional[str] = None,
key: Optional[str] = None,
path: Optional[str] = None,
) -> Optional[_Group]:
"""Find a group that matches the provided parameters.
Parameters
----------
name
"USB Keyboard"
Not unique, will return the first group that matches.
key
"USB Keyboard", "USB Keyboard 2", ...
path
"/dev/input/event3"
"""
for group in self.get_groups():
if name and group.name != name:
continue
if key and group.key != key:
continue
if path and path not in group.paths:
continue
return group
return None
# TODO global objects are bad practice
groups = _Groups()

View File

@ -0,0 +1,6 @@
import gi
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
gi.require_version("GLib", "2.0")
gi.require_version("GtkSource", "4")

View File

@ -0,0 +1,457 @@
# -*- 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/>.
"""Autocompletion for the editor."""
import re
from typing import Dict, Optional, List, Tuple
from evdev.ecodes import EV_KEY
from gi.repository import Gdk, Gtk, GLib, GObject
from inputremapper.configs.keyboard_layout import keyboard_layout, DISABLE_NAME
from inputremapper.configs.mapping import MappingData
from inputremapper.gui.components.editor import CodeEditor
from inputremapper.gui.controller import Controller
from inputremapper.gui.messages.message_broker import MessageBroker, MessageType
from inputremapper.gui.messages.message_data import UInputsData
from inputremapper.gui.utils import debounce
from inputremapper.injection.macros.parse import Parser
from inputremapper.logging.logger import logger
# no deprecated shorthand function-names
FUNCTION_NAMES = [name for name in Parser.TASK_CLASSES.keys() if len(name) > 1]
# no deprecated functions
FUNCTION_NAMES.remove("ifeq")
Capabilities = Dict[int, List]
def _get_left_text(iter_: Gtk.TextIter) -> str:
buffer = iter_.get_buffer()
result = buffer.get_text(buffer.get_start_iter(), iter_, True)
result = Parser.remove_comments(result)
result = result.replace("\n", " ")
return result.lower()
# regex to search for the beginning of a...
PARAMETER = r".*?[(,=+]\s*"
FUNCTION_CHAIN = r".*?\)\s*\.\s*"
def get_incomplete_function_name(iter_: Gtk.TextIter) -> str:
"""Get the word that is written left to the TextIter."""
left_text = _get_left_text(iter_)
# match foo in:
# bar().foo
# bar()\n.foo
# bar().\nfoo
# bar(\nfoo
# bar(\nqux=foo
# bar(KEY_A,\nfoo
# foo
match = re.match(rf"(?:{FUNCTION_CHAIN}|{PARAMETER}|^)(\w+)$", left_text)
logger.debug('get_incomplete_function_name text: "%s" match: %s', left_text, match)
if match is None:
return ""
return match[1]
def get_incomplete_parameter(iter_: Gtk.TextIter) -> Optional[str]:
"""Get the parameter that is written left to the TextIter."""
left_text = _get_left_text(iter_)
# match foo in:
# bar(foo
# bar(a=foo
# bar(qux, foo
# foo
# bar + foo
match = re.match(rf"(?:{PARAMETER}|^)(\w+)$", left_text)
logger.debug('get_incomplete_parameter text: "%s" match: %s', left_text, match)
if match is None:
return None
return match[1]
def propose_symbols(text_iter: Gtk.TextIter, codes: List[int]) -> List[Tuple[str, str]]:
"""Find key names that match the input at the cursor and are mapped to the codes."""
incomplete_name = get_incomplete_parameter(text_iter)
if incomplete_name is None or len(incomplete_name) <= 1:
return []
incomplete_name = incomplete_name.lower()
names = list(keyboard_layout.list_names(codes=codes)) + [DISABLE_NAME]
return [
(name, name)
for name in names
if incomplete_name in name.lower() and incomplete_name != name.lower()
]
def propose_function_names(text_iter: Gtk.TextIter) -> List[Tuple[str, str]]:
"""Find function names that match the input at the cursor."""
incomplete_name = get_incomplete_function_name(text_iter)
if incomplete_name is None or len(incomplete_name) <= 1:
return []
incomplete_name = incomplete_name.lower()
# A list of
# - ("key", "key(symbol)")
# - ("repeat", "repeat(repeats, macro)")
# etc.
function_names: List[Tuple[str, str]] = []
for name in FUNCTION_NAMES:
if incomplete_name in name.lower() and incomplete_name != name.lower():
task_class = Parser.TASK_CLASSES[name]
argument_names = task_class.get_macro_argument_names()
function_names.append((name, f"{name}({', '.join(argument_names)})"))
return function_names
class SuggestionLabel(Gtk.Label):
"""A label with some extra internal information."""
__gtype_name__ = "SuggestionLabel"
def __init__(self, display_name: str, suggestion: str):
super().__init__(label=display_name)
self.suggestion = suggestion
class Autocompletion(Gtk.Popover):
"""Provide keyboard-controllable beautiful autocompletions.
The one provided via source_view.get_completion() is not very appealing
"""
__gtype_name__ = "Autocompletion"
_target_uinput: Optional[str] = None
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
code_editor: CodeEditor,
):
"""Create an autocompletion popover.
It will remain hidden until there is something to autocomplete.
Parameters
----------
code_editor
The widget that contains the text that should be autocompleted
"""
super().__init__(
# Don't switch the focus to the popover when it shows
modal=False,
# Always show the popover below the cursor, don't move it to a different
# position based on the location within the window
constrain_to=Gtk.PopoverConstraint.NONE,
)
self.code_editor = code_editor
self.controller = controller
self.message_broker = message_broker
self._uinputs: Optional[Dict[str, Capabilities]] = None
self._target_key_capabilities: List[int] = []
self.scrolled_window = Gtk.ScrolledWindow(
min_content_width=200,
max_content_height=200,
propagate_natural_width=True,
propagate_natural_height=True,
)
self.list_box = Gtk.ListBox()
self.list_box.get_style_context().add_class("transparent")
self.scrolled_window.add(self.list_box)
# row-activated is on-click,
# row-selected is when scrolling through it
self.list_box.connect(
"row-activated",
self._on_suggestion_clicked,
)
self.add(self.scrolled_window)
self.get_style_context().add_class("autocompletion")
self.set_position(Gtk.PositionType.BOTTOM)
self.code_editor.gui.connect("key-press-event", self.navigate)
# add some delay, so that pressing the button in the completion works before
# the popover is hidden due to focus-out-event
self.code_editor.gui.connect("focus-out-event", self.on_gtk_text_input_unfocus)
self.code_editor.gui.get_buffer().connect("changed", self.update)
self.set_position(Gtk.PositionType.BOTTOM)
self.visible = False
self.attach_to_events()
self.show_all()
self.popdown() # hidden by default. this needs to happen after show_all!
def attach_to_events(self):
self.message_broker.subscribe(MessageType.mapping, self._on_mapping_changed)
self.message_broker.subscribe(MessageType.uinputs, self._on_uinputs_changed)
def on_gtk_text_input_unfocus(self, *_):
"""The code editor was unfocused."""
GLib.timeout_add(100, self.popdown)
# "(input-remapper-gtk:97611): Gtk-WARNING **: 16:33:56.464: GtkTextView -
# did not receive focus-out-event. If you connect a handler to this signal,
# it must return FALSE so the text view gets the event as well"
return False
def navigate(self, _, event: Gdk.EventKey):
"""Using the keyboard to select an autocompletion suggestion."""
if not self.visible:
return
if event.keyval == Gdk.KEY_Escape:
self.popdown()
return
selected_row = self.list_box.get_selected_row()
if event.keyval not in [Gdk.KEY_Down, Gdk.KEY_Up, Gdk.KEY_Return]:
# not one of the keys that controls the autocompletion. Deselect
# the row but keep it open
self.list_box.select_row(None)
return
if event.keyval == Gdk.KEY_Return:
if selected_row is None:
# nothing selected, forward the event to the text editor
return
# a row is selected and should be used for autocompletion
self.list_box.emit("row-activated", selected_row)
return Gdk.EVENT_STOP
num_rows = len(self.list_box.get_children())
if selected_row is None:
# select the first row
if event.keyval == Gdk.KEY_Down:
new_selected_row = self.list_box.get_row_at_index(0)
if event.keyval == Gdk.KEY_Up:
new_selected_row = self.list_box.get_row_at_index(num_rows - 1)
else:
# select the next row
selected_index = selected_row.get_index()
new_index = selected_index
if event.keyval == Gdk.KEY_Down:
new_index += 1
if event.keyval == Gdk.KEY_Up:
new_index -= 1
if new_index < 0:
new_index = num_rows - 1
if new_index > num_rows - 1:
new_index = 0
new_selected_row = self.list_box.get_row_at_index(new_index)
self.list_box.select_row(new_selected_row)
self._scroll_to_row(new_selected_row)
# don't change editor contents
return Gdk.EVENT_STOP
def _scroll_to_row(self, row: Gtk.ListBoxRow):
"""Scroll up or down so that the row is visible."""
# unfortunately, it seems that without focusing the row it won't happen
# automatically (or whatever the reason for this is, just a wild guess)
# (the focus should not leave the code editor, so that continuing
# to write code is possible), so here is a custom solution.
row_height = row.get_allocation().height
list_box_height = self.list_box.get_allocated_height()
if row:
# get coordinate relative to the list_box,
# measured from the top of the selected row to the top of the list_box
row_y_position = row.translate_coordinates(self.list_box, 0, 0)[1]
# Depending on the theme, the y_offset will be > 0, even though it
# is the uppermost element, due to margins/paddings.
if row_y_position < row_height:
row_y_position = 0
# if the selected row sits lower than the second to last row,
# then scroll all the way down. otherwise it will only scroll down
# to the bottom edge of the selected-row, which might not actually be the
# bottom of the list-box due to paddings.
if row_y_position > list_box_height - row_height * 1.5:
# using a value that is too high doesn't hurt here.
row_y_position = list_box_height
# the visible height of the scrolled_window. not the content.
height = self.scrolled_window.get_max_content_height()
current_y_scroll = self.scrolled_window.get_vadjustment().get_value()
vadjustment = self.scrolled_window.get_vadjustment()
# for the selected row to still be visible, its y_offset has to be
# at height - row_height. If the y_offset is higher than that, then
# the autocompletion needs to scroll down to make it visible again.
if row_y_position > current_y_scroll + (height - row_height):
value = row_y_position - (height - row_height)
vadjustment.set_value(value)
if row_y_position < current_y_scroll:
# the selected element is not visiable, so we need to scroll up.
vadjustment.set_value(row_y_position)
def _get_text_iter_at_cursor(self):
"""Get Gtk.TextIter at the current text cursor location."""
cursor = self.code_editor.gui.get_cursor_locations()[0]
return self.code_editor.gui.get_iter_at_location(cursor.x, cursor.y)[1]
def popup(self):
self.visible = True
super().popup()
def popdown(self):
self.visible = False
super().popdown()
@debounce(100)
def update(self, *_):
"""Find new autocompletion suggestions and display them. Hide if none."""
if len(self._target_key_capabilities) == 0:
logger.error("No target capabilities available")
return
if not self.code_editor.gui.is_focus():
self.popdown()
return
self.list_box.forall(self.list_box.remove)
# move the autocompletion to the text cursor
cursor = self.code_editor.gui.get_cursor_locations()[0]
# convert it to window coords, because the cursor values will be very large
# when the TextView is in a scrolled down ScrolledWindow.
window_coords = self.code_editor.gui.buffer_to_window_coords(
Gtk.TextWindowType.TEXT, cursor.x, cursor.y
)
cursor.x = window_coords.window_x
cursor.y = window_coords.window_y
cursor.y += 12
if self.code_editor.gui.get_show_line_numbers():
cursor.x += 48
self.set_pointing_to(cursor)
text_iter = self._get_text_iter_at_cursor()
# get a list of (evdev/xmodmap symbol-name, display-name)
suggested_names = propose_function_names(text_iter)
suggested_names += propose_symbols(text_iter, self._target_key_capabilities)
if len(suggested_names) == 0:
self.popdown()
return
self.popup() # ffs was this hard to find
# add visible autocompletion entries
for suggestion, display_name in suggested_names:
label = SuggestionLabel(display_name, suggestion)
self.list_box.insert(label, -1)
label.show_all()
def _update_capabilities(self):
if self._target_uinput and self._uinputs:
self._target_key_capabilities = self._uinputs[self._target_uinput][EV_KEY]
def _on_mapping_changed(self, mapping: MappingData):
self._target_uinput = mapping.target_uinput
self._update_capabilities()
def _on_uinputs_changed(self, data: UInputsData):
self._uinputs = data.uinputs
self._update_capabilities()
def _on_suggestion_clicked(self, _, selected_row):
"""An autocompletion suggestion was selected and should be inserted."""
selected_label = selected_row.get_children()[0]
suggestion = selected_label.suggestion
buffer = self.code_editor.gui.get_buffer()
# make sure to replace the complete unfinished word. Look to the right and
# remove whatever there is
cursor_iter = self._get_text_iter_at_cursor()
right = buffer.get_text(cursor_iter, buffer.get_end_iter(), True)
match = re.match(r"^(\w+)", right)
right = match[1] if match else ""
Gtk.TextView.do_delete_from_cursor(
self.code_editor.gui, Gtk.DeleteType.CHARS, len(right)
)
# do the same to the left
cursor_iter = self._get_text_iter_at_cursor()
left = buffer.get_text(buffer.get_start_iter(), cursor_iter, True)
match = re.match(r".*?(\w+)$", re.sub("\n", " ", left))
left = match[1] if match else ""
Gtk.TextView.do_delete_from_cursor(
self.code_editor.gui, Gtk.DeleteType.CHARS, -len(left)
)
# insert the autocompletion
Gtk.TextView.do_insert_at_cursor(self.code_editor.gui, suggestion)
self.emit("suggestion-inserted")
GObject.signal_new(
"suggestion-inserted",
Autocompletion,
GObject.SignalFlags.RUN_FIRST,
None,
[],
)

View File

View File

@ -0,0 +1,174 @@
# -*- 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/>.
"""Components used in multiple places."""
from __future__ import annotations
from gi.repository import Gtk
from typing import Optional
from inputremapper.configs.mapping import MappingData
from inputremapper.gui.controller import Controller
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import GroupData, PresetData
from inputremapper.gui.utils import HandlerDisabled
class FlowBoxEntry(Gtk.ToggleButton):
"""A device that can be selected in the GUI.
For example a keyboard or a mouse.
"""
__gtype_name__ = "FlowBoxEntry"
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
name: str,
icon_name: Optional[str] = None,
):
super().__init__()
self.icon_name = icon_name
self.message_broker = message_broker
self._controller = controller
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
if icon_name:
icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG)
box.add(icon)
label = Gtk.Label()
label.set_label(name)
self.name = name
# wrap very long names properly
label.set_line_wrap(True)
label.set_line_wrap_mode(2)
# this affeects how many device entries fit next to each other
label.set_width_chars(28)
label.set_max_width_chars(28)
box.add(label)
box.set_margin_top(18)
box.set_margin_bottom(18)
box.set_homogeneous(True)
box.set_spacing(12)
# self.set_relief(Gtk.ReliefStyle.NONE)
self.add(box)
self.show_all()
self.connect("toggled", self._on_gtk_toggle)
def _on_gtk_toggle(self):
raise NotImplementedError
def show_active(self, active):
"""Show the active state without triggering anything."""
with HandlerDisabled(self, self._on_gtk_toggle):
self.set_active(active)
class FlowBoxWrapper:
"""A wrapper for a flowbox that contains FlowBoxEntry widgets."""
def __init__(self, flowbox: Gtk.FlowBox):
self._gui = flowbox
def show_active_entry(self, name: Optional[str]):
"""Activate the togglebutton that matches the name."""
for child in self._gui.get_children():
flow_box_entry: FlowBoxEntry = child.get_children()[0]
flow_box_entry.show_active(flow_box_entry.name == name)
class Breadcrumbs:
"""Writes a breadcrumbs string into a given label."""
def __init__(
self,
message_broker: MessageBroker,
label: Gtk.Label,
show_device_group: bool = False,
show_preset: bool = False,
show_mapping: bool = False,
):
self._message_broker = message_broker
self._gui = label
self._connect_message_listener()
self.show_device_group = show_device_group
self.show_preset = show_preset
self.show_mapping = show_mapping
self._group_key: str = ""
self._preset_name: str = ""
self._mapping_name: str = ""
label.set_max_width_chars(50)
label.set_line_wrap(True)
label.set_line_wrap_mode(2)
self._render()
def _connect_message_listener(self):
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
self._message_broker.subscribe(MessageType.preset, self._on_preset_changed)
self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed)
def _on_preset_changed(self, data: PresetData):
self._preset_name = data.name or ""
self._render()
def _on_group_changed(self, data: GroupData):
self._group_key = data.group_key
self._render()
def _on_mapping_changed(self, mapping_data: MappingData):
self._mapping_name = mapping_data.format_name()
self._render()
def _render(self):
label = []
if self.show_device_group:
label.append(self._group_key or "?")
if self.show_preset:
label.append(self._preset_name or "?")
if self.show_mapping:
label.append(self._mapping_name or "?")
self._gui.set_label(" / ".join(label))

View File

@ -0,0 +1,115 @@
# -*- 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 typing import Optional
from gi.repository import Gtk
from inputremapper.gui.components.common import FlowBoxEntry, FlowBoxWrapper
from inputremapper.gui.components.editor import ICON_PRIORITIES, ICON_NAMES
from inputremapper.gui.components.main import Stack
from inputremapper.gui.controller import Controller
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import (
GroupsData,
GroupData,
DoStackSwitch,
)
from inputremapper.logging.logger import logger
class DeviceGroupEntry(FlowBoxEntry):
"""A device that can be selected in the GUI.
For example a keyboard or a mouse.
"""
__gtype_name__ = "DeviceGroupEntry"
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
icon_name: Optional[str],
group_key: str,
):
super().__init__(
message_broker=message_broker,
controller=controller,
icon_name=icon_name,
name=group_key,
)
self.group_key = group_key
def _on_gtk_toggle(self, *_, **__):
logger.debug('Selecting device "%s"', self.group_key)
self._controller.load_group(self.group_key)
self.message_broker.publish(DoStackSwitch(Stack.presets_page))
class DeviceGroupSelection(FlowBoxWrapper):
"""A wrapper for the container with our groups.
A group is a collection of devices.
"""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
flowbox: Gtk.FlowBox,
):
super().__init__(flowbox)
self._message_broker = message_broker
self._controller = controller
self._gui = flowbox
self._message_broker.subscribe(MessageType.groups, self._on_groups_changed)
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
def _on_groups_changed(self, data: GroupsData):
self._gui.foreach(self._gui.remove)
for group_key, types in data.groups.items():
if len(types) > 0:
device_type = sorted(types, key=ICON_PRIORITIES.index)[0]
icon_name = ICON_NAMES[device_type]
else:
icon_name = None
logger.debug("adding %s to device selection", group_key)
device_group_entry = DeviceGroupEntry(
self._message_broker,
self._controller,
icon_name,
group_key,
)
self._gui.insert(device_group_entry, -1)
if self._controller.data_manager.active_group:
self.show_active_entry(self._controller.data_manager.active_group.key)
def _on_group_changed(self, data: GroupData):
self.show_active_entry(data.group_key)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,134 @@
# -*- 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/>.
"""Components that wrap everything."""
from __future__ import annotations
from gi.repository import Gtk, Pango
from inputremapper.gui.controller import Controller
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import StatusData, DoStackSwitch
from inputremapper.gui.utils import CTX_ERROR, CTX_MAPPING, CTX_WARNING
class Stack:
"""Wraps the Stack, which contains the main menu pages."""
devices_page = 0
presets_page = 1
editor_page = 2
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
stack: Gtk.Stack,
):
self._message_broker = message_broker
self._controller = controller
self._gui = stack
self._message_broker.subscribe(
MessageType.do_stack_switch, self._do_stack_switch
)
def _do_stack_switch(self, msg: DoStackSwitch):
self._gui.set_visible_child(self._gui.get_children()[msg.page_index])
class StatusBar:
"""The status bar on the bottom of the main window."""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
status_bar: Gtk.Statusbar,
error_icon: Gtk.Image,
warning_icon: Gtk.Image,
):
self._message_broker = message_broker
self._controller = controller
self._gui = status_bar
self._error_icon = error_icon
self._warning_icon = warning_icon
label = self._gui.get_message_area().get_children()[0]
label.set_ellipsize(Pango.EllipsizeMode.END)
label.set_selectable(True)
self._message_broker.subscribe(MessageType.status_msg, self._on_status_update)
# keep track if there is an error or warning in the stack of statusbar
# unfortunately this is not exposed over the api
self._error = False
self._warning = False
def _on_status_update(self, data: StatusData):
"""Show a status message and set its tooltip.
If message is None, it will remove the newest message of the
given context_id.
"""
context_id = data.ctx_id
message = data.msg
tooltip = data.tooltip
status_bar = self._gui
if message is None:
status_bar.remove_all(context_id)
if context_id in (CTX_ERROR, CTX_MAPPING):
self._error_icon.hide()
self._error = False
if self._warning:
self._warning_icon.show()
if context_id == CTX_WARNING:
self._warning_icon.hide()
self._warning = False
if self._error:
self._error_icon.show()
status_bar.set_tooltip_text("")
return
if tooltip is None:
tooltip = message
self._error_icon.hide()
self._warning_icon.hide()
if context_id in (CTX_ERROR, CTX_MAPPING):
self._error_icon.show()
self._error = True
if context_id == CTX_WARNING:
self._warning_icon.show()
self._warning = True
status_bar.push(context_id, message)
status_bar.set_tooltip_text(tooltip)

View File

@ -0,0 +1,3 @@
class OutputTypeNames:
analog_axis = "Analog Axis"
key_or_macro = "Key or Macro"

View File

@ -0,0 +1,106 @@
# -*- 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/>.
"""All components that are visible on the page that shows all the presets."""
from __future__ import annotations
from gi.repository import Gtk
from inputremapper.gui.components.common import FlowBoxEntry, FlowBoxWrapper
from inputremapper.gui.components.main import Stack
from inputremapper.gui.controller import Controller
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import (
GroupData,
PresetData,
DoStackSwitch,
)
from inputremapper.logging.logger import logger
class PresetEntry(FlowBoxEntry):
"""A preset that can be selected in the GUI."""
__gtype_name__ = "PresetEntry"
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
preset_name: str,
):
super().__init__(
message_broker=message_broker, controller=controller, name=preset_name
)
self.preset_name = preset_name
def _on_gtk_toggle(self, *_, **__):
logger.debug('Selecting preset "%s"', self.preset_name)
self._controller.load_preset(self.preset_name)
self.message_broker.publish(DoStackSwitch(Stack.editor_page))
class PresetSelection(FlowBoxWrapper):
"""A wrapper for the container with our presets.
Selectes the active_preset.
"""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
flowbox: Gtk.FlowBox,
):
super().__init__(flowbox)
self._message_broker = message_broker
self._controller = controller
self._gui = flowbox
self._connect_message_listener()
def _connect_message_listener(self):
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
self._message_broker.subscribe(MessageType.preset, self._on_preset_changed)
def _on_group_changed(self, data: GroupData):
self._gui.foreach(self._gui.remove)
for preset_name in data.presets:
preset_entry = PresetEntry(
self._message_broker,
self._controller,
preset_name,
)
self._gui.insert(preset_entry, -1)
def _on_preset_changed(self, data: PresetData):
self.show_active_entry(data.name)
def set_active_preset(self, preset_name: str):
"""Change the currently selected preset."""
# TODO might only be needed in tests
for child in self._gui.get_children():
preset_entry: PresetEntry = child.get_children()[0]
preset_entry.set_active(preset_entry.preset_name == preset_name)

View File

@ -0,0 +1,876 @@
# -*- 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
import re
from functools import partial
from typing import (
TYPE_CHECKING,
Optional,
Union,
Literal,
Sequence,
Dict,
Callable,
List,
Any,
Tuple,
)
from evdev.ecodes import EV_KEY, EV_REL, EV_ABS
from gi.repository import Gtk
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import (
MappingData,
UIMapping,
MappingType,
)
from inputremapper.configs.paths import PathUtils
from inputremapper.configs.validation_errors import (
pydantify,
MissingMacroOrKeyError,
MacroButTypeOrCodeSetError,
SymbolAndCodeMismatchError,
MissingOutputAxisError,
WrongMappingTypeForKeyError,
OutputSymbolVariantError,
)
from inputremapper.exceptions import DataManagementError
from inputremapper.gui.components.output_type_names import OutputTypeNames
from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME
from inputremapper.gui.gettext import _
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import (
PresetData,
StatusData,
CombinationRecorded,
UserConfirmRequest,
DoStackSwitch,
)
from inputremapper.gui.utils import CTX_APPLY, CTX_ERROR, CTX_WARNING, CTX_MAPPING
from inputremapper.injection.injector import (
InjectorState,
InjectorStateMessage,
)
from inputremapper.logging.logger import logger
if TYPE_CHECKING:
# avoids gtk import error in tests
from inputremapper.gui.user_interface import UserInterface
MAPPING_DEFAULTS = {"target_uinput": "keyboard"}
class Controller:
"""Implements the behaviour of the gui."""
def __init__(
self,
message_broker: MessageBroker,
data_manager: DataManager,
) -> None:
self.message_broker = message_broker
self.data_manager = data_manager
self.gui: Optional[UserInterface] = None
self.button_left_warn = False
self._attach_to_events()
def set_gui(self, gui: UserInterface):
"""Let the Controller know about the user interface singleton.."""
self.gui = gui
def _attach_to_events(self) -> None:
self.message_broker.subscribe(MessageType.groups, self._on_groups_changed)
self.message_broker.subscribe(MessageType.preset, self._on_preset_changed)
self.message_broker.subscribe(MessageType.init, self._on_init)
self.message_broker.subscribe(
MessageType.preset, self._publish_mapping_errors_as_status_msg
)
self.message_broker.subscribe(
MessageType.mapping, self._publish_mapping_errors_as_status_msg
)
def _on_init(self, __):
"""Initialize the gui and the data_manager."""
# make sure we get a groups_changed event when everything is ready
# this might not be necessary if the reader-service takes longer to provide the
# initial groups
self.data_manager.publish_groups()
self.data_manager.publish_uinputs()
def _on_groups_changed(self, _):
"""Load the newest group as soon as everyone got notified
about the updated groups."""
if self.data_manager.active_group is not None:
# don't jump to a different group and preset suddenly, if the user
# is already looking at one
logger.debug("A group is already active")
return
group_key = self.get_a_group()
if group_key is None:
logger.debug("Could not find a group")
return
self.load_group(group_key)
def _on_preset_changed(self, data: PresetData):
"""Load a mapping as soon as everyone got notified about the new preset."""
if data.mappings:
mappings = list(data.mappings)
mappings.sort(
key=lambda mapping: (
mapping.format_name() or mapping.input_combination.beautify()
)
)
combination = mappings[0].input_combination
self.load_mapping(combination)
self.load_input_config(combination[0])
else:
# send an empty mapping to make sure the ui is reset to default values
self.message_broker.publish(MappingData(**MAPPING_DEFAULTS))
def _on_combination_recorded(self, data: CombinationRecorded):
combination = self._auto_use_as_analog(data.combination)
self.update_combination(combination)
def _format_status_bar_validation_errors(self) -> Optional[Tuple[str, str]]:
if not self.data_manager.active_preset:
return None
if self.data_manager.active_preset.is_valid():
self.message_broker.publish(StatusData(CTX_MAPPING))
return None
mappings = list(self.data_manager.active_preset)
# Move the selected (active) mapping to the front, so that it is checked first.
active_mapping = self.data_manager.active_mapping
if active_mapping is not None:
mappings.remove(active_mapping)
mappings.insert(0, active_mapping)
for mapping in mappings:
if not mapping.has_input_defined():
# Empty mapping, nothing recorded yet so nothing can be configured,
# therefore there isn't anything to validate.
continue
position = mapping.format_name()
error_strings = self._get_ui_error_strings(mapping)
if len(error_strings) == 0:
continue
if len(error_strings) > 1:
msg = _('%d Mapping errors at "%s", hover for info') % (
len(error_strings),
position,
)
tooltip = " " + "\n ".join(error_strings)
else:
msg = f'"{position}": {error_strings[0]}'
tooltip = error_strings[0]
return msg.replace("\n", " "), tooltip
return None
def _publish_mapping_errors_as_status_msg(self, *__) -> None:
"""Send mapping ValidationErrors to the MessageBroker."""
validation_result = self._format_status_bar_validation_errors()
if validation_result is None:
return
self.show_status(
CTX_MAPPING,
validation_result[0],
validation_result[1],
)
@staticmethod
def format_error_message(mapping, error_type, error_message: str) -> str:
"""Check all the different error messages which are not useful for the user."""
# There is no more elegant way of comparing error_type with the base class.
# https://github.com/pydantic/pydantic/discussions/5112
if (
pydantify(MacroButTypeOrCodeSetError) in error_type
or pydantify(SymbolAndCodeMismatchError) in error_type
) and mapping.input_combination.defines_analog_input:
return _(
"Remove the macro or key from the macro input field "
"when specifying an analog output"
)
if (
pydantify(MacroButTypeOrCodeSetError) in error_type
or pydantify(SymbolAndCodeMismatchError) in error_type
) and not mapping.input_combination.defines_analog_input:
return _(
"Remove the Analog Output Axis when specifying a macro or key output"
)
if pydantify(MissingOutputAxisError) in error_type:
error_message = _(
"The input specifies an analog axis, but no output axis is selected."
)
if mapping.output_symbol is not None:
event = [
event
for event in mapping.input_combination
if event.defines_analog_input
][0]
error_message += _(
"\nIf you mean to create a key or macro mapping "
"go to the advanced input configuration"
' and set a "Trigger Threshold" for '
f'"{event.description()}"'
)
return error_message
if pydantify(WrongMappingTypeForKeyError) in error_type:
error_message = _(
"The input specifies a key, but the output type is not "
f'"{OutputTypeNames.key_or_macro}".'
)
if mapping.output_type in (EV_ABS, EV_REL):
error_message += _(
"\nIf you mean to create an analog axis mapping go to the "
'advanced input configuration and set an input to "Use as Analog".'
)
return error_message
if pydantify(MissingMacroOrKeyError) in error_type:
return _("Missing macro or key")
return error_message
@staticmethod
def _get_ui_error_strings(mapping: UIMapping) -> List[str]:
"""Get a human readable error message from a mapping error."""
validation_error = mapping.get_error()
if validation_error is None:
return []
formatted_errors = []
for error in validation_error.errors():
if pydantify(OutputSymbolVariantError) in error["type"]:
# this is rather internal, when this error appears in the gui, there is
# also always another more readable error at the same time that explains
# this problem.
continue
error_string = f'"{mapping.format_name()}": '
error_message = error["msg"]
error_location = error["loc"][0]
if error_location != "__root__":
error_string += f"{error_location}: "
# check all the different error messages which are not useful for the user
formatted_errors.append(
Controller.format_error_message(
mapping,
error["type"],
error_message,
)
)
return formatted_errors
def get_a_preset(self) -> str:
"""Attempts to get the newest preset in the current group
creates a new preset if that fails."""
try:
return self.data_manager.get_newest_preset_name()
except FileNotFoundError:
pass
self.data_manager.create_preset(self.data_manager.get_available_preset_name())
return self.data_manager.get_newest_preset_name()
def get_a_group(self) -> Optional[str]:
"""Attempts to get the group with the newest preset
returns any if that fails."""
try:
return self.data_manager.get_newest_group_key()
except FileNotFoundError:
pass
keys = self.data_manager.get_group_keys()
return keys[0] if keys else None
def copy_preset(self):
"""Create a copy of the active preset and name it `preset_name copy`."""
name = self.data_manager.active_preset.name
match = re.search(r" copy *\d*$", name)
if match:
name = name[: match.start()]
self.data_manager.copy_preset(
self.data_manager.get_available_preset_name(f"{name} copy")
)
self.message_broker.publish(DoStackSwitch(1))
def _auto_use_as_analog(self, combination: InputCombination) -> InputCombination:
"""If output is analog, set the first fitting input to analog."""
if self.data_manager.active_mapping is None:
return combination
if not self.data_manager.active_mapping.is_analog_output():
return combination
if combination.find_analog_input_config():
# something is already set to do that
return combination
for i, input_config in enumerate(combination):
# find the first analog input and set it to "use as analog"
if input_config.type in (EV_ABS, EV_REL):
logger.info("Using %s as analog input", input_config)
# combinations and input_configs are immutable, a new combination
# is created to fit the needs instead
combination_list = list(combination)
combination_list[i] = input_config.modify(analog_threshold=0)
new_combination = InputCombination(combination_list)
return new_combination
return combination
def update_combination(self, combination: InputCombination):
"""Update the input_combination of the active mapping."""
combination = self._auto_use_as_analog(combination)
try:
self.data_manager.update_mapping(input_combination=combination)
self.save()
except KeyError:
self.show_status(
CTX_MAPPING,
f'"{combination.beautify()}" already mapped to something else',
)
return
if combination.is_problematic():
self.show_status(
CTX_WARNING,
_("ctrl, alt and shift may not combine properly"),
_(
"Your system might reinterpret combinations with those after they "
+ "are injected, and by doing so break them. Play around with the "
+ 'advanced "Release Input" toggle.'
),
)
def move_input_config_in_combination(
self,
input_config: InputConfig,
direction: Union[Literal["up"], Literal["down"]],
):
"""Move the active_input_config up or down in the input_combination of the
active_mapping."""
if (
not self.data_manager.active_mapping
or len(self.data_manager.active_mapping.input_combination) == 1
):
return
combination: Sequence[InputConfig] = (
self.data_manager.active_mapping.input_combination
)
i = combination.index(input_config)
if (
i + 1 == len(combination)
and direction == "down"
or i == 0
and direction == "up"
):
return
if direction == "up":
combination = (
list(combination[: i - 1])
+ [input_config]
+ [combination[i - 1]]
+ list(combination[i + 1 :])
)
elif direction == "down":
combination = (
list(combination[:i])
+ [combination[i + 1]]
+ [input_config]
+ list(combination[i + 2 :])
)
else:
raise ValueError(f"unknown direction: {direction}")
self.update_combination(InputCombination(combination))
self.load_input_config(input_config)
def load_input_config(self, input_config: InputConfig):
"""Load an InputConfig form the active mapping input combination."""
self.data_manager.load_input_config(input_config)
def update_input_config(self, new_input_config: InputConfig):
"""Modify the active input configuration."""
try:
self.data_manager.update_input_config(new_input_config)
except KeyError:
# we need to synchronize the gui
self.data_manager.publish_mapping()
self.data_manager.publish_event()
def remove_event(self):
"""Remove the active InputEvent from the active mapping event combination."""
if (
not self.data_manager.active_mapping
or not self.data_manager.active_input_config
):
return
combination = list(self.data_manager.active_mapping.input_combination)
combination.remove(self.data_manager.active_input_config)
try:
self.data_manager.update_mapping(
input_combination=InputCombination(combination)
)
self.load_input_config(combination[0])
self.save()
except (KeyError, ValueError):
# we need to synchronize the gui
self.data_manager.publish_mapping()
self.data_manager.publish_event()
def set_event_as_analog(self, analog: bool):
"""Use the active event as an analog input."""
assert self.data_manager.active_input_config is not None
event = self.data_manager.active_input_config
if event.type != EV_KEY:
if analog:
try:
self.data_manager.update_input_config(
event.modify(analog_threshold=0)
)
self.save()
return
except KeyError:
pass
else:
try_values = {EV_REL: [1, -1], EV_ABS: [10, -10]}
for value in try_values[event.type]:
try:
self.data_manager.update_input_config(
event.modify(analog_threshold=value)
)
self.save()
return
except KeyError:
pass
# didn't update successfully
# we need to synchronize the gui
self.data_manager.publish_mapping()
self.data_manager.publish_event()
def load_groups(self):
"""Refresh the groups."""
self.data_manager.refresh_groups()
def load_group(self, group_key: str):
"""Load the group and then a preset of that group."""
self.data_manager.load_group(group_key)
self.load_preset(self.get_a_preset())
def load_preset(self, name: str):
"""Load the preset."""
self.data_manager.load_preset(name)
# self.load_mapping(...) # not needed because we have on_preset_changed()
def rename_preset(self, new_name: str):
"""Rename the active_preset."""
if (
not self.data_manager.active_preset
or not new_name
or new_name == self.data_manager.active_preset.name
):
return
new_name = PathUtils.sanitize_path_component(new_name)
new_name = self.data_manager.get_available_preset_name(new_name)
self.data_manager.rename_preset(new_name)
def add_preset(self, name: str = DEFAULT_PRESET_NAME):
"""Create a new preset called `new preset n`, add it to the active_group."""
name = self.data_manager.get_available_preset_name(name)
try:
self.data_manager.create_preset(name)
self.data_manager.load_preset(name)
except PermissionError as e:
self.show_status(CTX_ERROR, _("Permission denied!"), str(e))
def delete_preset(self):
"""Delete the active_preset from the disc."""
def f(answer: bool):
if answer:
self.data_manager.delete_preset()
self.data_manager.load_preset(self.get_a_preset())
self.message_broker.publish(DoStackSwitch(1))
if not self.data_manager.active_preset:
return
msg = (
_('Are you sure you want to delete the preset "%s"?')
% self.data_manager.active_preset.name
)
self.message_broker.publish(UserConfirmRequest(msg, f))
def load_mapping(self, input_combination: InputCombination):
"""Load the mapping with the given input_combination form the active_preset."""
self.data_manager.load_mapping(input_combination)
self.load_input_config(input_combination[0])
def update_mapping(self, **changes):
"""Update the active_mapping with the given keywords and values."""
if "mapping_type" in changes.keys():
if not (changes := self._change_mapping_type(changes)):
# we need to synchronize the gui
self.data_manager.publish_mapping()
self.data_manager.publish_event()
return
self.data_manager.update_mapping(**changes)
self.save()
def create_mapping(self):
"""Create a new empty mapping in the active_preset."""
try:
self.data_manager.create_mapping()
except KeyError:
# there is already an empty mapping
return
self.data_manager.load_mapping(combination=InputCombination.empty_combination())
self.data_manager.update_mapping(**MAPPING_DEFAULTS)
def delete_mapping(self):
"""Remove the active_mapping form the active_preset."""
def get_answer(answer: bool):
if answer:
self.data_manager.delete_mapping()
self.save()
if not self.data_manager.active_mapping:
return
self.message_broker.publish(
UserConfirmRequest(
_("Are you sure you want to delete this mapping?"),
get_answer,
)
)
def set_autoload(self, autoload: bool):
"""Set the autoload state for the active_preset and active_group."""
self.data_manager.set_autoload(autoload)
self.data_manager.refresh_service_config_path()
def save(self):
"""Save all data to the disc."""
try:
self.data_manager.save()
except PermissionError as e:
self.show_status(CTX_ERROR, _("Permission denied!"), str(e))
def start_key_recording(self):
"""Record the input of the active_group
Updates the active_mapping.input_combination with the recorded events.
"""
state = self.data_manager.get_state()
if state == InjectorState.RUNNING or state == InjectorState.STARTING:
self.data_manager.stop_combination_recording()
self.message_broker.signal(MessageType.recording_finished)
self.show_status(CTX_ERROR, _('Use "Stop" to stop before editing'))
return
logger.debug("Recording Keys")
def on_recording_finished(_):
self.message_broker.unsubscribe(on_recording_finished)
self.message_broker.unsubscribe(self._on_combination_recorded)
self.gui.connect_shortcuts()
self.gui.disconnect_shortcuts()
self.message_broker.subscribe(
MessageType.combination_recorded,
self._on_combination_recorded,
)
self.message_broker.subscribe(
MessageType.recording_finished, on_recording_finished
)
self.data_manager.start_combination_recording()
def stop_key_recording(self):
"""Stop recording the input."""
logger.debug("Stopping Recording Keys")
self.data_manager.stop_combination_recording()
def start_injecting(self):
"""Inject the active_preset for the active_group."""
if len(self.data_manager.active_preset) == 0:
logger.error(_("Cannot apply empty preset file"))
# also helpful for first time use
self.show_status(CTX_ERROR, _("You need to add mappings first"))
return
if not self.button_left_warn:
if self.data_manager.active_preset.dangerously_mapped_btn_left():
self.show_status(
CTX_ERROR,
"This would disable your click button",
"Map a button to BTN_LEFT to avoid this.\n"
"To overwrite this warning, press apply again.",
)
self.button_left_warn = True
return
# todo: warn about unreleased keys
self.button_left_warn = False
self.message_broker.subscribe(
MessageType.injector_state,
self.show_injector_result,
)
self.show_status(CTX_APPLY, _("Starting injection..."))
if not self.data_manager.start_injecting():
self.message_broker.unsubscribe(self.show_injector_result)
self.show_status(
CTX_APPLY,
_('Failed to apply preset "%s"') % self.data_manager.active_preset.name,
)
def show_injector_result(self, msg: InjectorStateMessage) -> None:
"""Show if the injection was successfully started."""
self.message_broker.unsubscribe(self.show_injector_result)
state = msg.state
def running() -> None:
assert self.data_manager.active_preset is not None
msg = _('Applied preset "%s"') % self.data_manager.active_preset.name
if self.data_manager.active_preset.dangerously_mapped_btn_left():
msg += _(", CTRL + DEL to stop")
self.show_status(CTX_APPLY, msg)
logger.info(
'Group "%s" is currently mapped', self.data_manager.active_group.key
)
def no_grab() -> None:
assert self.data_manager.active_preset is not None
msg = (
_('Failed to apply preset "%s"') % self.data_manager.active_preset.name
)
tooltip = (
"Maybe your preset doesn't contain anything that is sent by the "
"device or another device is already grabbing it"
)
# InjectorState.NO_GRAB also happens when all mappings have validation
# errors. In that case, we can show something more useful.
validation_result = self._format_status_bar_validation_errors()
if validation_result is not None:
msg = f"{msg}. {validation_result[0]}"
tooltip = validation_result[1]
self.show_status(CTX_ERROR, msg, tooltip)
assert self.data_manager.active_preset # make mypy happy
state_calls: Dict[InjectorState, Callable] = {
InjectorState.RUNNING: running,
InjectorState.ERROR: partial(
self.show_status,
CTX_ERROR,
_('Error applying preset "%s"') % self.data_manager.active_preset.name,
),
InjectorState.NO_GRAB: no_grab,
InjectorState.UPGRADE_EVDEV: partial(
self.show_status,
CTX_ERROR,
"Upgrade python-evdev",
"Your python-evdev version is too old.",
),
}
if state in state_calls:
state_calls[state]()
def stop_injecting(self):
"""Stop injecting any preset for the active_group."""
def show_result(msg: InjectorStateMessage):
self.message_broker.unsubscribe(show_result)
if not msg.inactive():
# some speculation: there might be unexpected additional status messages
# with a different state, or the status is wrong because something in
# the long pipeline of status messages is broken.
logger.error(
"Expected the injection to eventually stop, but got state %s",
msg.state,
)
return
self.show_status(CTX_APPLY, _("Stopped the injection"))
try:
self.message_broker.subscribe(MessageType.injector_state, show_result)
self.data_manager.stop_injecting()
except DataManagementError:
self.message_broker.unsubscribe(show_result)
def show_status(
self,
ctx_id: int,
msg: Optional[str] = None,
tooltip: Optional[str] = None,
):
"""Send a status message to the ui to show it in the status-bar."""
self.message_broker.publish(StatusData(ctx_id, msg, tooltip))
def is_empty_mapping(self) -> bool:
"""Check if the active_mapping is empty."""
return (
self.data_manager.active_mapping == UIMapping(**MAPPING_DEFAULTS)
or self.data_manager.active_mapping is None
)
def refresh_groups(self):
"""Reload the connected devices and send them as a groups message.
Runs asynchronously.
"""
self.data_manager.refresh_groups()
def close(self):
"""Safely close the application."""
logger.debug("Closing Application")
self.save()
self.message_broker.signal(MessageType.terminate)
logger.debug("Quitting")
Gtk.main_quit()
def set_focus(self, component):
"""Focus the given component."""
self.gui.window.set_focus(component)
def _change_mapping_type(self, changes: Dict[str, Any]):
"""Query the user to update the mapping in order to change the mapping type."""
mapping = self.data_manager.active_mapping
if mapping is None:
return changes
if changes["mapping_type"] == mapping.mapping_type:
return changes
if changes["mapping_type"] == MappingType.ANALOG.value:
msg = _("You are about to change the mapping to analog.")
if mapping.output_symbol:
msg += _('\nThis will remove "{}" ' "from the text input!").format(
mapping.output_symbol
)
if not [
input_config
for input_config in mapping.input_combination
if input_config.defines_analog_input
]:
# there is no analog input configured, let's try to autoconfigure it
inputs: List[InputConfig] = list(mapping.input_combination)
for i, input_config in enumerate(inputs):
if input_config.type in [EV_ABS, EV_REL]:
inputs[i] = input_config.modify(analog_threshold=0)
changes["input_combination"] = InputCombination(inputs)
msg += _(
'\nThe input "{}" will be used as analog input.'
).format(input_config.description())
break
else:
# not possible to autoconfigure inform the user
msg += _("\nYou need to record an analog input.")
elif not mapping.output_symbol:
return changes
answer = None
def get_answer(answer_: bool):
nonlocal answer
answer = answer_
self.message_broker.publish(UserConfirmRequest(msg, get_answer))
if answer:
changes["output_symbol"] = None
return changes
else:
return None
if changes["mapping_type"] == MappingType.KEY_MACRO.value:
try:
analog_input = tuple(
filter(lambda i: i.defines_analog_input, mapping.input_combination)
)[0]
except IndexError:
changes["output_type"] = None
changes["output_code"] = None
return changes
answer = None
def get_answer(answer_: bool):
nonlocal answer
answer = answer_
self.message_broker.publish(
UserConfirmRequest(
f"You are about to change the mapping to a Key or Macro mapping!\n"
f"Go to the advanced input configuration and set a "
f'"Trigger Threshold" for "{analog_input.description()}".',
get_answer,
)
)
if answer:
changes["output_type"] = None
changes["output_code"] = None
return changes
else:
return None
return changes

View File

@ -0,0 +1,609 @@
# -*- 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 glob
import os
import re
import time
from typing import Optional, List, Tuple, Set
from gi.repository import GLib
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import UIMapping, MappingData
from inputremapper.configs.paths import PathUtils
from inputremapper.configs.preset import Preset
from inputremapper.configs.keyboard_layout import KeyboardLayout
from inputremapper.daemon import DaemonProxy
from inputremapper.exceptions import DataManagementError
from inputremapper.groups import _Group
from inputremapper.gui.gettext import _
from inputremapper.gui.messages.message_broker import (
MessageBroker,
)
from inputremapper.gui.messages.message_data import (
UInputsData,
GroupData,
PresetData,
CombinationUpdate,
)
from inputremapper.gui.reader_client import ReaderClient
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.injector import (
InjectorState,
InjectorStateMessage,
)
from inputremapper.logging.logger import logger
DEFAULT_PRESET_NAME = _("new preset")
# useful type aliases
Name = str
GroupKey = str
class DataManager:
"""DataManager provides an interface to create and modify configurations as well
as modify the state of the Service.
Any state changes will be announced via the MessageBroker.
"""
def __init__(
self,
message_broker: MessageBroker,
config: GlobalConfig,
reader_client: ReaderClient,
daemon: DaemonProxy,
uinputs: GlobalUInputs,
keyboard_layout: KeyboardLayout,
):
self.message_broker = message_broker
self._reader_client = reader_client
self._daemon = daemon
self._uinputs = uinputs
self._keyboard_layout = keyboard_layout
uinputs.prepare_all()
self._config = config
self._config.load_config()
self._active_preset: Optional[Preset[UIMapping]] = None
self._active_mapping: Optional[UIMapping] = None
self._active_input_config: Optional[InputConfig] = None
def publish_group(self):
"""Send active group to the MessageBroker.
This is internally called whenever the group changes.
It is usually not necessary to call this explicitly from
outside DataManager.
"""
self.message_broker.publish(
GroupData(self.active_group.key, self.get_preset_names())
)
def publish_preset(self):
"""Send active preset to the MessageBroker.
This is internally called whenever the preset changes.
It is usually not necessary to call this explicitly from
outside DataManager.
"""
self.message_broker.publish(
PresetData(
self.active_preset.name, self.get_mappings(), self.get_autoload()
)
)
def publish_mapping(self):
"""Send active mapping to the MessageBroker
This is internally called whenever the mapping changes.
It is usually not necessary to call this explicitly from
outside DataManager.
"""
if self.active_mapping:
self.message_broker.publish(self.active_mapping.get_bus_message())
def publish_event(self):
"""Send active event to the MessageBroker.
This is internally called whenever the event changes.
It is usually not necessary to call this explicitly from
outside DataManager
"""
if self.active_input_config:
assert self.active_input_config in self.active_mapping.input_combination
self.message_broker.publish(self.active_input_config)
def publish_uinputs(self):
"""Send the "uinputs" message on the MessageBroker."""
self.message_broker.publish(
UInputsData(
{
name: uinput.capabilities()
for name, uinput in self._uinputs.devices.items()
}
)
)
def publish_groups(self):
"""Publish the "groups" message on the MessageBroker."""
self._reader_client.publish_groups()
def publish_injector_state(self):
"""Publish the "injector_state" message for the active_group."""
if not self.active_group:
return
self.message_broker.publish(InjectorStateMessage(self.get_state()))
@property
def active_group(self) -> Optional[_Group]:
"""The currently loaded group."""
return self._reader_client.group
@property
def active_preset(self) -> Optional[Preset[UIMapping]]:
"""The currently loaded preset."""
return self._active_preset
@property
def active_mapping(self) -> Optional[UIMapping]:
"""The currently loaded mapping."""
return self._active_mapping
@property
def active_input_config(self) -> Optional[InputConfig]:
"""The currently loaded event."""
return self._active_input_config
def get_group_keys(self) -> Tuple[GroupKey, ...]:
"""Get all group keys (plugged devices)."""
return tuple(group.key for group in self._reader_client.groups.get_groups())
def get_preset_names(self) -> Tuple[Name, ...]:
"""Get all preset names for active_group and current user sorted by age."""
if not self.active_group:
raise DataManagementError("Cannot find presets: Group is not set")
device_folder = PathUtils.get_preset_path(self.active_group.name)
PathUtils.mkdir(device_folder)
paths = glob.glob(os.path.join(glob.escape(device_folder), "*.json"))
presets = [
os.path.splitext(os.path.basename(path))[0]
for path in sorted(paths, key=os.path.getmtime)
]
# the highest timestamp to the front
presets.reverse()
return tuple(presets)
def get_mappings(self) -> Optional[List[MappingData]]:
"""All mappings from the active_preset."""
if not self._active_preset:
return None
return [mapping.get_bus_message() for mapping in self._active_preset]
def get_autoload(self) -> bool:
"""The autoload status of the active_preset."""
if not self.active_preset or not self.active_group:
return False
return self._config.is_autoloaded(
self.active_group.key, self.active_preset.name
)
def set_autoload(self, status: bool):
"""Set the autoload status of the active_preset.
Will send "preset" message on the MessageBroker.
"""
if not self.active_preset or not self.active_group:
raise DataManagementError("Cannot set autoload status: Preset is not set")
if status:
self._config.set_autoload_preset(
self.active_group.key, self.active_preset.name
)
elif self.get_autoload():
self._config.set_autoload_preset(self.active_group.key, None)
self.publish_preset()
def get_newest_group_key(self) -> GroupKey:
"""group_key of the group with the most recently modified preset."""
paths = []
pattern = os.path.join(
glob.escape(PathUtils.get_preset_path()),
"*/*.json",
)
for path in glob.glob(pattern):
if self._reader_client.groups.find(key=PathUtils.split_all(path)[-2]):
paths.append((path, os.path.getmtime(path)))
if not paths:
raise FileNotFoundError()
path, _ = max(paths, key=lambda x: x[1])
return PathUtils.split_all(path)[-2]
def get_newest_preset_name(self) -> Name:
"""Preset name of the most recently modified preset in the active group."""
if not self.active_group:
raise DataManagementError("Cannot find newest preset: Group is not set")
pattern = os.path.join(
glob.escape(PathUtils.get_preset_path(self.active_group.name)),
"*.json",
)
paths = [(path, os.path.getmtime(path)) for path in glob.glob(pattern)]
if not paths:
raise FileNotFoundError()
path, _ = max(paths, key=lambda x: x[1])
return os.path.split(path)[-1].split(".")[0]
def get_available_preset_name(self, name=DEFAULT_PRESET_NAME) -> Name:
"""The first available preset in the active group."""
if not self.active_group:
raise DataManagementError("Unable find preset name. Group is not set")
name = name.strip()
# find a name that is not already taken
if os.path.exists(PathUtils.get_preset_path(self.active_group.name, name)):
# if there already is a trailing number, increment it instead of
# adding another one
match = re.match(r"^(.+) (\d+)$", name)
if match:
name = match[1]
i = int(match[2]) + 1
else:
i = 2
while os.path.exists(
PathUtils.get_preset_path(self.active_group.name, f"{name} {i}")
):
i += 1
return f"{name} {i}"
return name
def load_group(self, group_key: str):
"""Load a group. will publish "groups" and "injector_state" messages.
This will render the active_mapping and active_preset invalid.
"""
if group_key not in self.get_group_keys():
raise DataManagementError("Unable to load non existing group")
logger.info('Loading group "%s"', group_key)
self._active_input_config = None
self._active_mapping = None
self._active_preset = None
group = self._reader_client.groups.find(key=group_key)
self._reader_client.set_group(group)
self.publish_group()
self.publish_injector_state()
def load_preset(self, name: str):
"""Load a preset. Will send "preset" message on the MessageBroker.
This will render the active_mapping invalid.
"""
if not self.active_group:
raise DataManagementError("Unable to load preset. Group is not set")
logger.info('Loading preset "%s"', name)
preset_path = PathUtils.get_preset_path(self.active_group.name, name)
preset = Preset(preset_path, mapping_factory=UIMapping)
preset.load()
self._active_input_config = None
self._active_mapping = None
self._active_preset = preset
self.publish_preset()
def load_mapping(self, combination: InputCombination):
"""Load a mapping. Will send "mapping" message on the MessageBroker."""
if not self._active_preset:
raise DataManagementError("Unable to load mapping. Preset is not set")
mapping = self._active_preset.get_mapping(combination)
if not mapping:
msg = (
f"the mapping with {combination = } does not "
f"exist in the {self._active_preset.path}"
)
logger.error(msg)
raise KeyError(msg)
self._active_input_config = None
self._active_mapping = mapping
self.publish_mapping()
def load_input_config(self, input_config: InputConfig):
"""Load a InputConfig from the combination in the active mapping.
Will send "event" message on the MessageBroker,
"""
if not self.active_mapping:
raise DataManagementError("Unable to load event. Mapping is not set")
if input_config not in self.active_mapping.input_combination:
raise ValueError(
f"{input_config} is not member of active_mapping.input_combination: "
f"{self.active_mapping.input_combination}"
)
self._active_input_config = input_config
self.publish_event()
def rename_preset(self, new_name: str):
"""Rename the current preset and move the correct file.
Will send "group" and then "preset" message on the MessageBroker
"""
if not self.active_preset or not self.active_group:
raise DataManagementError("Unable rename preset: Preset is not set")
if self.active_preset.path == PathUtils.get_preset_path(
self.active_group.name, new_name
):
return
old_path = self.active_preset.path
assert old_path is not None
old_name = os.path.basename(old_path).split(".")[0]
new_path = PathUtils.get_preset_path(self.active_group.name, new_name)
if os.path.exists(new_path):
raise ValueError(
f"cannot rename {old_name} to " f"{new_name}, preset already exists"
)
logger.info('Moving "%s" to "%s"', old_path, new_path)
os.rename(old_path, new_path)
now = time.time()
os.utime(new_path, (now, now))
if self._config.is_autoloaded(self.active_group.key, old_name):
self._config.set_autoload_preset(self.active_group.key, new_name)
self.active_preset.path = PathUtils.get_preset_path(
self.active_group.name, new_name
)
self.publish_group()
self.publish_preset()
def copy_preset(self, name: str):
"""Copy the current preset to the given name.
Will send "group" and "preset" message to the MessageBroker and load the copy
"""
# todo: Do we want to load the copy here? or is this up to the controller?
if not self.active_preset or not self.active_group:
raise DataManagementError("Unable to copy preset: Preset is not set")
if self.active_preset.path == PathUtils.get_preset_path(
self.active_group.name, name
):
return
if name in self.get_preset_names():
raise ValueError(f"a preset with the name {name} already exits")
new_path = PathUtils.get_preset_path(self.active_group.name, name)
logger.info('Copy "%s" to "%s"', self.active_preset.path, new_path)
self.active_preset.path = new_path
self.save()
self.publish_group()
self.publish_preset()
def create_preset(self, name: str):
"""Create empty preset in the active_group.
Will send "group" message to the MessageBroker
"""
if not self.active_group:
raise DataManagementError("Unable to add preset. Group is not set")
path = PathUtils.get_preset_path(self.active_group.name, name)
if os.path.exists(path):
raise DataManagementError("Unable to add preset. Preset exists")
Preset(path).save()
self.publish_group()
def delete_preset(self):
"""Delete the active preset.
Will send "group" message to the MessageBroker
this will invalidate the active mapping,
"""
preset_path = self._active_preset.path
logger.info('Removing "%s"', preset_path)
os.remove(preset_path)
self._active_mapping = None
self._active_preset = None
self.publish_group()
def update_mapping(self, **kwargs):
"""Update the active mapping with the given keywords and values.
Will send "mapping" message to the MessageBroker. In case of a new
input_combination. This will first send a "combination_update" message.
"""
if not self._active_mapping:
raise DataManagementError("Cannot modify Mapping: Mapping is not set")
if symbol := kwargs.get("output_symbol"):
kwargs["output_symbol"] = self._keyboard_layout.correct_case(symbol)
combination = self.active_mapping.input_combination
for key, value in kwargs.items():
setattr(self._active_mapping, key, value)
if (
"input_combination" in kwargs
and combination != self.active_mapping.input_combination
):
self._active_input_config = None
self.message_broker.publish(
CombinationUpdate(combination, self._active_mapping.input_combination)
)
if "mapping_type" in kwargs:
# mapping_type must be the last update because it is automatically updated
# by a validation function
self._active_mapping.mapping_type = kwargs["mapping_type"]
self.publish_mapping()
def update_input_config(self, new_input_config: InputConfig):
"""Update the active input configuration.
Will send "combination_update", "mapping" and "event" messages to the
MessageBroker (in that order)
"""
if not self.active_mapping or not self.active_input_config:
raise DataManagementError("Cannot modify event: Event is not set")
combination = list(self.active_mapping.input_combination)
combination[combination.index(self.active_input_config)] = new_input_config
self.update_mapping(input_combination=InputCombination(combination))
self._active_input_config = new_input_config
self.publish_event()
def create_mapping(self):
"""Create empty mapping in the active preset.
Will send "preset" message to the MessageBroker
"""
if not self._active_preset:
raise DataManagementError("Cannot create mapping: Preset is not set")
self._active_preset.add(UIMapping())
self.publish_preset()
def delete_mapping(self):
"""Delete the active mapping.
Will send "preset" message to the MessageBroker
"""
if not self._active_mapping:
raise DataManagementError(
"cannot delete active mapping: active mapping is not set"
)
self._active_preset.remove(self._active_mapping.input_combination)
self._active_mapping = None
self.publish_preset()
def save(self):
"""Save the active preset."""
if self._active_preset:
self._active_preset.save()
def refresh_groups(self):
"""Refresh the groups (plugged devices).
Should send "groups" message to MessageBroker this will not happen immediately
because the system might take a bit until the groups are available
"""
self._reader_client.refresh_groups()
def start_combination_recording(self):
"""Record user input.
Will send "combination_recorded" messages as new input arrives.
Will eventually send a "recording_finished" message.
"""
self._reader_client.start_recorder()
def stop_combination_recording(self):
"""Stop recording user input.
Will send a recording_finished signal if a recording is running.
"""
self._reader_client.stop_recorder()
def stop_injecting(self) -> None:
"""Stop injecting for the active group.
Will send "injector_state" message once the injector has stopped."""
if not self.active_group:
raise DataManagementError("Cannot stop injection: Group is not set")
self._daemon.stop_injecting(self.active_group.key)
self.do_when_injector_state(
{InjectorState.STOPPED}, self.publish_injector_state
)
def start_injecting(self) -> bool:
"""Start injecting the active preset for the active group.
returns if the startup was successfully initialized.
Will send "injector_state" message once the startup is complete.
"""
if not self.active_preset or not self.active_group:
raise DataManagementError("Cannot start injection: Preset is not set")
self._daemon.set_config_dir(self._config.get_dir())
assert self.active_preset.name is not None
if self._daemon.start_injecting(self.active_group.key, self.active_preset.name):
self.do_when_injector_state(
{
InjectorState.RUNNING,
InjectorState.ERROR,
InjectorState.NO_GRAB,
InjectorState.UPGRADE_EVDEV,
},
self.publish_injector_state,
)
return True
return False
def get_state(self) -> InjectorState:
"""The state of the injector."""
if not self.active_group:
raise DataManagementError("Cannot read state: Group is not set")
return self._daemon.get_state(self.active_group.key)
def refresh_service_config_path(self):
"""Tell the service to refresh its config path."""
self._daemon.set_config_dir(self._config.get_dir())
def do_when_injector_state(self, states: Set[InjectorState], callback):
"""Run callback once the injector state is one of states."""
start = time.time()
def do():
if time.time() - start > 3:
# something went wrong, there should have been a state long ago.
# the timeout prevents tons of GLib.timeouts to run forever, especially
# after spamming the "Stop" button.
logger.error("Timed out while waiting for injector state %s", states)
return False
if self.get_state() in states:
callback()
return False
return True
GLib.timeout_add(100, do)

View File

@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@hip70890b.de>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Process that sends stuff to the GUI.
It should be started via input-remapper-control and pkexec.
GUIs should not run as root
https://wiki.archlinux.org/index.php/Running_GUI_applications_as_root
The service shouldn't do that even though it has root rights, because that
would enable key-loggers to just ask input-remapper for all user-input.
Instead, the ReaderService is used, which will be stopped when the gui closes.
Whereas for the reader-service to start a password is needed and it stops whe
the ui closes.
This uses the backend injection.event_reader and mapping_handlers to process all the
different input-events into simple on/off events and sends them to the gui.
"""
from __future__ import annotations
import evdev
from evdev.ecodes import EV_ABS
from inputremapper.configs.input_config import (
DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE,
)
from inputremapper.input_event import InputEvent
from inputremapper.ipc.pipe import Pipe
from inputremapper.logging.logger import logger
from inputremapper.injection.mapping_handlers.mapping_handler import MappingHandler
from inputremapper.injection.mapping_handlers.abs_util import calculate_trigger_point
# received by the reader-service
CMD_TERMINATE = "terminate"
CMD_STOP_READING = "stop-reading"
CMD_REFRESH_GROUPS = "refresh_groups"
# sent by the reader-service to the reader
MSG_GROUPS = "groups"
MSG_EVENT = "event"
MSG_STATUS = "status"
class ForwardToUIHandler(MappingHandler):
"""Implements the MappingHandler protocol. Sends all events into the pipe."""
def __init__(self, pipe: Pipe):
self.pipe = pipe
self._last_event = InputEvent.from_tuple((99, 99, 99))
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
suppress: bool = False,
) -> bool:
"""Filter duplicates and send into the pipe."""
if event == self._last_event:
return True
# These defaults work with EV_KEY and EV_REL
pressed = False if event.value == 0 else True
direction = 1 if event.value >= 0 else -1
# Because joysticks aren't as precise, they wiggle and their value might not be
# centered around 0, they need special treatment
if event.type == EV_ABS:
threshold, mid_point = calculate_trigger_point(
event,
DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE,
source,
)
# If within 30% (into each direction) of the mid_point, count as released
# A large threshold makes it significantly easier to not accidentally
# record both ABS_X and ABS_Y.
if abs(event.value - mid_point) < threshold:
pressed = False
if event.value < mid_point:
direction = -1
self._last_event = event
logger.debug("Sending %s to frontend", event)
self.pipe.send(
{
"type": MSG_EVENT,
"message": {
"sec": event.sec,
"usec": event.usec,
"type": event.type,
"code": event.code,
"value": event.value,
"pressed": pressed,
"direction": direction,
"origin_hash": event.origin_hash,
},
}
)
return True
def reset(self):
pass

View File

@ -0,0 +1,33 @@
# -*- 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 gettext
import locale
import os.path
from inputremapper.configs.data import get_data_path
APP_NAME = "input-remapper"
LOCALE_DIR = os.path.join(get_data_path(), "lang")
locale.bindtextdomain(APP_NAME, LOCALE_DIR)
locale.textdomain(APP_NAME)
translate = gettext.translation(APP_NAME, LOCALE_DIR, fallback=True)
_ = translate.gettext

View File

View File

@ -0,0 +1,120 @@
# -*- 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 os.path
import re
import traceback
from collections import defaultdict, deque
from typing import (
Callable,
Dict,
Set,
Protocol,
Tuple,
Deque,
Any,
)
from inputremapper.gui.messages.message_types import MessageType
from inputremapper.logging.logger import logger
class Message(Protocol):
"""The protocol any message must follow to be sent with the MessageBroker."""
@property
def message_type(self) -> MessageType: ...
# useful type aliases
MessageListener = Callable[[Any], None]
class MessageBroker:
shorten_path = re.compile(r"inputremapper/")
def __init__(self):
self._listeners: Dict[MessageType, Set[MessageListener]] = defaultdict(set)
self._messages: Deque[Tuple[Message, str, int]] = deque()
self._publishing = False
def publish(self, data: Message):
"""Schedule a massage to be sent.
The message will be sent after all currently pending messages are sent."""
self._messages.append((data, *self.get_caller()))
self._publish_all()
def signal(self, signal: MessageType):
"""Send a signal without any data payload."""
# This is different from calling self.publish because self.get_caller()
# looks back at the current stack 3 frames
self._messages.append((Signal(signal), *self.get_caller()))
self._publish_all()
def _publish(self, data: Message, file: str, line: int):
logger.debug(
"from %s:%d: Signal=%s: %s", file, line, data.message_type.name, data
)
for listener in self._listeners[data.message_type].copy():
listener(data)
def _publish_all(self):
"""Send all scheduled messages in order."""
if self._publishing:
# don't run this twice, so we not mess up the order
return
self._publishing = True
try:
while self._messages:
self._publish(*self._messages.popleft())
finally:
self._publishing = False
def subscribe(self, massage_type: MessageType, listener: MessageListener):
"""Attach a listener to an event."""
logger.debug("adding new Listener for %s: %s", massage_type, listener)
self._listeners[massage_type].add(listener)
return self
@staticmethod
def get_caller(position: int = 3) -> Tuple[str, int]:
"""Extract a file and line from current stack and format for logging."""
tb = traceback.extract_stack(limit=position)[0]
return os.path.basename(tb.filename), tb.lineno or 0
def unsubscribe(self, listener: MessageListener) -> None:
for listeners in self._listeners.values():
try:
listeners.remove(listener)
except KeyError:
pass
class Signal:
"""Send a Message without any associated data over the MassageBus."""
def __init__(self, message_type: MessageType):
self.message_type: MessageType = message_type
def __str__(self):
return f"Signal: {self.message_type}"
def __eq__(self, other: Any):
return type(self) is type(other) and self.message_type == other.message_type

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/>.
import re
from dataclasses import dataclass
from typing import Dict, Tuple, Optional, Callable
from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.mapping import MappingData
from inputremapper.gui.messages.message_types import (
MessageType,
Name,
Capabilities,
Key,
DeviceTypes,
)
@dataclass(frozen=True)
class UInputsData:
message_type = MessageType.uinputs
uinputs: Dict[Name, Capabilities]
def __str__(self):
string = f"{self.__class__.__name__}(uinputs={self.uinputs})"
# find all sequences of comma+space separated numbers, and shorten them
# to the first and last number
all_matches = list(re.finditer(r"(\d+, )+", string))
all_matches.reverse()
for match in all_matches:
start = match.start()
end = match.end()
start += string[start:].find(",") + 2
if start == end:
continue
string = f"{string[:start]}... {string[end:]}"
return string
@dataclass(frozen=True)
class GroupsData:
"""Message containing all available groups and their device types."""
message_type = MessageType.groups
groups: Dict[Key, DeviceTypes]
@dataclass(frozen=True)
class GroupData:
"""Message with the active group and available presets for the group."""
message_type = MessageType.group
group_key: str
presets: Tuple[str, ...]
@dataclass(frozen=True)
class PresetData:
"""Message with the active preset name and mapping names/combinations."""
message_type = MessageType.preset
name: Optional[Name]
mappings: Optional[Tuple[MappingData, ...]]
autoload: bool = False
@dataclass(frozen=True)
class StatusData:
"""Message with the strings and id for the status bar."""
message_type = MessageType.status_msg
ctx_id: int
msg: Optional[str] = None
tooltip: Optional[str] = None
@dataclass(frozen=True)
class CombinationRecorded:
"""Message with the latest recoded combination."""
message_type = MessageType.combination_recorded
combination: "InputCombination"
@dataclass(frozen=True)
class CombinationUpdate:
"""Message with the old and new combination (hash for a mapping) when it changed."""
message_type = MessageType.combination_update
old_combination: "InputCombination"
new_combination: "InputCombination"
@dataclass(frozen=True)
class UserConfirmRequest:
"""Message for requesting a user response (confirm/cancel) from the gui."""
message_type = MessageType.user_confirm_request
msg: str
respond: Callable[[bool], None] = lambda _: None
@dataclass(frozen=True)
class DoStackSwitch:
"""Command the stack to switch to a different page."""
message_type = MessageType.do_stack_switch
page_index: int

View File

@ -0,0 +1,60 @@
# -*- 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 enum import Enum
from typing import Dict, List
from inputremapper.groups import DeviceType
# useful type aliases
Capabilities = Dict[int, List]
Name = str
Key = str
DeviceTypes = List[DeviceType]
class MessageType(Enum):
reset_gui = "reset_gui"
terminate = "terminate"
init = "init"
uinputs = "uinputs"
groups = "groups"
group = "group"
preset = "preset"
mapping = "mapping"
selected_event = "selected_event"
combination_recorded = "combination_recorded"
# only the reader_client should send those messages:
recording_started = "recording_started"
recording_finished = "recording_finished"
combination_update = "combination_update"
status_msg = "status_msg"
injector_state = "injector_state"
gui_focus_request = "gui_focus_request"
user_confirm_request = "user_confirm_request"
do_stack_switch = "do_stack_switch"
# for unit tests:
test1 = "test1"
test2 = "test2"

View File

@ -0,0 +1,306 @@
# -*- 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/>.
"""Talking to the ReaderService that has root permissions.
see gui.reader_service.ReaderService
"""
import time
from typing import Optional, List, Generator, Dict, Set
import evdev
from gi.repository import GLib
from inputremapper.configs.input_config import (
InputCombination,
DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE,
)
from inputremapper.groups import _Groups, _Group
from inputremapper.gui.gettext import _
from inputremapper.gui.messages.message_broker import MessageBroker
from inputremapper.gui.messages.message_data import (
GroupsData,
CombinationRecorded,
StatusData,
)
from inputremapper.gui.messages.message_types import MessageType
from inputremapper.gui.reader_service import (
MSG_EVENT,
MSG_GROUPS,
CMD_TERMINATE,
CMD_REFRESH_GROUPS,
CMD_STOP_READING,
ReaderService,
)
from inputremapper.gui.utils import CTX_ERROR
from inputremapper.input_event import InputEvent
from inputremapper.ipc.pipe import Pipe
from inputremapper.logging.logger import logger
BLACKLISTED_EVENTS = [(1, evdev.ecodes.BTN_TOOL_DOUBLETAP)]
RecordingGenerator = Generator[None, InputEvent, None]
class ReaderClient:
"""Processes events from the reader-service for the GUI to use.
Does not serve any purpose for the injection service.
When a button was pressed, the newest keycode can be obtained from this object.
GTK has get_key for keyboard keys, but Reader also has knowledge of buttons like
the middle-mouse button.
"""
# how long to wait for the reader-service at most
_timeout: int = 5
def __init__(self, message_broker: MessageBroker, groups: _Groups):
self.groups = groups
self.message_broker = message_broker
self.group: Optional[_Group] = None
self._recording_generator: Optional[RecordingGenerator] = None
self._results_pipe, self._commands_pipe = self.connect()
self.attach_to_events()
self._read_timeout = GLib.timeout_add(30, self._read)
def ensure_reader_service_running(self):
if ReaderService.is_running():
return
logger.info("ReaderService not running anymore, restarting")
ReaderService.pkexec_reader_service()
# wait until the ReaderService is up
# wait no more than:
polling_period = 0.01
# this will make the gui non-responsive for 0.4s or something. The pkexec
# password prompt will appear, so the user understands that the lag has to
# be connected to the authentication. I would actually prefer the frozen gui
# over a reactive one here, because the short lag shows that stuff is going on
# behind the scenes.
for __ in range(int(self._timeout / polling_period)):
if self._results_pipe.poll():
logger.info("ReaderService started")
break
time.sleep(polling_period)
else:
msg = "The reader-service did not start"
logger.error(msg)
self.message_broker.publish(StatusData(CTX_ERROR, _(msg)))
def _send_command(self, command: str):
"""Send a command to the ReaderService."""
if command not in [CMD_TERMINATE, CMD_STOP_READING]:
self.ensure_reader_service_running()
logger.debug('Sending "%s" to ReaderService', command)
self._commands_pipe.send(command)
def connect(self):
"""Connect to the reader-service."""
results_pipe = Pipe(ReaderService.get_pipe_paths()[0])
commands_pipe = Pipe(ReaderService.get_pipe_paths()[1])
return results_pipe, commands_pipe
def attach_to_events(self):
"""Connect listeners to event_reader."""
self.message_broker.subscribe(
MessageType.terminate,
lambda _: self.terminate(),
)
def _read(self):
"""Read the messages from the reader-service and handle them."""
while self._results_pipe.poll():
message = self._results_pipe.recv()
logger.debug("received %s", message)
message_type = message["type"]
message_body = message["message"]
if message_type == MSG_GROUPS:
self._update_groups(message_body)
if message_type == MSG_EVENT:
# update the generator
try:
if self._recording_generator is not None:
self._recording_generator.send(InputEvent(**message_body))
else:
# the ReaderService should only send events while the gui
# is recording, so this is unexpected.
logger.error("Got event, but recorder is not running.")
except StopIteration:
# the _recording_generator returned
logger.debug("Recorder finished.")
self.stop_recorder()
break
return True
def start_recorder(self) -> None:
"""Record user input."""
if self.group is None:
logger.error("No group set")
return
logger.debug("Starting recorder.")
self._send_command(self.group.key)
self._recording_generator = self._recorder()
next(self._recording_generator)
self.message_broker.signal(MessageType.recording_started)
def stop_recorder(self) -> None:
"""Stop recording the input.
Will send recording_finished signals.
"""
logger.debug("Stopping recorder.")
self._send_command(CMD_STOP_READING)
if self._recording_generator:
self._recording_generator.close()
self._recording_generator = None
else:
# this would be unexpected. but this is not critical enough to
# show to the user without debug logs
logger.debug("No recording generator existed")
self.message_broker.signal(MessageType.recording_finished)
@staticmethod
def _input_event_to_config(event: InputEvent):
# This used to default to event.value, which was broken for joysticks because
# it resulted in a very low value (I tihnk 1). Which I think was because we
# overwrote the event.value with 1 in the handlers.
# For joysticks the default uses DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE
# in percent now. This value would break moues movements, because mice don't
# have a maximum value that we could use for percent calculations.
# So for EV_REL we just use 1 or -1 to keep it working the same way it used to
# work.
analog_threshold = event.direction
if event.type == evdev.ecodes.EV_ABS:
analog_threshold = event.direction * DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE
return {
"type": event.type,
"code": event.code,
"analog_threshold": analog_threshold,
"origin_hash": event.origin_hash,
}
def _recorder(self) -> RecordingGenerator:
"""Generator which receives InputEvents.
It accumulates them into EventCombinations and sends those on the
message_broker. It will stop once all keys or inputs are released.
"""
active: Set = set()
accumulator: List[InputEvent] = []
while True:
event: InputEvent = yield
if event.type_and_code in BLACKLISTED_EVENTS:
continue
if not event.is_pressed():
try:
active.remove(event.input_match_hash)
except KeyError:
# we haven't seen this before probably a key got released which
# was pressed before we started recording. ignore it.
continue
if not active:
# all previously recorded events are released
return
continue
active.add(event.input_match_hash)
accu_input_hashes = [e.input_match_hash for e in accumulator]
if event.input_match_hash in accu_input_hashes and event not in accumulator:
# the value has changed but the event is already in the accumulator
# update the event
i = accu_input_hashes.index(event.input_match_hash)
accumulator[i] = event
self.message_broker.publish(
CombinationRecorded(
InputCombination(map(self._input_event_to_config, accumulator))
)
)
if event not in accumulator:
accumulator.append(event)
self.message_broker.publish(
CombinationRecorded(
InputCombination(map(self._input_event_to_config, accumulator))
)
)
def set_group(self, group: Optional[_Group]):
"""Set the group for which input events should be read later."""
# TODO load the active_group from the controller instead?
self.group = group
def terminate(self):
"""Stop reading keycodes for good."""
self._send_command(CMD_TERMINATE)
self.stop_recorder()
if self._read_timeout is not None:
GLib.source_remove(self._read_timeout)
self._read_timeout = None
while self._results_pipe.poll():
self._results_pipe.recv()
def refresh_groups(self):
"""Ask the ReaderService for new device groups."""
self._send_command(CMD_REFRESH_GROUPS)
def publish_groups(self):
"""Announce all known groups."""
groups: Dict[str, List[str]] = {
group.key: group.types or [] for group in self.groups.get_groups()
}
self.message_broker.publish(GroupsData(groups))
def _update_groups(self, dump: str):
if dump != self.groups.dumps():
self.groups.loads(dump)
logger.debug("Received %d devices", len(self.groups.get_groups()))
self._groups_updated = True
# send this even if the groups did not change, as the user expects the ui
# to respond in some form
self.publish_groups()

View File

@ -0,0 +1,420 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@hip70890b.de>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Process that sends stuff to the GUI.
It should be started via input-remapper-control and pkexec.
GUIs should not run as root
https://wiki.archlinux.org/index.php/Running_GUI_applications_as_root
The service shouldn't do that even though it has root rights, because that
would enable key-loggers to just ask input-remapper for all user-input.
Instead, the ReaderService is used, which will be stopped when the gui closes.
Whereas for the reader-service to start a password is needed and it stops whe
the ui closes.
This uses the backend injection.event_reader and mapping_handlers to process all the
different input-events into simple on/off events and sends them to the gui.
"""
from __future__ import annotations
import asyncio
import logging
import multiprocessing
import os
import subprocess
import sys
import time
from collections import defaultdict
from typing import Set, List, Tuple
import evdev
from evdev.ecodes import EV_KEY, EV_ABS, EV_REL, REL_HWHEEL, REL_WHEEL
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping, KnownUinput
from inputremapper.groups import _Groups, _Group
from inputremapper.injection.event_reader import EventReader
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
from inputremapper.injection.mapping_handlers.mapping_handler import (
NotifyCallback,
MappingHandler,
)
from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler
from inputremapper.input_event import InputEvent
from inputremapper.ipc.pipe import Pipe
from inputremapper.logging.logger import logger
from inputremapper.user import UserUtils
from inputremapper.utils import get_device_hash
from inputremapper.gui.forward_to_ui_handler import ForwardToUIHandler
# received by the reader-service
CMD_TERMINATE = "terminate"
CMD_STOP_READING = "stop-reading"
CMD_REFRESH_GROUPS = "refresh_groups"
# sent by the reader-service to the reader
MSG_GROUPS = "groups"
MSG_EVENT = "event"
MSG_STATUS = "status"
RELEASE_TIMEOUT = 0.3
class ReaderService:
"""Service that only reads events and is supposed to run as root.
Sends device information and keycodes to the GUI.
Commands are either numbers for generic commands,
or strings to start listening on a specific device.
"""
# the speed threshold at which relative axis are considered moving
# and will be sent as "pressed" to the frontend.
# We want to allow some mouse movement before we record it as an input
rel_xy_speed = defaultdict(lambda: 3)
# wheel events usually don't produce values higher than 1
rel_xy_speed[REL_WHEEL] = 1
rel_xy_speed[REL_HWHEEL] = 1
# Polkit won't ask for another password if the pid stays the same or something, and
# if the previous request was no more than 5 minutes ago. see
# https://unix.stackexchange.com/a/458260.
# If the user does something after 6 minutes they will get a prompt already if the
# reader timed out already, which sounds annoying. Instead, I'd rather have the
# password prompt appear at most every 15 minutes.
_maximum_lifetime: int = 60 * 15
_timeout_tolerance: int = 60
def __init__(self, groups: _Groups, global_uinputs: GlobalUInputs) -> None:
"""Construct the reader-service and initialize its communication pipes."""
self._start_time = time.time()
self.groups = groups
self.global_uinputs = global_uinputs
self._results_pipe = Pipe(self.get_pipe_paths()[0])
self._commands_pipe = Pipe(self.get_pipe_paths()[1])
self._pipe = multiprocessing.Pipe()
self._tasks: Set[asyncio.Task] = set()
self._stop_event = asyncio.Event()
self._results_pipe.send({"type": MSG_STATUS, "message": "ready"})
@staticmethod
def get_pipe_paths() -> Tuple[str, str]:
"""Get the path where the pipe can be found."""
return (
f"/tmp/input-remapper-{UserUtils.home}/reader-results",
f"/tmp/input-remapper-{UserUtils.home}/reader-commands",
)
@staticmethod
def pipes_exist() -> bool:
# Just checking for one of the 4 files (results, commands both read and write)
# should be enough I guess.
path = f"{ReaderService.get_pipe_paths()[0]}r"
# Use os.path.exists, not lexists or islink, because broken links are bad.
# New pipes and symlinks need to be made.
return os.path.exists(path)
@staticmethod
def is_running() -> bool:
"""Check if the reader-service is running."""
try:
subprocess.check_output(["pgrep", "-f", "input-remapper-reader-service"])
except subprocess.CalledProcessError:
return False
return True
@staticmethod
def pkexec_reader_service() -> None:
"""Start reader-service via pkexec to run in the background."""
debug = " -d" if logger.level <= logging.DEBUG else ""
cmd = f"pkexec input-remapper-control --command start-reader-service{debug}"
logger.debug("Running `%s`", cmd)
exit_code = os.system(cmd)
if exit_code != 0:
raise Exception(f"Failed to pkexec the reader-service, code {exit_code}")
async def run(self) -> None:
"""Start doing stuff."""
# the reader will check for new commands later, once it is running
# it keeps running for one device or another.
logger.debug("Discovering initial groups")
self.groups.refresh()
self._send_groups()
await asyncio.gather(
self._read_commands(),
self._timeout(),
self._stop_if_pipes_broken(),
)
def _send_groups(self) -> None:
"""Send the groups to the gui."""
logger.debug("Sending groups")
self._results_pipe.send({"type": MSG_GROUPS, "message": self.groups.dumps()})
async def _timeout(self) -> None:
"""Stop automatically after some time."""
# Prevents a permanent hole for key-loggers to exist, in case the gui crashes.
# If the ReaderService stops even though the gui needs it, it needs to restart
# it. This makes it also more comfortable to have debug mode running during
# development, because it won't keep writing inputs containing passwords and
# such to the terminal forever.
await asyncio.sleep(self._maximum_lifetime)
# if it is currently reading, wait a bit longer for the gui to complete
# what it is doing.
if self._is_reading():
logger.debug("Waiting a bit longer for the gui to finish reading")
for _ in range(self._timeout_tolerance):
if not self._is_reading():
# once reading completes, it should terminate right away
break
await asyncio.sleep(1)
logger.debug("Maximum life-span reached, terminating")
sys.exit(1)
async def _read_commands(self) -> None:
"""Handle all unread commands.
this will run until it receives CMD_TERMINATE
"""
logger.debug("Waiting for commands")
async for cmd in self._commands_pipe:
logger.debug('Received command "%s"', cmd)
if cmd == CMD_TERMINATE:
await self._stop_reading()
logger.debug("Terminating")
sys.exit(0)
if cmd == CMD_REFRESH_GROUPS:
self.groups.refresh()
self._send_groups()
continue
if cmd == CMD_STOP_READING:
await self._stop_reading()
continue
group = self.groups.find(key=cmd)
if group is None:
# this will block for a bit maybe we want to do this async?
self.groups.refresh()
group = self.groups.find(key=cmd)
if group is not None:
await self._stop_reading()
self._start_reading(group)
continue
logger.error('Received unknown command "%s"', cmd)
async def _stop_if_pipes_broken(self) -> None:
# The GUI probably exited, and failed to tell the reader-service to stop.
# Pipes are owned by the GUI process, because the non-privileged GUI process
# needs to be able to read them. Therefore, they are gone.
while True:
await asyncio.sleep(1)
if not self.pipes_exist():
await self._stop_reading()
logger.debug("Pipes broken, exiting")
sys.exit(13)
def _is_reading(self) -> bool:
"""Check if the ReaderService is currently sending events to the GUI."""
return len(self._tasks) > 0
def _start_reading(self, group: _Group) -> None:
"""Find all devices of that group, filter interesting ones and send the events
to the gui."""
sources = []
for path in group.paths:
try:
device = evdev.InputDevice(path)
except (FileNotFoundError, OSError):
logger.error('Could not find "%s"', path)
return None
capabilities = device.capabilities(absinfo=False)
if (
EV_KEY in capabilities
or EV_ABS in capabilities
or EV_REL in capabilities
):
sources.append(device)
context = self._create_event_pipeline(sources)
# create the event reader and start it
for device in sources:
reader = EventReader(context, device, self._stop_event)
self._tasks.add(asyncio.create_task(reader.run()))
async def _stop_reading(self) -> None:
"""Stop the running event_reader."""
self._stop_event.set()
if self._tasks:
await asyncio.gather(*self._tasks)
self._tasks = set()
self._stop_event.clear()
def _create_event_pipeline(self, sources: List[evdev.InputDevice]) -> ContextDummy:
"""Create a custom event pipeline for each event code in the capabilities.
Instead of sending the events to an uinput they will be sent to the frontend.
"""
context_dummy = ContextDummy()
# create a context for each source
for device in sources:
device_hash = get_device_hash(device)
capabilities = device.capabilities(absinfo=False)
for ev_code in capabilities.get(EV_KEY) or ():
input_config = InputConfig(
type=EV_KEY,
code=ev_code,
origin_hash=device_hash,
)
context_dummy.add_handler(
input_config,
ForwardToUIHandler(self._results_pipe),
)
for ev_code in capabilities.get(EV_ABS) or ():
# positive direction
input_config = InputConfig(
type=EV_ABS,
code=ev_code,
analog_threshold=30,
origin_hash=device_hash,
)
mapping = Mapping(
input_combination=InputCombination([input_config]),
target_uinput=KnownUinput.KEYBOARD,
output_symbol="KEY_A",
)
handler: MappingHandler = AbsToBtnHandler(
InputCombination([input_config]),
mapping,
self.global_uinputs,
)
handler.set_sub_handler(ForwardToUIHandler(self._results_pipe))
context_dummy.add_handler(input_config, handler)
# negative direction
input_config = input_config.modify(analog_threshold=-30)
mapping = Mapping(
input_combination=InputCombination([input_config]),
target_uinput=KnownUinput.KEYBOARD,
output_symbol="KEY_A",
)
handler = AbsToBtnHandler(
InputCombination([input_config]),
mapping,
self.global_uinputs,
)
handler.set_sub_handler(ForwardToUIHandler(self._results_pipe))
context_dummy.add_handler(input_config, handler)
for ev_code in capabilities.get(EV_REL) or ():
# positive direction
input_config = InputConfig(
type=EV_REL,
code=ev_code,
analog_threshold=self.rel_xy_speed[ev_code],
origin_hash=device_hash,
)
mapping = Mapping(
input_combination=InputCombination([input_config]),
target_uinput=KnownUinput.KEYBOARD,
output_symbol="KEY_A",
release_timeout=RELEASE_TIMEOUT,
force_release_timeout=True,
)
handler = RelToBtnHandler(
InputCombination([input_config]),
mapping,
self.global_uinputs,
)
handler.set_sub_handler(ForwardToUIHandler(self._results_pipe))
context_dummy.add_handler(input_config, handler)
# negative direction
input_config = input_config.modify(
analog_threshold=-self.rel_xy_speed[ev_code]
)
mapping = Mapping(
input_combination=InputCombination([input_config]),
target_uinput=KnownUinput.KEYBOARD,
output_symbol="KEY_A",
release_timeout=RELEASE_TIMEOUT,
force_release_timeout=True,
)
handler = RelToBtnHandler(
InputCombination([input_config]),
mapping,
self.global_uinputs,
)
handler.set_sub_handler(ForwardToUIHandler(self._results_pipe))
context_dummy.add_handler(input_config, handler)
return context_dummy
class ForwardDummy(evdev.UInput):
# You may add more attributes of evdev.UInput here for compatibility
name: str = "forward-dummy"
@staticmethod
def write(*_):
pass
class ContextDummy:
"""Used for the reader so that no events are actually written to any uinput."""
def __init__(self):
self.listeners = set()
self._notify_callbacks = defaultdict(list)
self.forward_dummy = ForwardDummy()
def add_handler(self, input_config: InputConfig, handler: MappingHandler):
self._notify_callbacks[input_config.input_match_hash].append(handler.notify)
def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]:
return self._notify_callbacks[input_event.input_match_hash]
def reset(self):
pass
def get_forward_uinput(self, origin_hash) -> evdev.UInput:
"""Don't actually write anything."""
return self.forward_dummy

View File

@ -0,0 +1,417 @@
# -*- 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/>.
"""User Interface."""
from typing import Dict, Callable
from gi.repository import Gtk, GtkSource, Gdk, GObject
from inputremapper.configs.data import get_data_path
from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.mapping import MappingData
from inputremapper.gui.autocompletion import Autocompletion
from inputremapper.gui.components.common import Breadcrumbs
from inputremapper.gui.components.device_groups import DeviceGroupSelection
from inputremapper.gui.components.editor import (
MappingListBox,
TargetSelection,
CodeEditor,
RecordingToggle,
RecordingStatus,
AutoloadSwitch,
ReleaseCombinationSwitch,
CombinationListbox,
AnalogInputSwitch,
TriggerThresholdInput,
OutputAxisSelector,
ReleaseTimeoutInput,
TransformationDrawArea,
Sliders,
RelativeInputCutoffInput,
KeyAxisStackSwitcher,
RequireActiveMapping,
GdkEventRecorder,
)
from inputremapper.gui.components.main import Stack, StatusBar
from inputremapper.gui.components.presets import PresetSelection
from inputremapper.gui.controller import Controller
from inputremapper.gui.gettext import _
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import UserConfirmRequest
from inputremapper.gui.utils import (
gtk_iteration,
)
from inputremapper.injection.injector import InjectorStateMessage
from inputremapper.logging.logger import logger, COMMIT_HASH, VERSION, EVDEV_VERSION
# https://cjenkins.wordpress.com/2012/05/08/use-gtksourceview-widget-in-glade/
GObject.type_register(GtkSource.View)
# GtkSource.View() also works:
# https://stackoverflow.com/questions/60126579/gtk-builder-error-quark-invalid-object-type-webkitwebview
def on_close_about(about, _):
"""Hide the about dialog without destroying it."""
about.hide()
return True
class UserInterface:
"""The input-remapper gtk window."""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
):
self.message_broker = message_broker
self.controller = controller
# all shortcuts executed when ctrl+...
self.shortcuts: Dict[int, Callable] = {
Gdk.KEY_q: self.controller.close,
Gdk.KEY_r: self.controller.refresh_groups,
Gdk.KEY_Delete: self.controller.stop_injecting,
Gdk.KEY_n: self.controller.add_preset,
}
# stores the ids for all the listeners attached to the gui
self.gtk_listeners: Dict[Callable, int] = {}
self.message_broker.subscribe(MessageType.terminate, lambda _: self.close())
self.builder = Gtk.Builder()
self._build_ui()
self.window: Gtk.Window = self.get("window")
self.about: Gtk.Window = self.get("about-dialog")
self.combination_editor: Gtk.Dialog = self.get("combination-editor")
self._create_dialogs()
self._create_components()
self._connect_gtk_signals()
self._connect_message_listener()
self.window.show()
# hide everything until stuff is populated
self.get("vertical-wrapper").set_opacity(0)
# if any of the next steps take a bit to complete, have the window
# already visible (without content) to make it look more responsive.
gtk_iteration()
# now show the proper finished content of the window
self.get("vertical-wrapper").set_opacity(1)
def _build_ui(self):
"""Build the window from stylesheet and gladefile."""
css_provider = Gtk.CssProvider()
with open(get_data_path("style.css"), "r") as file:
css_provider.load_from_data(bytes(file.read(), encoding="UTF-8"))
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(),
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
gladefile = get_data_path("input-remapper.glade")
self.builder.add_from_file(gladefile)
self.builder.connect_signals(self)
def _create_components(self):
"""Setup all objects which manage individual components of the ui."""
message_broker = self.message_broker
controller = self.controller
DeviceGroupSelection(message_broker, controller, self.get("device_selection"))
PresetSelection(message_broker, controller, self.get("preset_selection"))
MappingListBox(message_broker, controller, self.get("selection_label_listbox"))
TargetSelection(message_broker, controller, self.get("target-selector"))
Breadcrumbs(
message_broker,
self.get("selected_device_name"),
show_device_group=True,
)
Breadcrumbs(
message_broker,
self.get("selected_preset_name"),
show_device_group=True,
show_preset=True,
)
Stack(message_broker, controller, self.get("main_stack"))
RecordingToggle(message_broker, controller, self.get("key_recording_toggle"))
StatusBar(
message_broker,
controller,
self.get("status_bar"),
self.get("error_status_icon"),
self.get("warning_status_icon"),
)
RecordingStatus(message_broker, self.get("recording_status"))
AutoloadSwitch(message_broker, controller, self.get("preset_autoload_switch"))
ReleaseCombinationSwitch(
message_broker, controller, self.get("release-combination-switch")
)
CombinationListbox(message_broker, controller, self.get("combination-listbox"))
AnalogInputSwitch(message_broker, controller, self.get("analog-input-switch"))
TriggerThresholdInput(
message_broker, controller, self.get("trigger-threshold-spin-btn")
)
RelativeInputCutoffInput(
message_broker, controller, self.get("input-cutoff-spin-btn")
)
OutputAxisSelector(message_broker, controller, self.get("output-axis-selector"))
KeyAxisStackSwitcher(
message_broker,
controller,
self.get("editor-stack"),
self.get("key_macro_toggle_btn"),
self.get("analog_toggle_btn"),
)
ReleaseTimeoutInput(
message_broker, controller, self.get("release-timeout-spin-button")
)
TransformationDrawArea(
message_broker, controller, self.get("transformation-draw-area")
)
Sliders(
message_broker,
controller,
self.get("gain-scale"),
self.get("deadzone-scale"),
self.get("expo-scale"),
)
GdkEventRecorder(self.window, self.get("gdk-event-recorder-label"))
RequireActiveMapping(
message_broker,
self.get("edit-combination-btn"),
require_recorded_input=True,
)
RequireActiveMapping(
message_broker,
self.get("output"),
require_recorded_input=True,
)
RequireActiveMapping(
message_broker,
self.get("delete-mapping"),
require_recorded_input=False,
)
# code editor and autocompletion
code_editor = CodeEditor(message_broker, controller, self.get("code_editor"))
autocompletion = Autocompletion(message_broker, controller, code_editor)
autocompletion.set_relative_to(self.get("code_editor_container"))
self.autocompletion = autocompletion # only for testing
def _create_dialogs(self):
"""Setup different dialogs, such as the about page."""
self.about.connect("delete-event", on_close_about)
# set_position needs to be done once initially, otherwise the
# dialog is not centered when it is opened for the first time
self.about.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
self.get("version-label").set_text(
f"input-remapper {VERSION} {COMMIT_HASH[:7]}"
f"\npython-evdev {EVDEV_VERSION}"
if EVDEV_VERSION
else ""
)
def _connect_gtk_signals(self):
self.get("delete_preset").connect(
"clicked", lambda *_: self.controller.delete_preset()
)
self.get("copy_preset").connect(
"clicked", lambda *_: self.controller.copy_preset()
)
self.get("create_preset").connect(
"clicked", lambda *_: self.controller.add_preset()
)
self.get("apply_preset").connect(
"clicked", lambda *_: self.controller.start_injecting()
)
self.get("stop_injection_preset_page").connect(
"clicked", lambda *_: self.controller.stop_injecting()
)
self.get("stop_injection_editor_page").connect(
"clicked", lambda *_: self.controller.stop_injecting()
)
self.get("rename-button").connect("clicked", self.on_gtk_rename_clicked)
self.get("preset_name_input").connect(
"key-release-event", self.on_gtk_preset_name_input_return
)
self.get("create_mapping_button").connect(
"clicked", lambda *_: self.controller.create_mapping()
)
self.get("delete-mapping").connect(
"clicked", lambda *_: self.controller.delete_mapping()
)
self.combination_editor.connect(
# it only takes self as argument, but delete-events provides more
# probably a gtk bug
"delete-event",
lambda dialog, *_: Gtk.Widget.hide_on_delete(dialog),
)
self.get("edit-combination-btn").connect(
"clicked", lambda *_: self.combination_editor.show()
)
self.get("remove-event-btn").connect(
"clicked", lambda *_: self.controller.remove_event()
)
self.connect_shortcuts()
def _connect_message_listener(self):
self.message_broker.subscribe(
MessageType.mapping, self.update_combination_label
)
self.message_broker.subscribe(
MessageType.injector_state, self.on_injector_state_msg
)
self.message_broker.subscribe(
MessageType.user_confirm_request, self._on_user_confirm_request
)
def _create_dialog(self, primary: str, secondary: str) -> Gtk.MessageDialog:
"""Create a message dialog with cancel and confirm buttons."""
message_dialog = Gtk.MessageDialog(
self.window,
Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT,
Gtk.MessageType.QUESTION,
Gtk.ButtonsType.NONE,
primary,
)
if secondary:
message_dialog.format_secondary_text(secondary)
message_dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
confirm_button = message_dialog.add_button("Confirm", Gtk.ResponseType.ACCEPT)
confirm_button.get_style_context().add_class(Gtk.STYLE_CLASS_DESTRUCTIVE_ACTION)
return message_dialog
def _on_user_confirm_request(self, msg: UserConfirmRequest):
# if the message contains a line-break, use the first chunk for the primary
# message, and the rest for the secondary message.
chunks = msg.msg.split("\n")
primary = chunks[0]
secondary = " ".join(chunks[1:])
message_dialog = self._create_dialog(primary, secondary)
response = message_dialog.run()
msg.respond(response == Gtk.ResponseType.ACCEPT)
message_dialog.hide()
def on_injector_state_msg(self, msg: InjectorStateMessage):
"""Update the ui to reflect the status of the injector."""
stop_injection_preset_page: Gtk.Button = self.get("stop_injection_preset_page")
stop_injection_editor_page: Gtk.Button = self.get("stop_injection_editor_page")
recording_toggle: Gtk.ToggleButton = self.get("key_recording_toggle")
if msg.active():
stop_injection_preset_page.set_opacity(1)
stop_injection_editor_page.set_opacity(1)
stop_injection_preset_page.set_sensitive(True)
stop_injection_editor_page.set_sensitive(True)
recording_toggle.set_opacity(0.5)
else:
stop_injection_preset_page.set_opacity(0.5)
stop_injection_editor_page.set_opacity(0.5)
stop_injection_preset_page.set_sensitive(True)
stop_injection_editor_page.set_sensitive(True)
recording_toggle.set_opacity(1)
def disconnect_shortcuts(self):
"""Stop listening for shortcuts.
e.g. when recording key combinations
"""
try:
self.window.disconnect(self.gtk_listeners.pop(self.on_gtk_shortcut))
except KeyError:
logger.debug("key listeners seem to be not connected")
def connect_shortcuts(self):
"""Start listening for shortcuts."""
if not self.gtk_listeners.get(self.on_gtk_shortcut):
self.gtk_listeners[self.on_gtk_shortcut] = self.window.connect(
"key-press-event", self.on_gtk_shortcut
)
def get(self, name: str):
"""Get a widget from the window."""
return self.builder.get_object(name)
def close(self):
"""Close the window."""
logger.debug("Closing window")
self.window.hide()
def update_combination_label(self, mapping: MappingData):
"""Listens for mapping and updates the combination label."""
label: Gtk.Label = self.get("combination-label")
if mapping.input_combination.beautify() == label.get_label():
return
if mapping.input_combination == InputCombination.empty_combination():
label.set_opacity(0.5)
label.set_label(_("no input configured"))
return
label.set_opacity(1)
label.set_label(mapping.input_combination.beautify())
def on_gtk_shortcut(self, _, event: Gdk.EventKey):
"""Execute shortcuts."""
if event.state & Gdk.ModifierType.CONTROL_MASK:
try:
self.shortcuts[event.keyval]()
except KeyError:
pass
def on_gtk_close(self, *_):
self.controller.close()
def on_gtk_about_clicked(self, _):
"""Show the about/help dialog."""
self.about.show()
def on_gtk_about_key_press(self, _, event):
"""Hide the about/help dialog."""
gdk_keycode = event.get_keyval()[1]
if gdk_keycode == Gdk.KEY_Escape:
self.about.hide()
def on_gtk_rename_clicked(self, *_):
name = self.get("preset_name_input").get_text()
self.controller.rename_preset(name)
self.get("preset_name_input").set_text("")
def on_gtk_preset_name_input_return(self, _, event: Gdk.EventKey):
if event.keyval == Gdk.KEY_Return:
self.on_gtk_rename_clicked()

272
inputremapper/gui/utils.py Normal file
View File

@ -0,0 +1,272 @@
# -*- 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 time
from dataclasses import dataclass
from typing import List, Callable, Dict, Optional
from gi.repository import Gtk, GLib, Gdk
from inputremapper.logging.logger import logger
# status ctx ids
CTX_SAVE = 0
CTX_APPLY = 1
CTX_KEYCODE = 2
CTX_ERROR = 3
CTX_WARNING = 4
CTX_MAPPING = 5
@dataclass()
class DebounceInfo:
# constant after register:
function: Optional[Callable]
other: object
key: int
# can change when called again:
args: list
kwargs: dict
glib_timeout: Optional[int]
class DebounceManager:
"""Stops all debounced functions if needed."""
debounce_infos: Dict[int, DebounceInfo] = {}
def _register(self, other, function):
debounce_info = DebounceInfo(
function=function,
glib_timeout=None,
other=other,
args=[],
kwargs={},
key=self._get_key(other, function),
)
key = self._get_key(other, function)
self.debounce_infos[key] = debounce_info
return debounce_info
def get(self, other: object, function: Callable) -> Optional[DebounceInfo]:
"""Find the debounce_info that matches the given callable."""
key = self._get_key(other, function)
return self.debounce_infos.get(key)
def _get_key(self, other, function):
return f"{id(other)},{function.__name__}"
def debounce(self, other, function, timeout_ms, *args, **kwargs):
"""Call this function with the given args later."""
debounce_info = self.get(other, function)
if debounce_info is None:
debounce_info = self._register(other, function)
debounce_info.args = args
debounce_info.kwargs = kwargs
glib_timeout = debounce_info.glib_timeout
if glib_timeout is not None:
GLib.source_remove(glib_timeout)
def run():
self.stop(other, function)
return function(other, *args, **kwargs)
debounce_info.glib_timeout = GLib.timeout_add(
timeout_ms,
lambda: run(),
)
def stop(self, other: object, function: Callable):
"""Stop the current debounce timeout of this function and don't call it.
New calls to that function will be debounced again.
"""
debounce_info = self.get(other, function)
if debounce_info is None:
logger.debug("Tried to stop function that is not currently scheduled")
return
if debounce_info.glib_timeout is not None:
GLib.source_remove(debounce_info.glib_timeout)
debounce_info.glib_timeout = None
def stop_all(self):
"""No debounced function should be called anymore after this.
New calls to that function will be debounced again.
"""
for debounce_info in self.debounce_infos.values():
self.stop(debounce_info.other, debounce_info.function)
def run_all_now(self):
"""Don't wait any longer."""
for debounce_info in self.debounce_infos.values():
if debounce_info.glib_timeout is None:
# nothing is currently waiting for this function to be called
continue
self.stop(debounce_info.other, debounce_info.function)
try:
logger.warning(
'Running "%s" now without waiting',
debounce_info.function.__name__,
)
debounce_info.function(
debounce_info.other,
*debounce_info.args,
**debounce_info.kwargs,
)
except Exception as exception:
# if individual functions fails, continue calling the others.
# also, don't raise this because there is nowhere this exception
# could be caught in a useful way
logger.error(exception)
def debounce(timeout):
"""Debounce a method call to improve performance.
Calling this with a millisecond value creates the decorator, so use something like
@debounce(50)
def function(self):
...
In tests, run_all_now can be used to avoid waiting to speed them up.
"""
# the outside `debounce` function is needed to obtain the millisecond value
def decorator(function):
# the regular decorator.
# @decorator
# def foo():
# ...
def wrapped(self, *args, **kwargs):
# this is the function that will actually be called
debounce_manager.debounce(self, function, timeout, *args, **kwargs)
wrapped.__name__ = function.__name__
return wrapped
return decorator
debounce_manager = DebounceManager()
class HandlerDisabled:
"""Safely modify a widget without causing handlers to be called.
Use in a `with` statement.
"""
def __init__(self, widget: Gtk.Widget, handler: Callable):
self.widget = widget
self.handler = handler
def __enter__(self):
try:
self.widget.handler_block_by_func(self.handler)
except TypeError as error:
# if nothing is connected to the given signal, it is not critical
# at all
logger.warning('HandlerDisabled entry failed: "%s"', error)
def __exit__(self, *_):
try:
self.widget.handler_unblock_by_func(self.handler)
except TypeError as error:
logger.warning('HandlerDisabled exit failed: "%s"', error)
def gtk_iteration(iterations=0):
"""Iterate while events are pending."""
while Gtk.events_pending():
Gtk.main_iteration()
for _ in range(iterations):
time.sleep(0.002)
while Gtk.events_pending():
Gtk.main_iteration()
class Colors:
"""Looks up colors from the GTK theme.
Defaults to libadwaita-light theme colors if the lookup fails.
"""
fallback_accent = Gdk.RGBA(0.21, 0.52, 0.89, 1)
fallback_background = Gdk.RGBA(0.98, 0.98, 0.98, 1)
fallback_base = Gdk.RGBA(1, 1, 1, 1)
fallback_border = Gdk.RGBA(0.87, 0.87, 0.87, 1)
fallback_font = Gdk.RGBA(0.20, 0.20, 0.20, 1)
@staticmethod
def get_color(names: List[str], fallback: Gdk.RGBA) -> Gdk.RGBA:
"""Get theme colors. Provide multiple names for fallback purposes."""
for name in names:
found, color = Gtk.StyleContext().lookup_color(name)
if found:
return color
return fallback
@staticmethod
def get_accent_color() -> Gdk.RGBA:
"""Look up the accent color from the current theme."""
return Colors.get_color(
["accent_bg_color", "theme_selected_bg_color"],
Colors.fallback_accent,
)
@staticmethod
def get_background_color() -> Gdk.RGBA:
"""Look up the background-color from the current theme."""
return Colors.get_color(
["theme_bg_color"],
Colors.fallback_background,
)
@staticmethod
def get_base_color() -> Gdk.RGBA:
"""Look up the base-color from the current theme."""
return Colors.get_color(
["theme_base_color"],
Colors.fallback_base,
)
@staticmethod
def get_border_color() -> Gdk.RGBA:
"""Look up the border from the current theme."""
return Colors.get_color(["borders"], Colors.fallback_border)
@staticmethod
def get_font_color() -> Gdk.RGBA:
"""Look up the border from the current theme."""
return Colors.get_color(
["theme_fg_color"],
Colors.fallback_font,
)

View File

View File

@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Stores injection-process wide information."""
from __future__ import annotations
from collections import defaultdict
from typing import List, Dict, Set, Hashable
import evdev
from inputremapper.configs.preset import Preset
from inputremapper.injection.mapping_handlers.mapping_handler import (
EventListener,
NotifyCallback,
)
from inputremapper.injection.mapping_handlers.mapping_parser import (
MappingParser,
EventPipelines,
)
from inputremapper.input_event import InputEvent
from inputremapper.logging.logger import logger
from inputremapper.utils import DeviceHash
class Context:
"""Stores injection-process wide information.
In some ways this is a wrapper for the preset that derives some
information that is specifically important to the injection.
The information in the context does not change during the injection.
One Context exists for each injection process, which is shared
with all coroutines and used objects.
Benefits of the context:
- less redundant passing around of parameters
- easier to add new process wide information without having to adjust
all function calls in unittests
- makes the injection class shorter and more specific to a certain task,
which is actually spinning up the injection.
Note, that for the reader_service a ContextDummy is used.
Members
-------
preset : Preset
The preset holds all Mappings for the injection process
listeners : Set[EventListener]
A set of callbacks which receive all events
callbacks : Dict[Tuple[int, int], List[NotifyCallback]]
All entry points to the event pipeline sorted by InputEvent.type_and_code
"""
listeners: Set[EventListener]
_notify_callbacks: Dict[Hashable, List[NotifyCallback]]
_handlers: EventPipelines
_forward_devices: Dict[DeviceHash, evdev.UInput]
_source_devices: Dict[DeviceHash, evdev.InputDevice]
def __init__(
self,
preset: Preset,
source_devices: Dict[DeviceHash, evdev.InputDevice],
forward_devices: Dict[DeviceHash, evdev.UInput],
mapping_parser: MappingParser,
) -> None:
if len(forward_devices) == 0:
logger.warning("forward_devices not set")
if len(source_devices) == 0:
logger.warning("source_devices not set")
self.listeners = set()
self._source_devices = source_devices
self._forward_devices = forward_devices
self._notify_callbacks = defaultdict(list)
self._handlers = mapping_parser.parse_mappings(preset, self)
self._create_callbacks()
def reset(self) -> None:
"""Call the reset method for each handler in the context."""
for handlers in self._handlers.values():
for handler in handlers:
handler.reset()
def _create_callbacks(self) -> None:
"""Add the notify method from all _handlers to self.callbacks."""
for input_config, handler_list in self._handlers.items():
input_match_hash = input_config.input_match_hash
logger.debug("Adding NotifyCallback for %s", input_match_hash)
self._notify_callbacks[input_match_hash].extend(
handler.notify for handler in handler_list
)
def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]:
input_match_hash = input_event.input_match_hash
return self._notify_callbacks[input_match_hash]
def get_forward_uinput(self, origin_hash: DeviceHash) -> evdev.UInput:
"""Get the "forward" uinput events from the given origin should go into."""
return self._forward_devices[origin_hash]
def get_source(self, key: DeviceHash) -> evdev.InputDevice:
return self._source_devices[key]
def get_leds(self) -> Set[int]:
"""Get a set of LED_* ecodes that are currently on."""
leds = set()
for device in self._source_devices.values():
leds.update(device.leds())
return leds

View File

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

View File

@ -0,0 +1,192 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Union, Tuple, Optional, List, Type
import evdev
import inputremapper.exceptions
import inputremapper.utils
from inputremapper.logging.logger import logger
MIN_ABS = -(2**15) # -32768
MAX_ABS = 2**15 # 32768
DEV_NAME = "input-remapper"
DEFAULT_UINPUTS = {
# for event codes see linux/input-event-codes.h
"keyboard": {
evdev.ecodes.EV_KEY: list(evdev.ecodes.KEY.keys() & evdev.ecodes.keys.keys())
},
"gamepad": {
evdev.ecodes.EV_KEY: [*range(0x130, 0x13F)], # BTN_SOUTH - BTN_THUMBR
evdev.ecodes.EV_ABS: [
*(
(i, evdev.AbsInfo(0, MIN_ABS, MAX_ABS, 0, 0, 0))
for i in range(0x00, 0x06)
),
*((i, evdev.AbsInfo(0, -1, 1, 0, 0, 0)) for i in range(0x10, 0x12)),
], # 6-axis and 1 hat switch
},
"mouse": {
evdev.ecodes.EV_KEY: [*range(0x110, 0x118)], # BTN_LEFT - BTN_TASK
evdev.ecodes.EV_REL: [*range(0x00, 0x0D)], # all REL axis
},
}
DEFAULT_UINPUTS["keyboard + mouse"] = {
evdev.ecodes.EV_KEY: [
*DEFAULT_UINPUTS["keyboard"][evdev.ecodes.EV_KEY],
*DEFAULT_UINPUTS["mouse"][evdev.ecodes.EV_KEY],
],
evdev.ecodes.EV_REL: [
*DEFAULT_UINPUTS["mouse"][evdev.ecodes.EV_REL],
],
}
class UInput(evdev.UInput):
_capabilities_cache: Optional[Dict] = None
def __init__(self, *args, **kwargs):
name = kwargs["name"]
logger.debug('creating UInput device: "%s"', name)
super().__init__(*args, **kwargs)
def can_emit(self, event: Tuple[int, int, int]):
"""Check if an event can be emitted by the UIinput.
Wrong events might be injected if the group mappings are wrong,
"""
# this will never change, so we cache it since evdev runs an expensive loop to
# gather the capabilities. (can_emit is called regularly)
if self._capabilities_cache is None:
self._capabilities_cache = self.capabilities(absinfo=False)
return event[1] in self._capabilities_cache.get(event[0], [])
class FrontendUInput:
"""Uinput which can not actually send events, for use in the frontend."""
def __init__(self, *_, events=None, name="py-evdev-uinput", **__):
# see https://python-evdev.readthedocs.io/en/latest/apidoc.html#module-evdev.uinput # noqa pylint: disable=line-too-long
self.events = events
self.name = name
logger.debug('creating fake UInput device: "%s"', self.name)
def capabilities(self):
return self.events
class GlobalUInputs:
"""Manages all UInputs that are shared between all injection processes."""
def __init__(
self,
uinput_factory: Union[Type[UInput], Type[FrontendUInput]],
):
self.devices: Dict[str, Union[UInput, FrontendUInput]] = {}
self._uinput_factory = uinput_factory
def __iter__(self):
return iter(uinput for _, uinput in self.devices.items())
@staticmethod
def can_default_uinput_emit(target: str, type_: int, code: int) -> bool:
"""Check if the uinput with the target name is capable of the event."""
capabilities = DEFAULT_UINPUTS.get(target, {}).get(type_)
return capabilities is not None and code in capabilities
@staticmethod
def find_fitting_default_uinputs(type_: int, code: int) -> List[str]:
"""Find the names of default uinputs that are able to emit this event."""
return [
uinput
for uinput in DEFAULT_UINPUTS
if code in DEFAULT_UINPUTS[uinput].get(type_, [])
]
def reset(self):
self.devices = {}
self.prepare_all()
def prepare_all(self):
"""Generate UInputs."""
for name, events in DEFAULT_UINPUTS.items():
if name in self.devices.keys():
continue
self.devices[name] = self._uinput_factory(
name=f"{DEV_NAME} {name}",
phys=DEV_NAME,
events=events,
)
def prepare_single(self, name: str):
"""Generate a single uinput.
This has to be done in the main process before injections that use it start.
"""
if name not in DEFAULT_UINPUTS:
raise KeyError("Could not find a matching uinput to generate.")
if name in self.devices:
logger.debug('Target "%s" already exists', name)
return
self.devices[name] = self._uinput_factory(
name=f"{DEV_NAME} {name}",
phys=DEV_NAME,
events=DEFAULT_UINPUTS[name],
)
def write(self, event: Tuple[int, int, int], target_uinput):
"""Write event to target uinput."""
uinput = self.get_uinput(target_uinput)
if not uinput:
raise inputremapper.exceptions.UinputNotAvailable(target_uinput)
if not uinput.can_emit(event):
raise inputremapper.exceptions.EventNotHandled(event)
# Was bool once due to a bug during development
assert not isinstance(event[2], bool) and isinstance(event[2], int)
logger.write(event, uinput)
uinput.write(*event)
uinput.syn()
def get_uinput(self, name: str) -> Optional[evdev.UInput]:
"""UInput with name
Or None if there is no uinput with this name.
Parameters
----------
name
uniqe name of the uinput device
"""
if name not in self.devices:
logger.error(
f'UInput "{name}" is unknown. '
+ f"Available: {list(self.devices.keys())}"
)
return None
return self.devices.get(name)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.

View File

@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from typing import Tuple, Optional, Dict, List
import evdev
from evdev.ecodes import EV_ABS
from inputremapper import exceptions
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
from inputremapper.injection.mapping_handlers.mapping_handler import (
HandlerEnums,
MappingHandler,
)
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.logging.logger import logger
from inputremapper.utils import get_evdev_constant_name
class AbsToAbsHandler(MappingHandler):
"""Handler which transforms EV_ABS to EV_ABS events."""
_map_axis: InputConfig # the InputConfig for the axis we map
_output_axis: Tuple[int, int] # the (type, code) of the output axis
_transform: Optional[Transformation]
_target_absinfo: evdev.AbsInfo
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
global_uinputs: GlobalUInputs,
**_,
) -> None:
super().__init__(combination, mapping, global_uinputs)
# find the input event we are supposed to map. If the input combination is
# BTN_A + ABS_X + BTN_B, then use the value of ABS_X for the transformation
assert (map_axis := combination.find_analog_input_config(type_=EV_ABS))
self._map_axis = map_axis
assert mapping.output_code is not None
assert mapping.output_type == EV_ABS
self._output_axis = (mapping.output_type, mapping.output_code)
target_uinput = global_uinputs.get_uinput(mapping.target_uinput)
assert target_uinput is not None
abs_capabilities = target_uinput.capabilities(absinfo=True)[EV_ABS]
self._target_absinfo = dict(abs_capabilities)[mapping.output_code]
self._transform = None
def __str__(self):
name = get_evdev_constant_name(*self._map_axis.type_and_code)
return (
f'AbsToAbsHandler for "{name}" {self._map_axis} '
f"maps {self._map_axis} to: {self.mapping.get_output_name_constant()} "
f"{self.mapping.get_output_type_code()} at "
f"{self.mapping.target_uinput}"
)
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
def get_children(self) -> List[MappingHandler]:
return []
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
suppress: bool = False,
) -> bool:
if event.input_match_hash != self._map_axis.input_match_hash:
return False
if EventActions.recenter in event.actions:
self._write(self._scale_to_target(0))
return True
if not self._transform:
absinfo = dict(source.capabilities(absinfo=True)[EV_ABS])[event.code] # type: ignore
self._transform = Transformation(
max_=absinfo.max,
min_=absinfo.min,
deadzone=self.mapping.deadzone,
gain=self.mapping.gain,
expo=self.mapping.expo,
)
try:
self._write(self._scale_to_target(self._transform(event.value)))
return True
except (exceptions.UinputNotAvailable, exceptions.EventNotHandled):
return False
def reset(self) -> None:
self._write(self._scale_to_target(0))
def _scale_to_target(self, x: float) -> int:
"""Scales an x value between -1 and 1 to an integer between
target_absinfo.min and target_absinfo.max
input values above 1 or below -1 are clamped to the extreme values
"""
factor = (self._target_absinfo.max - self._target_absinfo.min) / 2
offset = self._target_absinfo.min + factor
y = factor * x + offset
if y > offset:
return int(min(self._target_absinfo.max, y))
else:
return int(max(self._target_absinfo.min, y))
def _write(self, value: int):
"""Inject."""
try:
self.global_uinputs.write(
(*self._output_axis, value), self.mapping.target_uinput
)
except OverflowError:
# screwed up the calculation of the event value
logger.error("OverflowError (%s, %s, %s)", *self._output_axis, value)
def needs_wrapping(self) -> bool:
return len(self.input_configs) > 1
def set_sub_handler(self, handler: MappingHandler) -> None:
assert False # cannot have a sub-handler
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
if self.needs_wrapping():
return {InputCombination(self.input_configs): HandlerEnums.axisswitch}
return {}

View File

@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from typing import List
import evdev
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.mapping_handlers.abs_util import calculate_trigger_point
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
)
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.utils import get_evdev_constant_name
class AbsToBtnHandler(MappingHandler):
"""Handler which transforms an EV_ABS to a button event."""
_input_config: InputConfig
_configured_direction_was_pressed: bool
_sub_handler: MappingHandler
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
global_uinputs: GlobalUInputs,
**_,
) -> None:
super().__init__(combination, mapping, global_uinputs)
self._configured_direction_was_pressed = False
self._input_config = combination[0]
assert self._input_config.analog_threshold
assert len(combination) == 1
def __str__(self):
name = get_evdev_constant_name(*self._input_config.type_and_code)
return f'AbsToBtnHandler for "{name}" ' f"{self._input_config.type_and_code}"
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
def get_children(self) -> List[MappingHandler]:
return [self._sub_handler]
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
suppress: bool = False,
) -> bool:
if event.input_match_hash != self._input_config.input_match_hash:
return False
analog_threshold = self._input_config.analog_threshold
assert analog_threshold is not None
threshold, mid_point = calculate_trigger_point(
event,
analog_threshold,
source,
)
value = event.value
direction = 1 if value > mid_point else -1
want_positive = analog_threshold > 0
want_negative = analog_threshold < 0
# For dpads, the threshold is 1, but so is the max value. So <= and >= it is.
# If this is dumb, change the threhsold to be a float.
pressed = value >= threshold if want_positive else value <= threshold
'''print(f"""abs_to_btn
{pressed=}
{value=}
{want_positive=}
{want_negative=}
{direction=}
{threshold=}
{mid_point=}
{analog_threshold=}
{self._configured_direction_was_pressed=}"""
)'''
# dpad-right to a:
# dpad moves right: a down
# dpad returns: a up
# dpad goes left: dpad -1
# dpad returns: dpad 0
# There are two "dpad returns" cases that have different outcomes
# joystick-right to a:
# joystick moves to +1234: ignore (If the architecture could do it, forward 0)
# joystick moves over threshold: a down
# joystick returns below threshold: a up
# joystick moves -1234: forward -1234
# joystick goes to 0: forward 0
# (In many cases it won't exactly return to 0, but to +1 or something, because
# they aren't 100% precise. But the positive direction is mapped, so turn
# this into 0. Unfortunately there is currently no way to do this in our
# architecture.)
if not self._configured_direction_was_pressed:
# these needs to be <= and >= mid point, to forward the dpad release for
# the unmapped direction
if want_positive and value <= mid_point:
return False
if want_negative and value >= mid_point:
return False
# if it was pressed, then we first need to deal with releasing the sub-handler.
self._configured_direction_was_pressed = pressed
event = event.modify(
pressed=pressed,
direction=direction,
actions=(EventActions.as_key,),
)
return self._sub_handler.notify(
event,
source=source,
suppress=suppress,
)
def reset(self) -> None:
self._configured_direction_was_pressed = False
self._sub_handler.reset()

View File

@ -0,0 +1,245 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import asyncio
import math
import time
from functools import partial
from typing import Dict, Tuple, Optional, List
import evdev
from evdev.ecodes import (
EV_REL,
EV_ABS,
REL_WHEEL,
REL_HWHEEL,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
)
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import (
Mapping,
REL_XY_SCALING,
WHEEL_SCALING,
WHEEL_HI_RES_SCALING,
DEFAULT_REL_RATE,
)
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
from inputremapper.injection.mapping_handlers.mapping_handler import (
HandlerEnums,
MappingHandler,
)
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.logging.logger import logger
from inputremapper.utils import get_evdev_constant_name
class AbsToRelHandler(MappingHandler):
"""Handler which transforms an EV_ABS to EV_REL events."""
_map_axis: InputConfig # the InputConfig for the axis we map
_value: float # the current output value
_running: bool # if the run method is active
_stop: bool # if the run loop should return
_transform: Optional[Transformation]
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
global_uinputs: GlobalUInputs,
**_,
) -> None:
super().__init__(combination, mapping, global_uinputs)
# find the input event we are supposed to map
assert (map_axis := combination.find_analog_input_config(type_=EV_ABS))
self._map_axis = map_axis
self._value = 0
self._running = False
self._stop = True
self._transform = None
# bind the correct run method
if self.mapping.output_code in (
REL_WHEEL,
REL_HWHEEL,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
):
if self.mapping.output_code in (REL_WHEEL, REL_WHEEL_HI_RES):
codes = (REL_WHEEL, REL_WHEEL_HI_RES)
else:
codes = (REL_HWHEEL, REL_HWHEEL_HI_RES)
self._run = partial(self._run_wheel_output, codes=codes)
else:
self._run = partial(self._run_normal_output)
def __str__(self):
name = get_evdev_constant_name(*self._map_axis.type_and_code)
return (
f'AbsToRelHandler for "{name}" {self._map_axis}: '
f"maps to {self.mapping.get_output_name_constant()} "
f"{self.mapping.get_output_type_code()} at "
f"{self.mapping.target_uinput}"
)
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
def get_children(self) -> List[MappingHandler]:
return []
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
suppress: bool = False,
) -> bool:
if event.input_match_hash != self._map_axis.input_match_hash:
return False
if EventActions.recenter in event.actions:
self._stop = True
return True
if not self._transform:
absinfo = {
entry[0]: entry[1] # type: ignore
for entry in source.capabilities(absinfo=True)[EV_ABS]
}
self._transform = Transformation(
max_=absinfo[event.code].max,
min_=absinfo[event.code].min,
deadzone=self.mapping.deadzone,
gain=self.mapping.gain,
expo=self.mapping.expo,
)
transformed = self._transform(event.value)
self._value = transformed
if transformed == 0:
self._stop = True
return True
if not self._running:
asyncio.ensure_future(self._run())
return True
def reset(self) -> None:
self._stop = True
def _write(self, type_, keycode, value):
"""Inject."""
# if the mouse won't move even though correct stuff is written here,
# the capabilities are probably wrong
if value == 0:
# rel 0 does not make sense. We don't need to tell linux that the mouse
# should not be moved this time.
return
try:
self.global_uinputs.write(
(type_, keycode, value),
self.mapping.target_uinput,
)
except OverflowError:
# screwed up the calculation of mouse movements
logger.error("OverflowError (%s, %s, %s)", type_, keycode, value)
def needs_wrapping(self) -> bool:
return len(self.input_configs) > 1
def set_sub_handler(self, handler: MappingHandler) -> None:
assert False # cannot have a sub-handler
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
if self.needs_wrapping():
return {InputCombination(self.input_configs): HandlerEnums.axisswitch}
return {}
def _calculate_output(self, value, weight, remainder):
# self._value is between 0 and 1, scale up with weight
scaled = value * weight + remainder
# float_value % 1 will result in wrong calculations for negative values
remainder = math.fmod(scaled, 1)
return int(scaled), remainder
async def _run_normal_output(self) -> None:
"""Start injecting events."""
self._running = True
self._stop = False
remainder = 0.0
start = time.time()
# if the rate is configured to be slower than the default, increase the value, so
# that the overall speed stays the same.
rate_compensation = DEFAULT_REL_RATE / self.mapping.rel_rate
weight = REL_XY_SCALING * rate_compensation
while not self._stop:
value, remainder = self._calculate_output(
self._value,
weight,
remainder,
)
self._write(EV_REL, self.mapping.output_code, value)
time_taken = time.time() - start
sleep = max(0.0, (1 / self.mapping.rel_rate) - time_taken)
await asyncio.sleep(sleep)
start = time.time()
self._running = False
async def _run_wheel_output(self, codes: Tuple[int, int]) -> None:
"""Start injecting wheel events.
made to inject both REL_WHEEL and REL_WHEEL_HI_RES events, because otherwise
wheel output doesn't work for some people. See issue #354
"""
weights = (WHEEL_SCALING, WHEEL_HI_RES_SCALING)
self._running = True
self._stop = False
remainder = [0.0, 0.0]
start = time.time()
while not self._stop:
for i in range(len(codes)):
value, remainder[i] = self._calculate_output(
self._value,
weights[i],
remainder[i],
)
self._write(EV_REL, codes[i], value)
time_taken = time.time() - start
await asyncio.sleep(max(0.0, (1 / self.mapping.rel_rate) - time_taken))
start = time.time()
self._running = False

View File

@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from typing import Tuple
import evdev
from evdev.ecodes import EV_ABS, ABS_GAS, ABS_BRAKE, ABS_Z, ABS_RZ
from inputremapper.input_event import InputEvent
# TODO: potentially cache this function { cache_key_from_args: previous_result }
def calculate_trigger_point(
event: InputEvent,
analog_threshold: int,
source: evdev.InputDevice,
) -> Tuple[float, float]:
"""Calculate the threshold and resting-point of the axis.
If an EV_ABS events value suprasses the threshold, it should be considered pressed.
The threshold is the offset from the resting-point/middle in both directions.
The resting point might be the middle value for a joystick: 0, *128*, 256 or
-128, *0*, 128. Or it might be the minimum value of the shoulder triggers: *0* 256.
"""
absinfo = dict(source.capabilities(absinfo=True)[EV_ABS]) # type: ignore
abs_min = absinfo[event.code].min
abs_max = absinfo[event.code].max
assert analog_threshold
if abs_min == -1 and abs_max == 1:
# this is a hat switch
# return +-1
return (
analog_threshold // abs(analog_threshold),
0,
)
if event.code in [ABS_GAS, ABS_BRAKE, ABS_Z, ABS_RZ]:
threshold = abs_max * analog_threshold / 100
# For the L/R triggers, there is only one direction, and the resting
# position is the same as the min_abs.
middle = abs_min
return threshold, middle
half_range = (abs_max - abs_min) / 2
middle = half_range + abs_min
trigger_offset = half_range * analog_threshold / 100
# Examples for threshold of +50:
# -128 to 128. half_range is 128. middle is 0. trigger_offset is 64 (and above)
# 0 to 128. half_range is 64. middle is 64. trigger_offset is 96 (and above)
# threshold, middle
threshold = middle + trigger_offset
return threshold, middle

View File

@ -0,0 +1,190 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Tuple, Hashable, List
import evdev
from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.input_config import InputConfig
from inputremapper.configs.mapping import Mapping
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.mapping_handlers.mapping_handler import (
HandlerEnums,
MappingHandler,
ContextProtocol,
)
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.logging.logger import logger
from inputremapper.utils import get_device_hash
class AxisSwitchHandler(MappingHandler):
"""Enables or disables an axis.
This is used when a combination involving an analog input (rel or abs) is mapped
to another analog output (rel or abs). I think.
Generally, if multiple events are mapped to something in a combination, all of
them need to be triggered in order to map to the output.
If an analog input is combined with a key input, then the same thing should happen.
The key needs to be pressed and the joystick needs to be moved in order to generate
output.
"""
_map_axis: InputConfig # the InputConfig for the axis we switch on or off
_trigger_keys: Tuple[Hashable, ...] # all events that can switch the axis
_active: bool # whether the axis is on or off
_last_value: int # the value of the last axis event that arrived
_axis_source: evdev.InputDevice # the cached source of the axis input events
_forward_device: evdev.UInput # the cached forward uinput
_sub_handler: MappingHandler
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
context: ContextProtocol,
global_uinputs: GlobalUInputs,
**_,
):
super().__init__(combination, mapping, global_uinputs)
trigger_keys = tuple(
event.input_match_hash
for event in combination
if not event.defines_analog_input
)
assert len(trigger_keys) >= 1
assert (map_axis := combination.find_analog_input_config())
self._map_axis = map_axis
self._trigger_keys = trigger_keys
self._active = False
self._last_value = 0
self._axis_source = None
self._forward_device = None
self.context = context
def __str__(self):
return f"AxisSwitchHandler for {self._map_axis.type_and_code}"
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
def get_children(self) -> List[MappingHandler]:
return [self._sub_handler]
def _handle_key_input(self, event: InputEvent):
"""If a key is pressed, allow mapping analog events in subhandlers.
Analog events (e.g. ABS_X, REL_Y) that have gone through Handlers that
transform them to buttons also count as keys.
"""
key_is_pressed = event.is_pressed()
if self._active == key_is_pressed:
# nothing changed
return False
self._active = key_is_pressed
if self._axis_source is None:
return True
if not key_is_pressed:
# recenter the axis
logger.debug("Stopping axis for %s", self.mapping.input_combination)
event = InputEvent(
0,
0,
*self._map_axis.type_and_code,
0,
actions=(EventActions.recenter,),
origin_hash=self._map_axis.origin_hash,
)
self._sub_handler.notify(event, self._axis_source)
return True
if self._map_axis.type == evdev.ecodes.EV_ABS:
# send the last cached value so that the abs axis
# is at the correct position
logger.debug("Starting axis for %s", self.mapping.input_combination)
event = InputEvent(
0,
0,
*self._map_axis.type_and_code,
self._last_value,
origin_hash=self._map_axis.origin_hash,
)
self._sub_handler.notify(event, self._axis_source)
return True
return True
def _should_map(self, event: InputEvent):
return (
event.input_match_hash in self._trigger_keys
or event.input_match_hash == self._map_axis.input_match_hash
)
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
suppress: bool = False,
) -> bool:
if not self._should_map(event):
return False
if event.is_key_event:
# A key or an analog even that is being treated as a key (on/off) due to
# previous handlers.
return self._handle_key_input(event)
# do some caching so that we can generate the
# recenter event and an initial abs event
if self._axis_source is None:
self._axis_source = source
if self._forward_device is None:
device_hash = get_device_hash(source)
self._forward_device = self.context.get_forward_uinput(device_hash)
# always cache the value
self._last_value = event.value
if self._active:
return self._sub_handler.notify(event, source, suppress)
return False
def reset(self) -> None:
self._last_value = 0
self._active = False
self._sub_handler.reset()
def needs_wrapping(self) -> bool:
return True
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
combination = [
config for config in self.input_configs if not config.defines_analog_input
]
return {InputCombination(combination): HandlerEnums.combination}

View File

@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import math
from typing import Dict, Union
class Transformation:
"""Callable that returns the axis transformation at x."""
def __init__(
self,
# if input values are > max_, the return value will be > 1
max_: Union[int, float],
min_: Union[int, float],
deadzone: float,
gain: float = 1,
expo: float = 0,
) -> None:
self._max = max_
self._min = min_
self._deadzone = deadzone
self._gain = gain
self._expo = expo
self._cache: Dict[float, float] = {}
def __call__(self, /, x: Union[int, float]) -> float:
if x not in self._cache:
y = (
self._calc_qubic(self._flatten_deadzone(self._normalize(x)))
* self._gain
)
self._cache[x] = y
return self._cache[x]
def set_range(self, min_, max_):
# TODO docstring
if min_ != self._min or max_ != self._max:
self._cache = {}
self._min = min_
self._max = max_
def _normalize(self, x: Union[int, float]) -> float:
"""Move and scale x to be between -1 and 1
return: x
"""
if self._min == -1 and self._max == 1:
return x
half_range = (self._max - self._min) / 2
middle = half_range + self._min
return (x - middle) / half_range
def _flatten_deadzone(self, x: float) -> float:
"""
y ^ y ^
| |
1 | / 1 | /
| / | /
| / ==> | ---
| / | /
-1 | / -1 | /
|------------> |------------>
-1 1 x -1 1 x
"""
if abs(x) <= self._deadzone:
return 0
return (x - self._deadzone * x / abs(x)) / (1 - self._deadzone)
def _calc_qubic(self, x: float) -> float:
"""Transforms an x value by applying a qubic function
k = 0 : will yield no transformation f(x) = x
1 > k > 0 : will yield low sensitivity for low x values
and high sensitivity for high x values
-1 < k < 0 : will yield high sensitivity for low x values
and low sensitivity for high x values
see also: https://www.geogebra.org/calculator/mkdqueky
Mathematical definition:
f(x,d) = d * x + (1 - d) * x ** 3 | d = 1 - k | k ∈ [0,1]
the function is designed such that if follows these constraints:
f'(0, d) = d and f(1, d) = 1 and f(-x,d) = -f(x,d)
for k ∈ [-1,0) the above function is mirrored at y = x
and d = 1 + k
"""
k = self._expo
if k == 0 or x == 0:
return x
if 0 < k <= 1:
d = 1 - k
return d * x + (1 - d) * x**3
if -1 <= k < 0:
# calculate return value with the real inverse solution
# of y = b * x + a * x ** 3
# LaTeX for better readability:
#
# y=\frac{{{\left( \sqrt{27 {{x}^{2}}+\frac{4 {{b}^{3}}}{a}}
# +{{3}^{\frac{3}{2}}} x\right) }^{\frac{1}{3}}}}
# {{{2}^{\frac{1}{3}}} \sqrt{3} {{a}^{\frac{1}{3}}}}
# -\frac{{{2}^{\frac{1}{3}}} b}
# {\sqrt{3} {{a}^{\frac{2}{3}}}
# {{\left( \sqrt{27 {{x}^{2}}+\frac{4 {{b}^{3}}}{a}}
# +{{3}^{\frac{3}{2}}} x\right) }^{\frac{1}{3}}}}
sign = x / abs(x)
x = math.fabs(x)
d = 1 + k
a = 1 - d
b = d
c = (math.sqrt(27 * x**2 + (4 * b**3) / a) + 3 ** (3 / 2) * x) ** (1 / 3)
y = c / (2 ** (1 / 3) * math.sqrt(3) * a ** (1 / 3)) - (
2 ** (1 / 3) * b
) / (math.sqrt(3) * a ** (2 / 3) * c)
return y * sign
raise ValueError("k must be between -1 and 1")

View File

@ -0,0 +1,275 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations # needed for the TYPE_CHECKING import
from typing import TYPE_CHECKING, Dict, Hashable, Tuple, List
import evdev
from evdev.ecodes import EV_ABS, EV_REL
from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.mapping import Mapping
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
HandlerEnums,
)
from inputremapper.input_event import InputEvent
from inputremapper.logging.logger import logger
if TYPE_CHECKING:
from inputremapper.injection.context import Context
class CombinationHandler(MappingHandler):
"""Keeps track of a combination and notifies a sub handler."""
# map of InputEvent.input_match_hash -> bool , keep track of the combination state
_pressed_keys: Dict[Hashable, bool]
# the last update we sent to a sub-handler. If this is true, the output key is
# still being held down.
_output_previously_active: bool
_sub_handler: MappingHandler
_handled_input_hashes: list[Hashable]
_requires_a_release: Dict[Tuple[int, int], bool]
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
context: Context,
global_uinputs: GlobalUInputs,
**_,
) -> None:
logger.debug(str(mapping))
super().__init__(combination, mapping, global_uinputs)
self._pressed_keys = {}
self._output_previously_active = False
self._context = context
self._requires_a_release = {}
# prepare a key map for all events with non-zero value
for input_config in combination:
assert not input_config.defines_analog_input
self._pressed_keys[input_config.input_match_hash] = False
self._handled_input_hashes = [
input_config.input_match_hash for input_config in combination
]
assert len(self._pressed_keys) > 0 # no combination handler without a key
def __str__(self):
return (
f'CombinationHandler for "{str(self.mapping.input_combination)}" '
f"{tuple(t for t in self._pressed_keys.keys())}"
)
def __repr__(self):
description = (
f'CombinationHandler for "{repr(self.mapping.input_combination)}" '
f"{tuple(t for t in self._pressed_keys.keys())}"
)
return f"<{description} at {hex(id(self))}>"
def get_children(self) -> List[MappingHandler]:
return [self._sub_handler]
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
suppress: bool = False,
) -> bool:
if event.input_match_hash not in self._handled_input_hashes:
# we are not responsible for the event
return False
# update the state
# The value of non-key input should have been changed to either 0 or 1 at this
# point by other handlers.
is_pressed = event.is_pressed()
self._pressed_keys[event.input_match_hash] = is_pressed
# maybe this changes the activation status (triggered/not-triggered)
changed = self._is_activated() != self._output_previously_active
if changed:
if is_pressed:
return self._handle_freshly_activated(suppress, event, source)
else:
return self._handle_freshly_deactivated(event, source)
else:
if is_pressed:
return self._handle_no_change_press(event)
else:
return self._handle_no_change_release(event)
def _handle_no_change_press(self, event: InputEvent) -> bool:
"""A key was pressed, but this doesn't change the combinations activation state.
Can only happen if either the combination wasn't already active, or a duplicate
key-down event arrived (EV_ABS?)
"""
# self._output_previously_active is negated, because if the output is active, a
# key-down event triggered it, which then did not get forwarded, therefore
# it doesn't require a release.
self._require_release_later(not self._output_previously_active, event)
# output is active: consume the event
# output inactive: forward the event
return self._output_previously_active
def _handle_no_change_release(self, event: InputEvent) -> bool:
"""One of the combinations keys was released, but it didn't untrigger the
combination yet."""
# Negate: `False` means that the event-reader will forward the release.
return not self._should_release_event(event)
def _handle_freshly_activated(
self,
suppress: bool,
event: InputEvent,
source: evdev.InputDevice,
) -> bool:
"""The combination was deactivated, but is activated now."""
if suppress:
return False
# Send key up events to the forwarded uinput if configured to do so.
self._forward_release()
logger.debug(
"Sending %s to sub-handler %s",
repr(event),
repr(self._sub_handler),
)
self._output_previously_active = event.is_pressed()
sub_handler_result = self._sub_handler.notify(event, source, suppress)
# Only if the sub-handler return False, we need a release-event later.
# If it handled the event, the user never sees this key-down event.
self._require_release_later(not sub_handler_result, event)
return sub_handler_result
def _handle_freshly_deactivated(
self,
event: InputEvent,
source: evdev.InputDevice,
) -> bool:
"""The combination was activated, but is deactivated now."""
# We ignore the `suppress` argument for release events. Otherwise, we
# might end up with stuck keys (test_event_pipeline.test_combination).
# In the case of output axis, this will enable us to activate multiple
# axis with the same button.
logger.debug(
"Sending %s to sub-handler %s",
repr(event),
repr(self._sub_handler),
)
self._output_previously_active = event.is_pressed()
self._sub_handler.notify(event, source, suppress=False)
# Negate: `False` means that the event-reader will forward the release.
return not self._should_release_event(event)
def _should_release_event(self, event: InputEvent) -> bool:
"""Check if the key-up event should be forwarded by the event-reader.
After this, the release event needs to be injected by someone, otherwise the
dictionary was modified erroneously. If there is no entry, we assume that there
was no key-down event to release. Maybe a duplicate event arrived.
"""
# Ensure that all injected key-down events will get their release event
# injected eventually.
# If a key-up event arrives that will inactivate the combination, but
# for which previously a key-down event was injected (because it was
# an earlier key in the combination chain), then we need to ensure that its
# release is injected as well. So we get two release events in that case:
# one for the key, and one for the output.
assert event.is_pressed() == 0, f"expected {event.is_pressed()} to be 0"
return self._requires_a_release.pop(event.type_and_code, False)
def _require_release_later(self, require: bool, event: InputEvent) -> None:
"""Remember if this key-down event will need a release event later on."""
assert event.is_pressed() == 1
self._requires_a_release[event.type_and_code] = require
def reset(self) -> None:
self._sub_handler.reset()
for key in self._pressed_keys:
self._pressed_keys[key] = False
self._requires_a_release = {}
self._output_previously_active = False
def _is_activated(self) -> bool:
"""Return if all keys in the keymap are set to True."""
return False not in self._pressed_keys.values()
def _forward_release(self) -> None:
"""Forward a button release for all keys if this is a combination.
This might cause duplicate key-up events but those are ignored by evdev anyway
"""
if len(self._pressed_keys) == 1 or not self.mapping.release_combination_keys:
return
keys_to_release = filter(
lambda cfg: self._pressed_keys.get(cfg.input_match_hash),
self.mapping.input_combination,
)
logger.debug("Forwarding release for %s", self.mapping.input_combination)
for input_config in keys_to_release:
if not self._requires_a_release.get(input_config.type_and_code):
continue
origin_hash = input_config.origin_hash
if origin_hash is None:
logger.error(
f"Can't forward due to missing origin_hash in {repr(input_config)}"
)
continue
forward_to = self._context.get_forward_uinput(origin_hash)
logger.write(input_config, forward_to)
forward_to.write(*input_config.type_and_code, 0)
forward_to.syn()
# We are done with this key, forget about it
del self._requires_a_release[input_config.type_and_code]
def needs_ranking(self) -> bool:
return bool(self.input_configs)
def rank_by(self) -> InputCombination:
return InputCombination(
[event for event in self.input_configs if not event.defines_analog_input]
)
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
return_dict = {}
for config in self.input_configs:
if config.type == EV_ABS and not config.defines_analog_input:
return_dict[InputCombination([config])] = HandlerEnums.abs2btn
if config.type == EV_REL and not config.defines_analog_input:
return_dict[InputCombination([config])] = HandlerEnums.rel2btn
return return_dict

View File

@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from typing import List, Dict
import evdev
from evdev.ecodes import EV_ABS, EV_REL
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
HandlerEnums,
)
from inputremapper.input_event import InputEvent
class HierarchyHandler(MappingHandler):
"""Handler consisting of an ordered list of MappingHandler
only the first handler which successfully handles the event will execute it,
all other handlers will be notified, but suppressed
"""
_input_config: InputConfig
def __init__(
self,
handlers: List[MappingHandler],
input_config: InputConfig,
global_uinputs: GlobalUInputs,
) -> None:
self.handlers = handlers
self._input_config = input_config
combination = InputCombination([input_config])
# use the mapping from the first child TODO: find a better solution
mapping = handlers[0].mapping
super().__init__(combination, mapping, global_uinputs)
def __str__(self):
return f"HierarchyHandler for {self._input_config}"
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
def get_children(self) -> List[MappingHandler]:
return self.handlers
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
suppress: bool = False,
) -> bool:
if event.input_match_hash != self._input_config.input_match_hash:
return False
handled = False
for handler in self.handlers:
if handled:
# To allow an arbitrary number of output axes to be activated at the
# same time, we don't suppress them.
handler.notify(
event,
source,
suppress=not handler.mapping.input_combination.defines_analog_input,
)
continue
handled = handler.notify(event, source)
return handled
def reset(self) -> None:
for sub_handler in self.handlers:
sub_handler.reset()
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
if (
self._input_config.type == EV_ABS
and not self._input_config.defines_analog_input
):
return {InputCombination([self._input_config]): HandlerEnums.abs2btn}
if (
self._input_config.type == EV_REL
and not self._input_config.defines_analog_input
):
return {InputCombination([self._input_config]): HandlerEnums.rel2btn}
return {}
def set_sub_handler(self, handler: MappingHandler) -> None:
assert False

View File

@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from typing import Tuple, Dict, List
from inputremapper import exceptions
from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.mapping import Mapping
from inputremapper.exceptions import MappingParsingError
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.mapping_handlers.mapping_handler import (
HandlerEnums,
MappingHandler,
)
from inputremapper.input_event import InputEvent
from inputremapper.logging.logger import logger
from inputremapper.utils import get_evdev_constant_name
class KeyHandler(MappingHandler):
"""Injects the target key if notified."""
_active: bool
_maps_to: Tuple[int, int]
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
global_uinputs: GlobalUInputs,
**_,
):
super().__init__(combination, mapping, global_uinputs)
maps_to = mapping.get_output_type_code()
if not maps_to:
raise MappingParsingError(
"Unable to create key handler from mapping", mapping=mapping
)
self._maps_to = maps_to
self._active = False
def __str__(self):
name = get_evdev_constant_name(*self._maps_to)
return f"KeyHandler to {name} {self._maps_to} on {self.mapping.target_uinput}"
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
def get_children(self) -> List[MappingHandler]:
return []
def notify(self, event: InputEvent, *_, **__) -> bool:
"""Inject the correct value to the target uinput."""
event_tuple = (*self._maps_to, 1 if event.is_pressed() else 0)
try:
self.global_uinputs.write(event_tuple, self.mapping.target_uinput)
self._active = bool(event.is_pressed())
return True
except exceptions.Error:
return False
def reset(self) -> None:
logger.debug("resetting key_handler")
if self._active:
event_tuple = (*self._maps_to, 0)
self.global_uinputs.write(event_tuple, self.mapping.target_uinput)
self._active = False
def needs_wrapping(self) -> bool:
return True
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
return {InputCombination(self.input_configs): HandlerEnums.combination}

View File

@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import asyncio
import traceback
from typing import Dict, Callable, Tuple, List
from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.mapping import Mapping
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.parse import Parser
from inputremapper.injection.mapping_handlers.mapping_handler import (
ContextProtocol,
HandlerEnums,
MappingHandler,
)
from inputremapper.input_event import InputEvent
from inputremapper.logging.logger import logger
class MacroHandler(MappingHandler):
"""Runs the target macro if notified."""
# TODO: replace this by the macro itself
_macro: Macro
_active: bool
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
global_uinputs: GlobalUInputs,
*,
context: ContextProtocol,
):
super().__init__(combination, mapping, global_uinputs)
self._pressed_keys: Dict[Tuple[int, int], int] = {}
self._active = False
assert self.mapping.output_symbol is not None
self._macro = Parser.parse(self.mapping.output_symbol, context, mapping)
def __str__(self):
return f"MacroHandler maps to {self._macro} on {self.mapping.target_uinput}"
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
def get_children(self) -> List[MappingHandler]:
return []
async def run_macro(self, handler: Callable):
"""Run the macro with the provided function."""
try:
await self._macro.run(handler)
except Exception as exception:
logger.error('Macro "%s" failed with %s', self._macro.code, type(exception))
traceback.print_exc()
def notify(self, event: InputEvent, *_, **__) -> bool:
if event.is_pressed():
self._active = True
self._macro.press_trigger()
if self._macro.running:
return True
def handler(type_, code, value) -> None:
"""Handler for macros."""
self._remember_pressed_keys((type_, code, value))
self.global_uinputs.write(
(type_, code, value),
self.mapping.target_uinput,
)
asyncio.ensure_future(self.run_macro(handler))
return True
else:
self._active = False
self._macro.release_trigger()
return True
def reset(self) -> None:
self._active = False
# To avoid a key hanging forever. Can be pretty annoying, especially if it is
# a modifier that makes you unable to interact with your system.
for (type, code), value in self._pressed_keys.items():
if value == 1:
logger.debug("Releasing key %s", (type, code, value))
self.global_uinputs.write(
(type, code, 0),
self.mapping.target_uinput,
)
def needs_wrapping(self) -> bool:
return True
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
return {InputCombination(self.input_configs): HandlerEnums.combination}
def _remember_pressed_keys(self, event: Tuple[int, int, int]) -> None:
type, code, value = event
self._pressed_keys[(type, code)] = value

View File

@ -0,0 +1,213 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Provides protocols for mapping handlers
*** The architecture behind mapping handlers ***
Handling an InputEvent is done in 3 steps:
1. Input Event Handling
A MappingHandler that does Input event handling receives Input Events directly
from the EventReader.
To do so it must implement the MappingHandler protocol.
An MappingHandler may handle multiple events (InputEvent.type_and_code)
2. Event Transformation
The event gets transformed as described by the mapping.
e.g.: combining multiple events to a single one
transforming EV_ABS to EV_REL
macros
...
Multiple transformations may get chained
3. Event Injection
The transformed event gets injected to a global_uinput
MappingHandlers can implement one or more of these steps.
Overview of implemented handlers and the steps they implement:
Step 1:
- HierarchyHandler
Step 1 and 2:
- CombinationHandler
- AbsToBtnHandler
- RelToBtnHandler
Step 1, 2 and 3:
- AbsToRelHandler
- NullHandler
Step 2 and 3:
- KeyHandler
- MacroHandler
"""
from __future__ import annotations
import enum
from typing import Dict, Protocol, Set, Optional, List
import evdev
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping
from inputremapper.exceptions import MappingParsingError
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.input_event import InputEvent
from inputremapper.logging.logger import logger
class EventListener(Protocol):
async def __call__(self, event: evdev.InputEvent) -> None: ...
class ContextProtocol(Protocol):
"""The parts from context needed for handlers."""
listeners: Set[EventListener]
def get_forward_uinput(self, origin_hash) -> evdev.UInput:
pass
class NotifyCallback(Protocol):
"""Type signature of MappingHandler.notify
return True if the event was actually taken care of
"""
def __call__(
self,
event: InputEvent,
source: evdev.InputDevice,
suppress: bool = False,
) -> bool: ...
class HandlerEnums(enum.Enum):
# converting to btn
abs2btn = enum.auto()
rel2btn = enum.auto()
macro = enum.auto()
key = enum.auto()
# converting to "analog"
btn2rel = enum.auto()
rel2rel = enum.auto()
abs2rel = enum.auto()
btn2abs = enum.auto()
rel2abs = enum.auto()
abs2abs = enum.auto()
# special handlers
combination = enum.auto()
hierarchy = enum.auto()
axisswitch = enum.auto()
disable = enum.auto()
class MappingHandler:
mapping: Mapping
# all input events this handler cares about
# should always be a subset of mapping.input_combination
input_configs: List[InputConfig]
_sub_handler: Optional[MappingHandler]
# https://bugs.python.org/issue44807
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
global_uinputs: GlobalUInputs,
**_,
) -> None:
"""Initialize the handler
Parameters
----------
combination
the combination from sub_handler.wrap_with()
mapping
"""
self.mapping = mapping
self.input_configs = list(combination)
self._sub_handler = None
self.global_uinputs = global_uinputs
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
suppress: bool = False,
) -> bool:
"""Notify this handler about an incoming event.
Parameters
----------
event
The newest event that came from `source`, and that should be mapped to
something else
source
Where `event` comes from
"""
raise NotImplementedError
def reset(self) -> None:
"""Reset the state of the handler e.g. release any buttons."""
raise NotImplementedError
def needs_wrapping(self) -> bool:
"""If this handler needs to be wrapped in another MappingHandler."""
return len(self.wrap_with()) > 0
def needs_ranking(self) -> bool:
"""If this handler needs ranking and wrapping with a HierarchyHandler."""
return False
def rank_by(self) -> Optional[InputCombination]:
"""The combination for which this handler needs ranking."""
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
"""A dict of InputCombination -> HandlerEnums.
for each InputCombination this handler should be wrapped
with the given MappingHandler.
"""
return {}
def set_sub_handler(self, handler: MappingHandler) -> None:
"""Give this handler a sub_handler."""
self._sub_handler = handler
def occlude_input_event(self, input_config: InputConfig) -> None:
"""Remove the config from self.input_configs."""
if not self.input_configs:
logger.debug_mapping_handler(self)
raise MappingParsingError(
"Cannot remove a non existing config", mapping_handler=self
)
# should be called for each event a wrapping-handler
# has in its input_configs InputCombination
self.input_configs.remove(input_config)
def get_children(self) -> List[MappingHandler]:
return []

View File

@ -0,0 +1,360 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Functions to assemble the mapping handler tree."""
from collections import defaultdict
from typing import Dict, List, Type, Optional, Set, Iterable, Sized, Tuple, Sequence
from evdev.ecodes import EV_KEY, EV_ABS, EV_REL
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.keyboard_layout import DISABLE_CODE, DISABLE_NAME
from inputremapper.configs.mapping import Mapping
from inputremapper.configs.preset import Preset
from inputremapper.exceptions import MappingParsingError
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.macros.parse import Parser
from inputremapper.injection.mapping_handlers.abs_to_abs_handler import AbsToAbsHandler
from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler
from inputremapper.injection.mapping_handlers.axis_switch_handler import (
AxisSwitchHandler,
)
from inputremapper.injection.mapping_handlers.combination_handler import (
CombinationHandler,
)
from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler
from inputremapper.injection.mapping_handlers.key_handler import KeyHandler
from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler
from inputremapper.injection.mapping_handlers.mapping_handler import (
HandlerEnums,
ContextProtocol,
MappingHandler,
)
from inputremapper.injection.mapping_handlers.null_handler import NullHandler
from inputremapper.injection.mapping_handlers.rel_to_abs_handler import RelToAbsHandler
from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler
from inputremapper.injection.mapping_handlers.rel_to_rel_handler import RelToRelHandler
from inputremapper.logging.logger import logger
from inputremapper.utils import get_evdev_constant_name
EventPipelines = Dict[InputConfig, Set[MappingHandler]]
mapping_handler_classes: Dict[HandlerEnums, Optional[Type[MappingHandler]]] = {
# all available mapping_handlers
HandlerEnums.abs2btn: AbsToBtnHandler,
HandlerEnums.rel2btn: RelToBtnHandler,
HandlerEnums.macro: MacroHandler,
HandlerEnums.key: KeyHandler,
HandlerEnums.btn2rel: None, # can be a macro
HandlerEnums.rel2rel: RelToRelHandler,
HandlerEnums.abs2rel: AbsToRelHandler,
HandlerEnums.btn2abs: None, # can be a macro
HandlerEnums.rel2abs: RelToAbsHandler,
HandlerEnums.abs2abs: AbsToAbsHandler,
HandlerEnums.combination: CombinationHandler,
HandlerEnums.hierarchy: HierarchyHandler,
HandlerEnums.axisswitch: AxisSwitchHandler,
HandlerEnums.disable: NullHandler,
}
class MappingParser:
def __init__(
self,
global_uinputs: GlobalUInputs,
) -> None:
self.global_uinputs = global_uinputs
def parse_mappings(
self,
preset: Preset,
context: ContextProtocol,
) -> EventPipelines:
"""Create a dict with a list of MappingHandler for each InputEvent."""
handlers = []
for mapping in preset:
# start with the last handler in the chain, each mapping only has one output,
# but may have multiple inputs, therefore the last handler is a good starting
# point to assemble the pipeline
handler_enum = self._get_output_handler(mapping)
constructor = mapping_handler_classes[handler_enum]
if not constructor:
logger.warning(
"a mapping handler '%s' for %s is not implemented",
handler_enum,
mapping.format_name(),
)
continue
output_handler = constructor(
mapping.input_combination,
mapping,
context=context,
global_uinputs=self.global_uinputs,
)
# layer other handlers on top until the outer handler needs ranking or can
# directly handle a input event
handlers.extend(self._create_event_pipeline(output_handler, context))
# figure out which handlers need ranking and wrap them with hierarchy_handlers
need_ranking = defaultdict(set)
for handler in handlers.copy():
if handler.needs_ranking():
combination = handler.rank_by()
if not combination:
raise MappingParsingError(
f"{type(handler).__name__} claims to need ranking but does not "
f"return a combination to rank by",
mapping_handler=handler,
)
need_ranking[combination].add(handler)
handlers.remove(handler)
# the HierarchyHandler's might not be the starting point of the event pipeline,
# layer other handlers on top again.
ranked_handlers = self._create_hierarchy_handlers(need_ranking)
for handler in ranked_handlers:
handlers.extend(
self._create_event_pipeline(handler, context, ignore_ranking=True)
)
# group all handlers by the input events they take care of. One handler might end
# up in multiple groups if it takes care of multiple InputEvents
event_pipelines: EventPipelines = defaultdict(set)
for handler in handlers:
assert handler.input_configs
for input_config in handler.input_configs:
logger.debug(
"event-pipeline with entry point: %s %s",
get_evdev_constant_name(*input_config.type_and_code),
input_config.input_match_hash,
)
logger.debug_mapping_handler(handler)
event_pipelines[input_config].add(handler)
return event_pipelines
def _create_event_pipeline(
self,
handler: MappingHandler,
context: ContextProtocol,
ignore_ranking=False,
) -> List[MappingHandler]:
"""Recursively wrap a handler with other handlers until the
outer handler needs ranking or is finished wrapping.
"""
if not handler.needs_wrapping() or (
handler.needs_ranking() and not ignore_ranking
):
return [handler]
handlers = []
for combination, handler_enum in handler.wrap_with().items():
constructor = mapping_handler_classes[handler_enum]
if not constructor:
raise NotImplementedError(
f"mapping handler {handler_enum} is not implemented"
)
super_handler = constructor(
combination,
handler.mapping,
context=context,
global_uinputs=self.global_uinputs,
)
super_handler.set_sub_handler(handler)
for event in combination:
# the handler now has a super_handler which takes care about the events.
# so we need to hide them on the handler
handler.occlude_input_event(event)
handlers.extend(self._create_event_pipeline(super_handler, context))
if handler.input_configs:
# the handler was only partially wrapped,
# we need to return it as a toplevel handler
handlers.append(handler)
return handlers
def _get_output_handler(self, mapping: Mapping) -> HandlerEnums:
"""Determine the correct output handler.
this is used as a starting point for the mapping parser
"""
if mapping.output_code == DISABLE_CODE or mapping.output_symbol == DISABLE_NAME:
return HandlerEnums.disable
if mapping.output_symbol:
if Parser.is_this_a_macro(mapping.output_symbol):
return HandlerEnums.macro
return HandlerEnums.key
if mapping.output_type == EV_KEY:
return HandlerEnums.key
input_event = self._maps_axis(mapping.input_combination)
if not input_event:
raise MappingParsingError(
f"This {mapping = } does not map to an axis, key or macro",
mapping=Mapping,
)
if mapping.output_type == EV_REL:
if input_event.type == EV_KEY:
return HandlerEnums.btn2rel
if input_event.type == EV_REL:
return HandlerEnums.rel2rel
if input_event.type == EV_ABS:
return HandlerEnums.abs2rel
if mapping.output_type == EV_ABS:
if input_event.type == EV_KEY:
return HandlerEnums.btn2abs
if input_event.type == EV_REL:
return HandlerEnums.rel2abs
if input_event.type == EV_ABS:
return HandlerEnums.abs2abs
raise MappingParsingError(
f"the output of {mapping = } is unknown", mapping=Mapping
)
def _maps_axis(self, combination: InputCombination) -> Optional[InputConfig]:
"""Whether this InputCombination contains an InputEvent that is treated as
an axis and not a binary (key or button) event.
"""
for event in combination:
if event.defines_analog_input:
return event
return None
def _create_hierarchy_handlers(
self,
handlers: Dict[InputCombination, Set[MappingHandler]],
) -> Set[MappingHandler]:
"""Sort handlers by input events and create Hierarchy handlers."""
sorted_handlers = set()
all_combinations = handlers.keys()
events = set()
# gather all InputEvents from all handlers
for combination in all_combinations:
for event in combination:
events.add(event)
# create a ranking for each event
for event in events:
# find all combinations (from handlers) which contain the event
combinations_with_event = [
combination for combination in all_combinations if event in combination
]
if len(combinations_with_event) == 1:
# there was only one handler containing that event return it as is
sorted_handlers.update(handlers[combinations_with_event[0]])
continue
# there are multiple handler with the same event.
# rank them and create the HierarchyHandler
sorted_combinations = self._order_combinations(
combinations_with_event,
event,
)
sub_handlers: List[MappingHandler] = []
for combination in sorted_combinations:
sub_handlers.extend(handlers[combination])
sorted_handlers.add(
HierarchyHandler(
sub_handlers,
event,
self.global_uinputs,
)
)
for handler in sub_handlers:
# the handler now has a HierarchyHandler which takes care about this event.
# so we hide need to hide it on the handler
handler.occlude_input_event(event)
return sorted_handlers
def _order_combinations(
self,
combinations: List[InputCombination],
common_config: InputConfig,
) -> List[InputCombination]:
"""Reorder the keys according to some rules.
such that a combination a+b+c is in front of a+b which is in front of b
for a+b+c vs. b+d+e: a+b+c would be in front of b+d+e, because the common key b
has the higher index in the a+b+c (1), than in the b+c+d (0) list
in this example b would be the common key
as for combinations like a+b+c and e+d+c with the common key c: ¯\\_(ツ)_/¯
Parameters
----------
combinations
the list which needs ordering
common_config
the InputConfig all InputCombination's in combinations have in common
"""
combinations.sort(key=len)
for start, end in self._ranges_with_constant_length(combinations.copy()):
sub_list = combinations[start:end]
sub_list.sort(key=lambda x: x.index(common_config))
combinations[start:end] = sub_list
combinations.reverse()
return combinations
def _ranges_with_constant_length(
self,
x: Sequence[Sized],
) -> Iterable[Tuple[int, int]]:
"""Get all ranges of x for which the elements have constant length
Parameters
----------
x: Sequence[Sized]
l must be ordered by increasing length of elements
"""
start_idx = 0
last_len = 0
for idx, y in enumerate(x):
if len(y) > last_len and idx - start_idx > 1:
yield start_idx, idx
if len(y) == last_len and idx + 1 == len(x):
yield start_idx, idx + 1
if len(y) > last_len:
start_idx = idx
if len(y) < last_len:
raise MappingParsingError(
"ranges_with_constant_length was called with an unordered list"
)
last_len = len(y)

View File

@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List
import evdev
from inputremapper.configs.input_config import InputCombination
from inputremapper.injection.mapping_handlers.mapping_handler import (
HandlerEnums,
MappingHandler,
)
from inputremapper.input_event import InputEvent
class NullHandler(MappingHandler):
"""Handler which consumes the event and does nothing."""
def __str__(self):
return f"NullHandler for {self.mapping.input_combination}<{id(self)}>"
def get_children(self) -> List[MappingHandler]:
return []
def needs_wrapping(self) -> bool:
return False in [
input_.defines_analog_input for input_ in self.mapping.input_combination
]
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
if not self.mapping.input_combination.defines_analog_input:
return {self.mapping.input_combination: HandlerEnums.combination}
assert len(self.mapping.input_combination) > 1, "nees_wrapping ensures this!"
return {self.mapping.input_combination: HandlerEnums.axisswitch}
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
suppress: bool = False,
) -> bool:
return True
def reset(self) -> None:
pass

View File

@ -0,0 +1,248 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import asyncio
from typing import Tuple, Dict, Optional, List
import evdev
from evdev.ecodes import (
EV_ABS,
EV_REL,
REL_WHEEL,
REL_HWHEEL,
REL_HWHEEL_HI_RES,
REL_WHEEL_HI_RES,
)
from inputremapper import exceptions
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import (
Mapping,
WHEEL_SCALING,
WHEEL_HI_RES_SCALING,
REL_XY_SCALING,
DEFAULT_REL_RATE,
)
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
from inputremapper.injection.mapping_handlers.mapping_handler import (
HandlerEnums,
MappingHandler,
)
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.logging.logger import logger
class RelToAbsHandler(MappingHandler):
"""Handler which transforms EV_REL to EV_ABS events.
High EV_REL input results in high EV_ABS output.
If no new EV_REL events are seen, the EV_ABS output is set to 0 after
release_timeout.
"""
_map_axis: InputConfig # InputConfig for the relative movement we map
_output_axis: Tuple[int, int] # the (type, code) of the output axis
_transform: Transformation
_target_absinfo: evdev.AbsInfo
# infinite loop which centers the output when input stops
_recenter_loop: Optional[asyncio.Task]
_moving: asyncio.Event # event to notify the _recenter_loop
_previous_event: Optional[InputEvent]
_observed_rate: float # input events per second
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
global_uinputs: GlobalUInputs,
**_,
) -> None:
super().__init__(combination, mapping, global_uinputs)
# find the input event we are supposed to map. If the input combination is
# BTN_A + REL_X + BTN_B, then use the value of REL_X for the transformation
assert (map_axis := combination.find_analog_input_config(type_=EV_REL))
self._map_axis = map_axis
assert mapping.output_code is not None
assert mapping.output_type == EV_ABS
self._output_axis = (mapping.output_type, mapping.output_code)
target_uinput = global_uinputs.get_uinput(mapping.target_uinput)
assert target_uinput is not None
abs_capabilities = target_uinput.capabilities(absinfo=True)[EV_ABS]
self._target_absinfo = dict(abs_capabilities)[mapping.output_code]
max_ = self._get_default_cutoff()
self._transform = Transformation(
min_=-max(1, int(max_)),
max_=max(1, int(max_)),
deadzone=mapping.deadzone,
gain=mapping.gain,
expo=mapping.expo,
)
self._moving = asyncio.Event()
self._recenter_loop = None
self._previous_event = None
self._observed_rate = DEFAULT_REL_RATE
def __str__(self):
return (
f"RelToAbsHandler for {self._map_axis} "
f"maps to: {self.mapping.get_output_name_constant()} "
f"{self.mapping.get_output_type_code()} at "
f"{self.mapping.target_uinput}"
)
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
def get_children(self) -> List[MappingHandler]:
return []
def _observe_rate(self, event: InputEvent):
"""Watch incoming events and remember how many events appear per second."""
if self._previous_event is not None:
delta_time = event.timestamp() - self._previous_event.timestamp()
if delta_time == 0:
logger.error("Observed two events with the same timestamp")
return
rate = 1 / delta_time
# mice seem to have a constant rate. wheel events are jaggy and the
# rate depends on how fast it is turned.
if rate > self._observed_rate:
logger.debug("Updating rate to %s", rate)
self._observed_rate = rate
self._calculate_cutoff()
self._previous_event = event
def _get_default_cutoff(self):
"""Get the cutoff value assuming the default input rate."""
if self._map_axis.code in [REL_WHEEL, REL_HWHEEL]:
return self.mapping.rel_to_abs_input_cutoff * WHEEL_SCALING
if self._map_axis.code in [REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES]:
return self.mapping.rel_to_abs_input_cutoff * WHEEL_HI_RES_SCALING
return self.mapping.rel_to_abs_input_cutoff * REL_XY_SCALING
def _calculate_cutoff(self):
"""Correct the default cutoff with the observed input rate, and set it."""
# Mice that have very high input rates report low values at the same time.
# If the rate is high, use a lower cutoff-value. If the rate is low, use a
# higher cutoff-value.
cutoff = self._get_default_cutoff()
cutoff *= DEFAULT_REL_RATE / self._observed_rate
self._transform.set_range(-max(1, int(cutoff)), max(1, int(cutoff)))
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
suppress: bool = False,
) -> bool:
self._observe_rate(event)
if event.input_match_hash != self._map_axis.input_match_hash:
return False
if EventActions.recenter in event.actions:
if self._recenter_loop:
self._recenter_loop.cancel()
self._recenter()
return True
if not self._recenter_loop or self._recenter_loop.cancelled():
self._recenter_loop = asyncio.create_task(self._create_recenter_loop())
self._moving.set() # notify the _recenter_loop
try:
self._write(self._scale_to_target(self._transform(event.value)))
return True
except (exceptions.UinputNotAvailable, exceptions.EventNotHandled):
return False
def reset(self) -> None:
if self._recenter_loop:
self._recenter_loop.cancel()
self._recenter()
def _recenter(self) -> None:
"""Recenter the output."""
self._write(self._scale_to_target(0))
async def _create_recenter_loop(self) -> None:
"""Coroutine which waits for the input to start moving,
then waits until the input stops moving, centers the output and repeat.
Runs forever.
"""
while True:
await self._moving.wait() # input moving started
while (
await asyncio.wait(
(asyncio.create_task(self._moving.wait()),),
timeout=self.mapping.release_timeout,
)
)[0]:
self._moving.clear() # still moving
self._recenter() # input moving stopped
def _scale_to_target(self, x: float) -> int:
"""Scales a x value between -1 and 1 to an integer between
target_absinfo.min and target_absinfo.max
input values above 1 or below -1 are clamped to the extreme values
"""
factor = (self._target_absinfo.max - self._target_absinfo.min) / 2
offset = self._target_absinfo.min + factor
y = factor * x + offset
if y > offset:
return int(min(self._target_absinfo.max, y))
else:
return int(max(self._target_absinfo.min, y))
def _write(self, value: int) -> None:
"""Inject."""
try:
self.global_uinputs.write(
(*self._output_axis, value),
self.mapping.target_uinput,
)
except OverflowError:
# screwed up the calculation of the event value
logger.error("OverflowError (%s, %s, %s)", *self._output_axis, value)
def needs_wrapping(self) -> bool:
return len(self.input_configs) > 1
def set_sub_handler(self, handler: MappingHandler) -> None:
assert False # cannot have a sub-handler
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
if self.needs_wrapping():
return {InputCombination(self.input_configs): HandlerEnums.axisswitch}
return {}

View File

@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import asyncio
import time
from typing import List
import evdev
from evdev.ecodes import EV_REL
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
)
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.logging.logger import logger
class RelToBtnHandler(MappingHandler):
"""Handler which transforms an EV_REL to a button event
and sends that to a sub_handler
adheres to the MappingHandler protocol
"""
_active: bool
_input_config: InputConfig
_last_activation: float
_sub_handler: MappingHandler
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
global_uinputs: GlobalUInputs,
**_,
) -> None:
super().__init__(combination, mapping, global_uinputs)
self._active = False
self._input_config = combination[0]
self._last_activation = time.time()
self._abort_release = False
assert self._input_config.analog_threshold != 0
assert len(combination) == 1
def __str__(self):
return f'RelToBtnHandler for "{self._input_config}"'
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
def get_children(self) -> List[MappingHandler]:
return [self._sub_handler]
async def _stage_release(
self,
source: evdev.InputDevice,
suppress: bool,
):
while time.time() < self._last_activation + self.mapping.release_timeout:
await asyncio.sleep(1 / self.mapping.rel_rate)
if self._abort_release:
self._abort_release = False
return
event = InputEvent(
0,
0,
*self._input_config.type_and_code,
value=0,
actions=(EventActions.as_key,),
origin_hash=self._input_config.origin_hash,
)
logger.debug("Sending %s to sub_handler", event)
self._sub_handler.notify(event, source, suppress)
self._active = False
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
suppress: bool = False,
) -> bool:
assert event.type == EV_REL
if event.input_match_hash != self._input_config.input_match_hash:
return False
assert (threshold := self._input_config.analog_threshold)
value = event.value
if (value < threshold > 0) or (value > threshold < 0):
if self._active:
# the axis is below the threshold and the stage_release
# function is running
if self.mapping.force_release_timeout:
# consume the event
return True
event = event.modify(pressed=False, actions=(EventActions.as_key,))
logger.debug("Sending %s to sub_handler", event)
self._abort_release = True
else:
# don't consume the event.
# We could return True to consume events
return False
else:
# the axis is above the threshold
if not self._active:
asyncio.ensure_future(self._stage_release(source, suppress))
if value >= threshold > 0:
direction = 1
else:
direction = -1
self._last_activation = time.time()
event = event.modify(
pressed=True,
direction=direction,
actions=(EventActions.as_key,),
)
self._active = event.is_pressed()
# logger.debug("Sending %s to sub_handler", event)
return self._sub_handler.notify(event, source=source, suppress=suppress)
def reset(self) -> None:
if self._active:
self._abort_release = True
self._active = False
self._sub_handler.reset()

View File

@ -0,0 +1,277 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import math
from typing import Dict, List
import evdev
from evdev.ecodes import (
EV_REL,
REL_WHEEL,
REL_HWHEEL,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
)
from inputremapper import exceptions
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import (
Mapping,
REL_XY_SCALING,
WHEEL_SCALING,
WHEEL_HI_RES_SCALING,
)
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
from inputremapper.injection.mapping_handlers.mapping_handler import (
HandlerEnums,
MappingHandler,
)
from inputremapper.input_event import InputEvent
from inputremapper.logging.logger import logger
def is_wheel(event) -> bool:
return event.type == EV_REL and event.code in (REL_WHEEL, REL_HWHEEL)
def is_high_res_wheel(event) -> bool:
return event.type == EV_REL and event.code in (REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES)
class Remainder:
_scale: float
_remainder: float
def __init__(self, scale: float):
self._scale = scale
self._remainder = 0
def input(self, value: float) -> int:
# if the mouse moves very slow, it might not move at all because of the
# int-conversion (which is required when writing). store the remainder
# (the decimal places) and add it up, until the mouse moves a little.
scaled = value * self._scale + self._remainder
self._remainder = math.fmod(scaled, 1)
return int(scaled)
class RelToRelHandler(MappingHandler):
"""Handler which transforms EV_REL to EV_REL events."""
_input_config: InputConfig # the relative movement we map
_max_observed_input: float
_transform: Transformation
_remainder: Remainder
_wheel_remainder: Remainder
_wheel_hi_res_remainder: Remainder
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
global_uinputs: GlobalUInputs,
**_,
) -> None:
super().__init__(combination, mapping, global_uinputs)
assert self.mapping.output_code is not None
# find the input event we are supposed to map. If the input combination is
# BTN_A + REL_X + BTN_B, then use the value of REL_X for the transformation
input_config = combination.find_analog_input_config(type_=EV_REL)
assert input_config is not None
self._input_config = input_config
self._max_observed_input = 1
self._remainder = Remainder(REL_XY_SCALING)
self._wheel_remainder = Remainder(WHEEL_SCALING)
self._wheel_hi_res_remainder = Remainder(WHEEL_HI_RES_SCALING)
self._transform = Transformation(
max_=1,
min_=-1,
deadzone=self.mapping.deadzone,
gain=self.mapping.gain,
expo=self.mapping.expo,
)
def __str__(self):
return (
f"RelToRelHandler for {self._input_config} "
f"maps to: {self.mapping.output_code} at {self.mapping.target_uinput}"
)
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
def get_children(self) -> List[MappingHandler]:
return []
def _should_map(self, event: InputEvent):
"""Check if this input event is relevant for this handler."""
return event.input_match_hash == self._input_config.input_match_hash
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
suppress: bool = False,
) -> bool:
if not self._should_map(event):
return False
"""
There was the idea to define speed as "movemnt per second".
There are deprecated mapping variables in this explanation.
rel2rel example:
- input every 0.1s (`input_rate` of 10 events/s), value of 200
- input speed is 2000, because in 1 second a value of 2000 acumulates
- `input_rel_speed` is a const defined as 4000 px/s, how fast mice usually move
- `transformed = Transformation(input.value, max=input_rel_speed / input_rate)`
- get 0.5 because the expo is 0
- `abs_to_rel_speed` is 5000
- inject 2500 therefore per second, making it a bit faster
- divide 2500 by the rate of 10 to inject a value of 250 each time input occurs
```
output_value = Transformation(
input.value,
max=input_rel_speed / input_rate
) * abs_to_rel_speed / input_rate
```
The input_rel_speed could be used here instead of abs_to_rel_speed, because the
gain already controls the speed. In that case it would be a 1:1 ratio of
input-to-output value if the gain is 1.
for wheel and wheel_hi_res, different input speed constants must be set.
abs2rel needs a base value for the output, so `abs_to_rel_speed` is still
required.
`abs_to_rel_speed / rel_rate * transform(input.value, max=absinfo.max)`
is the output value. Both abs_to_rel_speed and the transformation-gain control
speed.
if abs_to_rel_speed controls speed in the abs2rel output, it should also do so
in other handlers that have EV_REL output.
unfortunately input_rate needs to be determined during runtime, which screws
the overall speed up when slowly moving the input device in the beginning,
because slow input is thought to be the regular input.
---
transforming from rate based to rate based speed values won't work well.
better to use fractional speed values.
REL_X of 40 = REL_WHEEL of 1 = REL_WHEE_HI_RES of 1/120
this is why abs_to_rel_speed does not affect the rel_to_rel handler.
The expo calculation will be wrong in the beginning, because it is based on
the highest observed value. The overall gain will be fine though.
"""
input_value = float(event.value)
# scale down now, the remainder calculation scales up by the same factor later
# depending on what kind of event this becomes.
if event.is_wheel_event:
input_value /= WHEEL_SCALING
elif event.is_wheel_hi_res_event:
input_value /= WHEEL_HI_RES_SCALING
else:
# even though the input rate is unknown we can apply REL_XY_SCALING, which
# is based on 60hz or something, because the un-scaling also uses values
# based on 60hz. So the rate cancels out
input_value /= REL_XY_SCALING
if abs(input_value) > self._max_observed_input:
self._max_observed_input = abs(input_value)
# If _max_observed_input is wrong when the injection starts and the correct
# value learned during runtime, results can be weird at the beginning.
# If expo and deadzone are not set, then it is linear and doesn't matter.
transformed = self._transform(input_value / self._max_observed_input)
transformed *= self._max_observed_input
is_wheel_output = self.mapping.is_wheel_output()
is_hi_res_wheel_output = self.mapping.is_high_res_wheel_output()
horizontal = self.mapping.output_code in (
REL_HWHEEL_HI_RES,
REL_HWHEEL,
)
try:
if is_wheel_output or is_hi_res_wheel_output:
# inject both kinds of wheels, otherwise wheels don't work for some
# people. See issue #354
self._write(
REL_HWHEEL if horizontal else REL_WHEEL,
self._wheel_remainder.input(transformed),
)
self._write(
REL_HWHEEL_HI_RES if horizontal else REL_WHEEL_HI_RES,
self._wheel_hi_res_remainder.input(transformed),
)
else:
self._write(
self.mapping.output_code,
self._remainder.input(transformed),
)
return True
except OverflowError:
# screwed up the calculation of the event value
logger.error("OverflowError while handling %s", event)
return True
except (exceptions.UinputNotAvailable, exceptions.EventNotHandled):
return False
def reset(self) -> None:
pass
def _write(self, code: int, value: int):
if value == 0:
# rel 0 does not make sense. We don't need to tell linux that the mouse
# should not be moved this time.
return
self.global_uinputs.write(
(EV_REL, code, value),
self.mapping.target_uinput,
)
def needs_wrapping(self) -> bool:
return len(self.input_configs) > 1
def set_sub_handler(self, handler: MappingHandler) -> None:
assert False # cannot have a sub-handler
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
if self.needs_wrapping():
return {InputCombination(self.input_configs): HandlerEnums.axisswitch}
return {}

View File

@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Functions to handle numlocks.
For unknown reasons the numlock status can change when starting injections,
which is why these functions exist.
"""
import re
import subprocess
from inputremapper.logging.logger import logger
def is_numlock_on():
"""Get the current state of the numlock."""
try:
xset_q = subprocess.check_output(
["xset", "q"],
stderr=subprocess.STDOUT,
).decode()
num_lock_status = re.search(r"Num Lock:\s+(.+?)\s", xset_q)
if num_lock_status is not None:
return num_lock_status[1] == "on"
return False
except (FileNotFoundError, subprocess.CalledProcessError):
# tty
return None
def set_numlock(state):
"""Set the numlock to a given state of True or False."""
if state is None:
return
value = {True: "on", False: "off"}[state]
try:
subprocess.check_output(["numlockx", value])
except subprocess.CalledProcessError:
# might be in a tty
pass
except FileNotFoundError:
# doesn't seem to be installed everywhere
logger.debug("numlockx not found")
def ensure_numlock(func):
"""Decorator to reset the numlock to its initial state afterwards."""
def wrapped(*args, **kwargs):
# for some reason, grabbing a device can modify the num lock state.
# remember it and apply back later
numlock_before = is_numlock_on()
result = func(*args, **kwargs)
set_numlock(numlock_before)
return result
return wrapped

View File

@ -0,0 +1,264 @@
# -*- 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 enum
from dataclasses import dataclass
from typing import Tuple, Optional, Hashable, Literal
import evdev
from evdev import ecodes
from inputremapper.utils import get_evdev_constant_name, DeviceHash
class EventActions(enum.Enum):
"""Additional information an InputEvent can send through the event pipeline."""
as_key = enum.auto() # treat this event as a key event
recenter = enum.auto() # recenter the axis when receiving this
none = enum.auto()
# Todo: add slots=True as soon as python 3.10 is in common distros
@dataclass(frozen=True)
class InputEvent:
"""Events that are generated during runtime.
Is a drop-in replacement for evdev.InputEvent
"""
sec: int
usec: int
type: int
code: int
value: int
# Our own custom attributes
# (They need types for the dataclass to allow them in the constructor)
pressed: bool | None = None # haven't figured out yet, depends on threshold config
direction: int = 1 # -1 for joystick left, +1 for joystick right and buttons
actions: Tuple[EventActions, ...] = ()
origin_hash: Optional[DeviceHash] = None
def __eq__(self, other: InputEvent | evdev.InputEvent | Tuple[int, int, int]):
# useful in tests
if isinstance(other, InputEvent) or isinstance(other, evdev.InputEvent):
return self.event_tuple == (other.type, other.code, other.value)
if isinstance(other, tuple):
return self.event_tuple == other
raise TypeError(f"cannot compare {type(other)} with InputEvent")
def is_pressed(self) -> bool:
"""Get if the event is representing a pressed button, joystick, trigger,
a scrolled wheel, a moved mouse, etc.
It might depend on stuff like the analog_threshold.
"""
if self.pressed is not None:
return self.pressed
# As long as we haven't checked it using the analog thresholds and such,
# assume that anything != 0 means it is pressed.'
if self.value == 0:
return False
return True
@staticmethod
def validate_event(event):
"""Test if the event is valid."""
if not isinstance(event.type, int):
raise TypeError(f"Expected type to be an int, but got {event.type}")
if not isinstance(event.code, int):
raise TypeError(f"Expected code to be an int, but got {event.code}")
if not isinstance(event.value, int):
# this happened to me because I screwed stuff up
raise TypeError(f"Expected value to be an int, but got {event.value}")
return event
@property
def input_match_hash(self) -> Hashable:
"""a Hashable object which is intended to match the InputEvent with a
InputConfig.
"""
return self.type, self.code, self.origin_hash
@classmethod
def from_event(
cls,
event: evdev.InputEvent,
origin_hash: Optional[DeviceHash] = None,
) -> InputEvent:
"""Create a InputEvent from another InputEvent or evdev.InputEvent."""
try:
return cls(
event.sec,
event.usec,
event.type,
event.code,
event.value,
origin_hash=origin_hash,
)
except AttributeError as exception:
raise TypeError(
f"Failed to create InputEvent from {event = }"
) from exception
@classmethod
def from_tuple(
cls,
event_tuple: Tuple[int, int, int],
origin_hash: Optional[DeviceHash] = None,
) -> InputEvent:
"""Create a InputEvent from a (type, code, value) tuple."""
# use this as rarely as possible. Construct objects early on and pass them
# around instead of passing around integers
if len(event_tuple) != 3:
raise TypeError(
f"failed to create InputEvent {event_tuple = } must have length 3"
)
return cls.validate_event(
cls(
0,
0,
int(event_tuple[0]),
int(event_tuple[1]),
int(event_tuple[2]),
origin_hash=origin_hash,
)
)
@classmethod
def abs(cls, code: int, value: int, origin_hash: Optional[DeviceHash] = None):
"""Create an abs event, like joystick movements."""
return cls.validate_event(
cls(
0,
0,
ecodes.EV_ABS,
code,
value,
origin_hash=origin_hash,
)
)
@classmethod
def rel(cls, code: int, value: int, origin_hash: Optional[str] = None):
"""Create a rel event, like mouse movements."""
return cls.validate_event(
cls(
0,
0,
ecodes.EV_REL,
code,
value,
origin_hash=origin_hash,
)
)
@classmethod
def key(cls, code: int, value: Literal[0, 1], origin_hash: Optional[str] = None):
"""Create a key event, like keyboard keys or gamepad buttons.
A value of 1 means "press", a value of 0 means "release".
"""
return cls.validate_event(
cls(
0,
0,
ecodes.EV_KEY,
code,
value,
origin_hash=origin_hash,
)
)
@property
def type_and_code(self) -> Tuple[int, int]:
"""Event type, code."""
return self.type, self.code
@property
def event_tuple(self) -> Tuple[int, int, int]:
"""Event type, code, value."""
return self.type, self.code, self.value
@property
def is_key_event(self) -> bool:
"""Whether this is interpreted as a key event."""
return self.type == evdev.ecodes.EV_KEY or EventActions.as_key in self.actions
@property
def is_wheel_event(self) -> bool:
"""Whether this is interpreted as a key event."""
return self.type == evdev.ecodes.EV_REL and self.code in [
ecodes.REL_WHEEL,
ecodes.REL_HWHEEL,
]
@property
def is_wheel_hi_res_event(self) -> bool:
"""Whether this is interpreted as a key event."""
return self.type == evdev.ecodes.EV_REL and self.code in [
ecodes.REL_WHEEL_HI_RES,
ecodes.REL_HWHEEL_HI_RES,
]
def __str__(self):
name = get_evdev_constant_name(self.type, self.code)
return f"InputEvent for {self.event_tuple} {name}"
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
def timestamp(self):
"""Return the unix timestamp of when the event was seen."""
return self.sec + self.usec / 1000000
def modify(
self,
sec: Optional[int] = None,
usec: Optional[int] = None,
type_: Optional[int] = None,
code: Optional[int] = None,
value: Optional[int] = None,
pressed: Optional[bool] = None,
direction: Optional[int] = None,
actions: Optional[Tuple[EventActions, ...]] = None,
origin_hash: Optional[str] = None,
) -> InputEvent:
"""Return a new modified event."""
return InputEvent(
sec if sec is not None else self.sec,
usec if usec is not None else self.usec,
type_ if type_ is not None else self.type,
code if code is not None else self.code,
value if value is not None else self.value,
pressed if pressed is not None else self.is_pressed(),
direction if direction is not None else self.direction,
actions if actions is not None else self.actions,
origin_hash=origin_hash if origin_hash is not None else self.origin_hash, # type: ignore
)

View File

@ -0,0 +1,30 @@
#!/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/>.
"""Information about the input-remapper installation.
These defaults might be overwritten by package maintainers.
"""
COMMIT_HASH = "unknown"
VERSION = "2.2.1"
# depending on where this file is installed to, make sure to use the proper
# prefix path for data.
DATA_DIR = "/usr/share/input-remapper"

View File

@ -0,0 +1,24 @@
# -*- 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/>.
"""Since I'm not forking, I can't use multiprocessing.Pipe.
Processes that need privileges are spawned with pkexec, which connect to
known pipe paths to communicate with the non-privileged parent process.
"""

183
inputremapper/ipc/pipe.py Normal file
View File

@ -0,0 +1,183 @@
# -*- 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/>.
"""Named bidirectional non-blocking pipes.
>>> p1 = Pipe('foo')
>>> p2 = Pipe('foo')
>>> p1.send(1)
>>> p2.poll()
>>> p2.recv()
>>> p2.send(2)
>>> p1.poll()
>>> p1.recv()
Beware that pipes read any available messages,
even those written by themselves.
"""
import asyncio
import json
import os
import time
from typing import Optional, AsyncIterator, Union
from inputremapper.configs.paths import PathUtils
from inputremapper.logging.logger import logger
class Pipe:
"""Pipe object.
This is not for secure communication. If pipes already exist, they will be used,
but existing pipes might have open permissions! Only use this for stuff that
non-privileged users would be allowed to read.
"""
def __init__(self, path):
"""Create a pipe, or open it if it already exists."""
self._path = path
self._unread = []
self._created_at = time.time()
self._transport: Optional[asyncio.ReadTransport] = None
self._async_iterator: Optional[AsyncIterator] = None
paths = (f"{path}r", f"{path}w")
PathUtils.mkdir(os.path.dirname(path))
if not os.path.exists(paths[0]):
logger.debug("Creating new pipes %s", paths)
# The fd the link points to is closed, or none ever existed
# If there is a link, remove it.
if os.path.islink(paths[0]):
os.remove(paths[0])
if os.path.islink(paths[1]):
os.remove(paths[1])
self._fds = os.pipe()
fds_dir = f"/proc/{os.getpid()}/fd/"
PathUtils.chown(f"{fds_dir}{self._fds[0]}")
PathUtils.chown(f"{fds_dir}{self._fds[1]}")
# to make it accessible by path constants, create symlinks
os.symlink(f"{fds_dir}{self._fds[0]}", paths[0])
os.symlink(f"{fds_dir}{self._fds[1]}", paths[1])
else:
logger.debug("Using existing pipes %s", paths)
# thanks to os.O_NONBLOCK, readline will return b'' when there
# is nothing to read
self._fds = (
os.open(paths[0], os.O_RDONLY | os.O_NONBLOCK),
os.open(paths[1], os.O_WRONLY | os.O_NONBLOCK),
)
self._handles = (open(self._fds[0], "r"), open(self._fds[1], "w"))
# clear the pipe of any contents, to avoid leftover messages from breaking
# the reader-client or reader-service
while self.poll():
leftover = self.recv()
logger.debug('Cleared leftover message "%s"', leftover)
def __del__(self):
if self._transport:
logger.debug("closing transport")
self._transport.close()
for file in self._handles:
file.close()
def recv(self):
"""Read an object from the pipe or None if nothing available.
Doesn't transmit pickles, to avoid injection attacks on the
privileged reader-service. Only messages that can be converted to json
are allowed.
"""
if len(self._unread) > 0:
return self._unread.pop(0)
line = self._handles[0].readline()
if len(line) == 0:
return None
return self._get_msg(line)
def _get_msg(self, line: str):
parsed = json.loads(line)
if parsed[0] < self._created_at and os.environ.get("UNITTEST"):
# important to avoid race conditions between multiple unittests,
# for example old terminate messages reaching a new instance of
# the reader-service.
logger.debug("Ignoring old message %s", parsed)
return None
return parsed[1]
def send(self, message: Union[str, int, float, dict, list, tuple]):
"""Write a serializable object to the pipe."""
dump = json.dumps((time.time(), message))
# there aren't any newlines supposed to be,
# but if there are it breaks readline().
self._handles[1].write(dump.replace("\n", ""))
self._handles[1].write("\n")
self._handles[1].flush()
def poll(self):
"""Check if there is anything that can be read."""
if len(self._unread) > 0:
return True
# using select.select apparently won't mark the pipe as ready
# anymore when there are multiple lines to read but only a single
# line is retreived. Using read instead.
msg = self.recv()
if msg is not None:
self._unread.append(msg)
return len(self._unread) > 0
def fileno(self):
"""Compatibility to select.select."""
return self._handles[0].fileno()
def __aiter__(self):
return self
async def __anext__(self):
if not self._async_iterator:
loop = asyncio.get_running_loop()
reader = asyncio.StreamReader()
self._transport, _ = await loop.connect_read_pipe(
lambda: asyncio.StreamReaderProtocol(reader), self._handles[0]
)
self._async_iterator = reader.__aiter__()
return self._get_msg(await self._async_iterator.__anext__())
async def recv_async(self):
"""Read the next line with async. Do not use this when using
the async for loop."""
return await self.__aiter__().__anext__()

View File

@ -0,0 +1,122 @@
# -*- 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/>.
"""Share a dictionary across processes."""
import atexit
import multiprocessing
import select
from typing import Optional, Any
from inputremapper.logging.logger import logger
class SharedDict:
"""Share a dictionary across processes."""
# because unittests terminate all child processes in cleanup I can't use
# multiprocessing.Manager
def __init__(self):
"""Create a shared dictionary."""
super().__init__()
# To avoid blocking forever if something goes wrong. The maximum
# observed time communication takes was 0.001 for me on a slow pc
self._timeout = 0.02
self.pipe = multiprocessing.Pipe()
self.process = None
atexit.register(self._stop)
def start(self):
"""Ensure the process to manage the dictionary is running."""
if self.process is not None and self.process.is_alive():
logger.debug("SharedDict process already running")
return
# if the manager has already been running in the past but stopped
# for some reason, the dictionary contents are lost.
logger.debug("Starting SharedDict process")
self.process = multiprocessing.Process(target=self.manage)
self.process.start()
def manage(self):
"""Manage the dictionary, handle read and write requests."""
logger.debug("SharedDict process started")
shared_dict = {}
while True:
message = self.pipe[0].recv()
logger.debug("SharedDict got %s", message)
if message[0] == "stop":
return
if message[0] == "set":
shared_dict[message[1]] = message[2]
if message[0] == "clear":
shared_dict.clear()
if message[0] == "get":
self.pipe[0].send(shared_dict.get(message[1]))
if message[0] == "ping":
self.pipe[0].send("pong")
def _stop(self):
"""Stop the managing process."""
self.pipe[1].send(("stop",))
def _clear(self):
"""Clears the memory."""
self.pipe[1].send(("clear",))
def get(self, key: str):
"""Get a value from the dictionary.
If it doesn't exist, returns None.
"""
return self[key]
def is_alive(self, timeout: Optional[int] = None):
"""Check if the manager process is running."""
self.pipe[1].send(("ping",))
select.select([self.pipe[1]], [], [], timeout or self._timeout)
if self.pipe[1].poll():
return self.pipe[1].recv() == "pong"
return False
def __setitem__(self, key: str, value: Any):
self.pipe[1].send(("set", key, value))
def __getitem__(self, key: str):
self.pipe[1].send(("get", key))
select.select([self.pipe[1]], [], [], self._timeout)
if self.pipe[1].poll():
return self.pipe[1].recv()
logger.error("select.select timed out")
return None
def __del__(self):
self._stop()

Some files were not shown because too many files have changed in this diff Show More