chore(repo): baseline du fork input-remapper (upstream v2.2.1)
Le dépôt ne contenait que README.md ; ajout de l'intégralité du code fonctionnel du fork comme socle stable de la branche de release. L'état runtime d'orchestration (.ideai/) est exclu du suivi. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
0
inputremapper/__init__.py
Normal file
0
inputremapper/__init__.py
Normal file
0
inputremapper/bin/__init__.py
Normal file
0
inputremapper/bin/__init__.py
Normal file
429
inputremapper/bin/input_remapper_control.py
Executable file
429
inputremapper/bin/input_remapper_control.py
Executable 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
|
||||
152
inputremapper/bin/input_remapper_gtk.py
Executable file
152
inputremapper/bin/input_remapper_gtk.py
Executable 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()
|
||||
80
inputremapper/bin/input_remapper_reader_service.py
Executable file
80
inputremapper/bin/input_remapper_reader_service.py
Executable 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())
|
||||
71
inputremapper/bin/input_remapper_service.py
Executable file
71
inputremapper/bin/input_remapper_service.py
Executable 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()
|
||||
39
inputremapper/bin/process_utils.py
Normal file
39
inputremapper/bin/process_utils.py
Normal 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
|
||||
0
inputremapper/configs/__init__.py
Normal file
0
inputremapper/configs/__init__.py
Normal file
33
inputremapper/configs/data.py
Normal file
33
inputremapper/configs/data.py
Normal 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)
|
||||
142
inputremapper/configs/global_config.py
Normal file
142
inputremapper/configs/global_config.py
Normal 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 = {}
|
||||
485
inputremapper/configs/input_config.py
Normal file
485
inputremapper/configs/input_config.py
Normal 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)
|
||||
221
inputremapper/configs/keyboard_layout.py
Normal file
221
inputremapper/configs/keyboard_layout.py
Normal 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()
|
||||
525
inputremapper/configs/mapping.py
Normal file
525
inputremapper/configs/mapping.py
Normal 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_
|
||||
516
inputremapper/configs/migrations.py
Normal file
516
inputremapper/configs/migrations.py
Normal 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
|
||||
157
inputremapper/configs/paths.py
Normal file
157
inputremapper/configs/paths.py
Normal 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)
|
||||
325
inputremapper/configs/preset.py
Normal file
325
inputremapper/configs/preset.py
Normal 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
|
||||
137
inputremapper/configs/validation_errors.py
Normal file
137
inputremapper/configs/validation_errors.py
Normal 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
548
inputremapper/daemon.py
Normal 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)
|
||||
65
inputremapper/exceptions.py
Normal file
65
inputremapper/exceptions.py
Normal 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
567
inputremapper/groups.py
Normal 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()
|
||||
6
inputremapper/gui/__init__.py
Normal file
6
inputremapper/gui/__init__.py
Normal 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")
|
||||
457
inputremapper/gui/autocompletion.py
Normal file
457
inputremapper/gui/autocompletion.py
Normal 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,
|
||||
[],
|
||||
)
|
||||
0
inputremapper/gui/components/__init__.py
Normal file
0
inputremapper/gui/components/__init__.py
Normal file
174
inputremapper/gui/components/common.py
Normal file
174
inputremapper/gui/components/common.py
Normal 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))
|
||||
115
inputremapper/gui/components/device_groups.py
Normal file
115
inputremapper/gui/components/device_groups.py
Normal 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)
|
||||
1211
inputremapper/gui/components/editor.py
Normal file
1211
inputremapper/gui/components/editor.py
Normal file
File diff suppressed because it is too large
Load Diff
134
inputremapper/gui/components/main.py
Normal file
134
inputremapper/gui/components/main.py
Normal 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)
|
||||
3
inputremapper/gui/components/output_type_names.py
Normal file
3
inputremapper/gui/components/output_type_names.py
Normal file
@ -0,0 +1,3 @@
|
||||
class OutputTypeNames:
|
||||
analog_axis = "Analog Axis"
|
||||
key_or_macro = "Key or Macro"
|
||||
106
inputremapper/gui/components/presets.py
Normal file
106
inputremapper/gui/components/presets.py
Normal 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)
|
||||
876
inputremapper/gui/controller.py
Normal file
876
inputremapper/gui/controller.py
Normal 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
|
||||
609
inputremapper/gui/data_manager.py
Normal file
609
inputremapper/gui/data_manager.py
Normal 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)
|
||||
125
inputremapper/gui/forward_to_ui_handler.py
Normal file
125
inputremapper/gui/forward_to_ui_handler.py
Normal 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
|
||||
33
inputremapper/gui/gettext.py
Normal file
33
inputremapper/gui/gettext.py
Normal 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
|
||||
0
inputremapper/gui/messages/__init__.py
Normal file
0
inputremapper/gui/messages/__init__.py
Normal file
120
inputremapper/gui/messages/message_broker.py
Normal file
120
inputremapper/gui/messages/message_broker.py
Normal 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
|
||||
126
inputremapper/gui/messages/message_data.py
Normal file
126
inputremapper/gui/messages/message_data.py
Normal file
@ -0,0 +1,126 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
60
inputremapper/gui/messages/message_types.py
Normal file
60
inputremapper/gui/messages/message_types.py
Normal 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"
|
||||
306
inputremapper/gui/reader_client.py
Normal file
306
inputremapper/gui/reader_client.py
Normal 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()
|
||||
420
inputremapper/gui/reader_service.py
Normal file
420
inputremapper/gui/reader_service.py
Normal 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
|
||||
417
inputremapper/gui/user_interface.py
Normal file
417
inputremapper/gui/user_interface.py
Normal 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
272
inputremapper/gui/utils.py
Normal 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,
|
||||
)
|
||||
0
inputremapper/injection/__init__.py
Normal file
0
inputremapper/injection/__init__.py
Normal file
132
inputremapper/injection/context.py
Normal file
132
inputremapper/injection/context.py
Normal file
@ -0,0 +1,132 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Stores injection-process wide information."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import List, Dict, Set, Hashable
|
||||
|
||||
import evdev
|
||||
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
EventListener,
|
||||
NotifyCallback,
|
||||
)
|
||||
from inputremapper.injection.mapping_handlers.mapping_parser import (
|
||||
MappingParser,
|
||||
EventPipelines,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.utils import DeviceHash
|
||||
|
||||
|
||||
class Context:
|
||||
"""Stores injection-process wide information.
|
||||
|
||||
In some ways this is a wrapper for the preset that derives some
|
||||
information that is specifically important to the injection.
|
||||
|
||||
The information in the context does not change during the injection.
|
||||
|
||||
One Context exists for each injection process, which is shared
|
||||
with all coroutines and used objects.
|
||||
|
||||
Benefits of the context:
|
||||
- less redundant passing around of parameters
|
||||
- easier to add new process wide information without having to adjust
|
||||
all function calls in unittests
|
||||
- makes the injection class shorter and more specific to a certain task,
|
||||
which is actually spinning up the injection.
|
||||
|
||||
Note, that for the reader_service a ContextDummy is used.
|
||||
|
||||
Members
|
||||
-------
|
||||
preset : Preset
|
||||
The preset holds all Mappings for the injection process
|
||||
listeners : Set[EventListener]
|
||||
A set of callbacks which receive all events
|
||||
callbacks : Dict[Tuple[int, int], List[NotifyCallback]]
|
||||
All entry points to the event pipeline sorted by InputEvent.type_and_code
|
||||
"""
|
||||
|
||||
listeners: Set[EventListener]
|
||||
_notify_callbacks: Dict[Hashable, List[NotifyCallback]]
|
||||
_handlers: EventPipelines
|
||||
_forward_devices: Dict[DeviceHash, evdev.UInput]
|
||||
_source_devices: Dict[DeviceHash, evdev.InputDevice]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
preset: Preset,
|
||||
source_devices: Dict[DeviceHash, evdev.InputDevice],
|
||||
forward_devices: Dict[DeviceHash, evdev.UInput],
|
||||
mapping_parser: MappingParser,
|
||||
) -> None:
|
||||
if len(forward_devices) == 0:
|
||||
logger.warning("forward_devices not set")
|
||||
|
||||
if len(source_devices) == 0:
|
||||
logger.warning("source_devices not set")
|
||||
|
||||
self.listeners = set()
|
||||
self._source_devices = source_devices
|
||||
self._forward_devices = forward_devices
|
||||
self._notify_callbacks = defaultdict(list)
|
||||
self._handlers = mapping_parser.parse_mappings(preset, self)
|
||||
|
||||
self._create_callbacks()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Call the reset method for each handler in the context."""
|
||||
for handlers in self._handlers.values():
|
||||
for handler in handlers:
|
||||
handler.reset()
|
||||
|
||||
def _create_callbacks(self) -> None:
|
||||
"""Add the notify method from all _handlers to self.callbacks."""
|
||||
for input_config, handler_list in self._handlers.items():
|
||||
input_match_hash = input_config.input_match_hash
|
||||
logger.debug("Adding NotifyCallback for %s", input_match_hash)
|
||||
self._notify_callbacks[input_match_hash].extend(
|
||||
handler.notify for handler in handler_list
|
||||
)
|
||||
|
||||
def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]:
|
||||
input_match_hash = input_event.input_match_hash
|
||||
return self._notify_callbacks[input_match_hash]
|
||||
|
||||
def get_forward_uinput(self, origin_hash: DeviceHash) -> evdev.UInput:
|
||||
"""Get the "forward" uinput events from the given origin should go into."""
|
||||
return self._forward_devices[origin_hash]
|
||||
|
||||
def get_source(self, key: DeviceHash) -> evdev.InputDevice:
|
||||
return self._source_devices[key]
|
||||
|
||||
def get_leds(self) -> Set[int]:
|
||||
"""Get a set of LED_* ecodes that are currently on."""
|
||||
leds = set()
|
||||
for device in self._source_devices.values():
|
||||
leds.update(device.leds())
|
||||
return leds
|
||||
205
inputremapper/injection/event_reader.py
Normal file
205
inputremapper/injection/event_reader.py
Normal file
@ -0,0 +1,205 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Because multiple calls to async_read_loop won't work."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import traceback
|
||||
from typing import AsyncIterator, Protocol, Set, List
|
||||
|
||||
import evdev
|
||||
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
EventListener,
|
||||
NotifyCallback,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.utils import get_device_hash, DeviceHash
|
||||
|
||||
|
||||
class Context(Protocol):
|
||||
listeners: Set[EventListener]
|
||||
|
||||
def reset(self): ...
|
||||
|
||||
def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]: ...
|
||||
|
||||
def get_forward_uinput(self, origin_hash: DeviceHash) -> evdev.UInput: ...
|
||||
|
||||
|
||||
class EventReader:
|
||||
"""Reads input events from a single device and distributes them.
|
||||
|
||||
There is one EventReader object for each source, which tells multiple
|
||||
mapping_handlers that a new event is ready so that they can inject all sorts of
|
||||
funny things.
|
||||
|
||||
Other devnodes may be present for the hardware device, in which case this
|
||||
needs to be created multiple times.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context: Context,
|
||||
source: evdev.InputDevice,
|
||||
stop_event: asyncio.Event,
|
||||
) -> None:
|
||||
"""Initialize all mapping_handlers
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source
|
||||
where to read keycodes from
|
||||
"""
|
||||
self._device_hash = get_device_hash(source)
|
||||
self._source = source
|
||||
self.context = context
|
||||
self.stop_event = stop_event
|
||||
|
||||
def stop(self):
|
||||
"""Stop the reader."""
|
||||
self.stop_event.set()
|
||||
|
||||
async def read_loop(self) -> AsyncIterator[evdev.InputEvent]:
|
||||
stop_task = asyncio.Task(self.stop_event.wait())
|
||||
loop = asyncio.get_running_loop()
|
||||
events_ready = asyncio.Event()
|
||||
loop.add_reader(self._source.fileno(), events_ready.set)
|
||||
|
||||
while True:
|
||||
_, pending = await asyncio.wait(
|
||||
{stop_task, asyncio.Task(events_ready.wait())},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
fd_broken = os.stat(self._source.fileno()).st_nlink == 0
|
||||
if fd_broken:
|
||||
# happens when the device is unplugged while reading, causing 100% cpu
|
||||
# usage because events_ready.set is called repeatedly forever,
|
||||
# while read_loop will hang at self._source.read_one().
|
||||
logger.error("fd broke, was the device unplugged?")
|
||||
|
||||
if stop_task.done() or fd_broken:
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
loop.remove_reader(self._source.fileno())
|
||||
logger.debug("read loop stopped")
|
||||
return
|
||||
|
||||
events_ready.clear()
|
||||
while event := self._source.read_one():
|
||||
yield event
|
||||
|
||||
def send_to_handlers(self, event: InputEvent) -> bool:
|
||||
"""Send the event to the NotifyCallbacks.
|
||||
|
||||
Return if anyone took care of the event.
|
||||
"""
|
||||
if event.type == evdev.ecodes.EV_MSC:
|
||||
return False
|
||||
|
||||
if event.type == evdev.ecodes.EV_SYN:
|
||||
return False
|
||||
|
||||
handled = False
|
||||
notify_callbacks = self.context.get_notify_callbacks(event)
|
||||
|
||||
if notify_callbacks:
|
||||
for notify_callback in notify_callbacks:
|
||||
handled = notify_callback(event, source=self._source) | handled
|
||||
|
||||
return handled
|
||||
|
||||
async def send_to_listeners(self, event: InputEvent) -> None:
|
||||
"""Send the event to listeners."""
|
||||
if event.type == evdev.ecodes.EV_MSC:
|
||||
return
|
||||
|
||||
if event.type == evdev.ecodes.EV_SYN:
|
||||
return
|
||||
|
||||
for listener in self.context.listeners.copy():
|
||||
# use a copy, since the listeners might remove themselves from the set
|
||||
|
||||
await listener(event)
|
||||
|
||||
# Running macros have priority, give them a head-start for processing the
|
||||
# event. If if_single injects a modifier, this modifier should be active
|
||||
# before the next handler injects an "a" or something, so that it is
|
||||
# possible to capitalize it via if_single.
|
||||
# 1. Event from keyboard arrives (e.g. an "a")
|
||||
# 2. the listener for if_single is called
|
||||
# 3. if_single decides runs then (e.g. injects shift_L)
|
||||
# 4. The original event is forwarded (or whatever it is supposed to do)
|
||||
# 5. Capitalized "A" is injected.
|
||||
# So make sure to call the listeners before notifying the handlers.
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
def forward(self, event: InputEvent) -> None:
|
||||
"""Forward an event, which injects it unmodified."""
|
||||
forward_to = self.context.get_forward_uinput(self._device_hash)
|
||||
logger.write(event, forward_to)
|
||||
forward_to.write(*event.event_tuple)
|
||||
|
||||
async def handle(self, event: InputEvent) -> None:
|
||||
if event.type == evdev.ecodes.EV_KEY and event.value == 2:
|
||||
# button-hold event. Environments (gnome, etc.) create them on
|
||||
# their own for the injection-fake-device if the release event
|
||||
# won't appear, no need to forward or map them.
|
||||
return
|
||||
|
||||
await self.send_to_listeners(event)
|
||||
|
||||
handled = self.send_to_handlers(event)
|
||||
|
||||
if not handled:
|
||||
# no handler took care of it, forward it
|
||||
self.forward(event)
|
||||
|
||||
async def run(self):
|
||||
"""Start doing things.
|
||||
|
||||
Can be stopped by stopping the asyncio loop or by setting the stop_event.
|
||||
This loop reads events from a single device only.
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting to listen for events from %s, fd %s",
|
||||
self._source.path,
|
||||
self._source.fd,
|
||||
)
|
||||
|
||||
async for event in self.read_loop():
|
||||
try:
|
||||
# Fire and forget, so that handlers and listeners can take their time,
|
||||
# if they want to wait for something special to happen.
|
||||
asyncio.ensure_future(
|
||||
self.handle(
|
||||
InputEvent.from_event(event, origin_hash=self._device_hash)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Handling event %s failed with %s", event, type(e))
|
||||
traceback.print_exception(e)
|
||||
|
||||
self.context.reset()
|
||||
logger.info("read loop for %s stopped", self._source.path)
|
||||
192
inputremapper/injection/global_uinputs.py
Normal file
192
inputremapper/injection/global_uinputs.py
Normal file
@ -0,0 +1,192 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import Dict, Union, Tuple, Optional, List, Type
|
||||
|
||||
import evdev
|
||||
|
||||
import inputremapper.exceptions
|
||||
import inputremapper.utils
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
MIN_ABS = -(2**15) # -32768
|
||||
MAX_ABS = 2**15 # 32768
|
||||
DEV_NAME = "input-remapper"
|
||||
DEFAULT_UINPUTS = {
|
||||
# for event codes see linux/input-event-codes.h
|
||||
"keyboard": {
|
||||
evdev.ecodes.EV_KEY: list(evdev.ecodes.KEY.keys() & evdev.ecodes.keys.keys())
|
||||
},
|
||||
"gamepad": {
|
||||
evdev.ecodes.EV_KEY: [*range(0x130, 0x13F)], # BTN_SOUTH - BTN_THUMBR
|
||||
evdev.ecodes.EV_ABS: [
|
||||
*(
|
||||
(i, evdev.AbsInfo(0, MIN_ABS, MAX_ABS, 0, 0, 0))
|
||||
for i in range(0x00, 0x06)
|
||||
),
|
||||
*((i, evdev.AbsInfo(0, -1, 1, 0, 0, 0)) for i in range(0x10, 0x12)),
|
||||
], # 6-axis and 1 hat switch
|
||||
},
|
||||
"mouse": {
|
||||
evdev.ecodes.EV_KEY: [*range(0x110, 0x118)], # BTN_LEFT - BTN_TASK
|
||||
evdev.ecodes.EV_REL: [*range(0x00, 0x0D)], # all REL axis
|
||||
},
|
||||
}
|
||||
DEFAULT_UINPUTS["keyboard + mouse"] = {
|
||||
evdev.ecodes.EV_KEY: [
|
||||
*DEFAULT_UINPUTS["keyboard"][evdev.ecodes.EV_KEY],
|
||||
*DEFAULT_UINPUTS["mouse"][evdev.ecodes.EV_KEY],
|
||||
],
|
||||
evdev.ecodes.EV_REL: [
|
||||
*DEFAULT_UINPUTS["mouse"][evdev.ecodes.EV_REL],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class UInput(evdev.UInput):
|
||||
_capabilities_cache: Optional[Dict] = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
name = kwargs["name"]
|
||||
logger.debug('creating UInput device: "%s"', name)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def can_emit(self, event: Tuple[int, int, int]):
|
||||
"""Check if an event can be emitted by the UIinput.
|
||||
|
||||
Wrong events might be injected if the group mappings are wrong,
|
||||
"""
|
||||
# this will never change, so we cache it since evdev runs an expensive loop to
|
||||
# gather the capabilities. (can_emit is called regularly)
|
||||
if self._capabilities_cache is None:
|
||||
self._capabilities_cache = self.capabilities(absinfo=False)
|
||||
|
||||
return event[1] in self._capabilities_cache.get(event[0], [])
|
||||
|
||||
|
||||
class FrontendUInput:
|
||||
"""Uinput which can not actually send events, for use in the frontend."""
|
||||
|
||||
def __init__(self, *_, events=None, name="py-evdev-uinput", **__):
|
||||
# see https://python-evdev.readthedocs.io/en/latest/apidoc.html#module-evdev.uinput # noqa pylint: disable=line-too-long
|
||||
self.events = events
|
||||
self.name = name
|
||||
|
||||
logger.debug('creating fake UInput device: "%s"', self.name)
|
||||
|
||||
def capabilities(self):
|
||||
return self.events
|
||||
|
||||
|
||||
class GlobalUInputs:
|
||||
"""Manages all UInputs that are shared between all injection processes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
uinput_factory: Union[Type[UInput], Type[FrontendUInput]],
|
||||
):
|
||||
self.devices: Dict[str, Union[UInput, FrontendUInput]] = {}
|
||||
self._uinput_factory = uinput_factory
|
||||
|
||||
def __iter__(self):
|
||||
return iter(uinput for _, uinput in self.devices.items())
|
||||
|
||||
@staticmethod
|
||||
def can_default_uinput_emit(target: str, type_: int, code: int) -> bool:
|
||||
"""Check if the uinput with the target name is capable of the event."""
|
||||
capabilities = DEFAULT_UINPUTS.get(target, {}).get(type_)
|
||||
return capabilities is not None and code in capabilities
|
||||
|
||||
@staticmethod
|
||||
def find_fitting_default_uinputs(type_: int, code: int) -> List[str]:
|
||||
"""Find the names of default uinputs that are able to emit this event."""
|
||||
return [
|
||||
uinput
|
||||
for uinput in DEFAULT_UINPUTS
|
||||
if code in DEFAULT_UINPUTS[uinput].get(type_, [])
|
||||
]
|
||||
|
||||
def reset(self):
|
||||
self.devices = {}
|
||||
self.prepare_all()
|
||||
|
||||
def prepare_all(self):
|
||||
"""Generate UInputs."""
|
||||
for name, events in DEFAULT_UINPUTS.items():
|
||||
if name in self.devices.keys():
|
||||
continue
|
||||
|
||||
self.devices[name] = self._uinput_factory(
|
||||
name=f"{DEV_NAME} {name}",
|
||||
phys=DEV_NAME,
|
||||
events=events,
|
||||
)
|
||||
|
||||
def prepare_single(self, name: str):
|
||||
"""Generate a single uinput.
|
||||
|
||||
This has to be done in the main process before injections that use it start.
|
||||
"""
|
||||
if name not in DEFAULT_UINPUTS:
|
||||
raise KeyError("Could not find a matching uinput to generate.")
|
||||
|
||||
if name in self.devices:
|
||||
logger.debug('Target "%s" already exists', name)
|
||||
return
|
||||
|
||||
self.devices[name] = self._uinput_factory(
|
||||
name=f"{DEV_NAME} {name}",
|
||||
phys=DEV_NAME,
|
||||
events=DEFAULT_UINPUTS[name],
|
||||
)
|
||||
|
||||
def write(self, event: Tuple[int, int, int], target_uinput):
|
||||
"""Write event to target uinput."""
|
||||
uinput = self.get_uinput(target_uinput)
|
||||
if not uinput:
|
||||
raise inputremapper.exceptions.UinputNotAvailable(target_uinput)
|
||||
|
||||
if not uinput.can_emit(event):
|
||||
raise inputremapper.exceptions.EventNotHandled(event)
|
||||
|
||||
# Was bool once due to a bug during development
|
||||
assert not isinstance(event[2], bool) and isinstance(event[2], int)
|
||||
|
||||
logger.write(event, uinput)
|
||||
uinput.write(*event)
|
||||
uinput.syn()
|
||||
|
||||
def get_uinput(self, name: str) -> Optional[evdev.UInput]:
|
||||
"""UInput with name
|
||||
|
||||
Or None if there is no uinput with this name.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name
|
||||
uniqe name of the uinput device
|
||||
"""
|
||||
if name not in self.devices:
|
||||
logger.error(
|
||||
f'UInput "{name}" is unknown. '
|
||||
+ f"Available: {list(self.devices.keys())}"
|
||||
)
|
||||
return None
|
||||
|
||||
return self.devices.get(name)
|
||||
511
inputremapper/injection/injector.py
Normal file
511
inputremapper/injection/injector.py
Normal file
@ -0,0 +1,511 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Keeps injecting keycodes in the background based on the preset."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import enum
|
||||
import multiprocessing
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from multiprocessing.connection import Connection
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
import evdev
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.groups import (
|
||||
_Group,
|
||||
classify,
|
||||
DeviceType,
|
||||
)
|
||||
from inputremapper.gui.messages.message_broker import MessageType
|
||||
from inputremapper.injection.context import Context
|
||||
from inputremapper.injection.event_reader import EventReader
|
||||
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
|
||||
from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.utils import get_device_hash, DeviceHash
|
||||
|
||||
CapabilitiesDict = Dict[int, List[int]]
|
||||
|
||||
DEV_NAME = "input-remapper"
|
||||
|
||||
|
||||
# messages sent to the injector process
|
||||
class InjectorCommand(str, enum.Enum):
|
||||
CLOSE = "CLOSE"
|
||||
|
||||
|
||||
# messages the injector process reports back to the service
|
||||
class InjectorState(str, enum.Enum):
|
||||
UNKNOWN = "UNKNOWN"
|
||||
STARTING = "STARTING"
|
||||
ERROR = "FAILED"
|
||||
RUNNING = "RUNNING"
|
||||
STOPPED = "STOPPED"
|
||||
NO_GRAB = "NO_GRAB"
|
||||
UPGRADE_EVDEV = "UPGRADE_EVDEV"
|
||||
|
||||
|
||||
def is_in_capabilities(
|
||||
combination: InputCombination, capabilities: CapabilitiesDict
|
||||
) -> bool:
|
||||
"""Are this combination or one of its sub keys in the capabilities?"""
|
||||
for event in combination:
|
||||
if event.code in capabilities.get(event.type, []):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_udev_name(name: str, suffix: str) -> str:
|
||||
"""Make sure the generated name is not longer than 80 chars."""
|
||||
max_len = 80 # based on error messages
|
||||
remaining_len = max_len - len(DEV_NAME) - len(suffix) - 2
|
||||
middle = name[:remaining_len]
|
||||
name = f"{DEV_NAME} {middle} {suffix}"
|
||||
return name
|
||||
|
||||
|
||||
def get_forward_name(name: str) -> str:
|
||||
"""Keep forwarded uinput names within evdev's 80 character limit."""
|
||||
return name[:80]
|
||||
|
||||
|
||||
def get_forward_phys(source: evdev.InputDevice) -> str:
|
||||
"""Use a stable phys marker for forwarded devices.
|
||||
|
||||
The original phys path must not be reused because it makes the forwarded
|
||||
device look like the hardware device to our autoload rule. However, using a
|
||||
dedicated input-remapper phys marker still allows us to identify and ignore
|
||||
the forwarded device elsewhere.
|
||||
"""
|
||||
if source.phys:
|
||||
return f"{DEV_NAME}/{source.phys}"
|
||||
|
||||
return DEV_NAME
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InjectorStateMessage:
|
||||
message_type = MessageType.injector_state
|
||||
state: Union[InjectorState]
|
||||
|
||||
def active(self) -> bool:
|
||||
return self.state in [InjectorState.RUNNING, InjectorState.STARTING]
|
||||
|
||||
def inactive(self) -> bool:
|
||||
return self.state in [InjectorState.STOPPED, InjectorState.NO_GRAB]
|
||||
|
||||
|
||||
class Injector(multiprocessing.Process):
|
||||
"""Initializes, starts and stops injections.
|
||||
|
||||
Is a process to make it non-blocking for the rest of the code and to
|
||||
make running multiple injector easier. There is one process per
|
||||
hardware-device that is being mapped.
|
||||
"""
|
||||
|
||||
group: _Group
|
||||
preset: Preset
|
||||
context: Optional[Context]
|
||||
_devices: List[evdev.InputDevice]
|
||||
_state: InjectorState
|
||||
_msg_pipe: Tuple[Connection, Connection]
|
||||
_event_readers: List[EventReader]
|
||||
_stop_event: asyncio.Event
|
||||
|
||||
regrab_timeout = 0.2
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
group: _Group,
|
||||
preset: Preset,
|
||||
mapping_parser: MappingParser,
|
||||
) -> None:
|
||||
"""
|
||||
|
||||
Parameters
|
||||
----------
|
||||
group
|
||||
the device group
|
||||
"""
|
||||
self.group = group
|
||||
self.mapping_parser = mapping_parser
|
||||
self._state = InjectorState.UNKNOWN
|
||||
|
||||
# used to interact with the parts of this class that are running within
|
||||
# the new process
|
||||
self._msg_pipe = multiprocessing.Pipe()
|
||||
|
||||
self.preset = preset
|
||||
self.context = None # only needed inside the injection process
|
||||
|
||||
self._event_readers = []
|
||||
|
||||
super().__init__(name=group.key)
|
||||
|
||||
"""Functions to interact with the running process."""
|
||||
|
||||
def get_state(self) -> InjectorState:
|
||||
"""Get the state of the injection.
|
||||
|
||||
Can be safely called from the main process.
|
||||
"""
|
||||
# before we try to we try to guess anything lets check if there is a message
|
||||
state = self._state
|
||||
while self._msg_pipe[1].poll():
|
||||
state = self._msg_pipe[1].recv()
|
||||
|
||||
# figure out what is going on step by step
|
||||
alive = self.is_alive()
|
||||
|
||||
# if `self.start()` has been called
|
||||
started = state != InjectorState.UNKNOWN or alive
|
||||
|
||||
if started:
|
||||
if state == InjectorState.UNKNOWN and alive:
|
||||
# if it is alive, it is definitely at least starting up.
|
||||
state = InjectorState.STARTING
|
||||
|
||||
if state in (InjectorState.STARTING, InjectorState.RUNNING) and not alive:
|
||||
# we thought it is running (maybe it was when get_state was previously),
|
||||
# but the process is not alive. It probably crashed
|
||||
state = InjectorState.ERROR
|
||||
logger.error("Injector was unexpectedly found stopped")
|
||||
|
||||
logger.debug(
|
||||
'Injector state of "%s", "%s": %s',
|
||||
self.group.key,
|
||||
self.preset.name,
|
||||
state,
|
||||
)
|
||||
self._state = state
|
||||
return self._state
|
||||
|
||||
@ensure_numlock
|
||||
def stop_injecting(self) -> None:
|
||||
"""Stop injecting keycodes.
|
||||
|
||||
Can be safely called from the main procss.
|
||||
"""
|
||||
logger.info('Stopping injecting keycodes for group "%s"', self.group.key)
|
||||
self._msg_pipe[1].send(InjectorCommand.CLOSE)
|
||||
|
||||
"""Process internal stuff."""
|
||||
|
||||
def _find_input_device(
|
||||
self, input_config: InputConfig
|
||||
) -> Optional[evdev.InputDevice]:
|
||||
"""find the InputDevice specified by the InputConfig
|
||||
|
||||
ensures the devices supports the type and code specified by the InputConfig"""
|
||||
devices_by_hash = {get_device_hash(device): device for device in self._devices}
|
||||
|
||||
# mypy thinks None is the wrong type for dict.get()
|
||||
if device := devices_by_hash.get(input_config.origin_hash): # type: ignore
|
||||
if input_config.code in device.capabilities(absinfo=False).get(
|
||||
input_config.type, []
|
||||
):
|
||||
return device
|
||||
return None
|
||||
|
||||
def _find_input_device_fallback(
|
||||
self, input_config: InputConfig
|
||||
) -> Optional[evdev.InputDevice]:
|
||||
"""find the InputDevice specified by the InputConfig fallback logic"""
|
||||
ranking = [
|
||||
DeviceType.KEYBOARD,
|
||||
DeviceType.GAMEPAD,
|
||||
DeviceType.MOUSE,
|
||||
DeviceType.TOUCHPAD,
|
||||
DeviceType.GRAPHICS_TABLET,
|
||||
DeviceType.CAMERA,
|
||||
DeviceType.UNKNOWN,
|
||||
]
|
||||
candidates: List[evdev.InputDevice] = [
|
||||
device
|
||||
for device in self._devices
|
||||
if input_config.code
|
||||
in device.capabilities(absinfo=False).get(input_config.type, [])
|
||||
]
|
||||
|
||||
if len(candidates) > 1:
|
||||
# there is more than on input device which can be used for this
|
||||
# event we choose only one determined by the ranking
|
||||
return sorted(candidates, key=lambda d: ranking.index(classify(d)))[0]
|
||||
if len(candidates) == 1:
|
||||
return candidates.pop()
|
||||
|
||||
logger.error(f"Could not find input for {input_config}")
|
||||
return None
|
||||
|
||||
def _grab_devices(self) -> Dict[DeviceHash, evdev.InputDevice]:
|
||||
"""Grab all InputDevices that match a mappings' origin_hash."""
|
||||
# use a dict because the InputDevice is not directly hashable
|
||||
needed_devices = {}
|
||||
input_configs = set()
|
||||
|
||||
# find all unique input_config's
|
||||
for mapping in self.preset:
|
||||
for input_config in mapping.input_combination:
|
||||
input_configs.add(input_config)
|
||||
|
||||
# find all unique input_device's
|
||||
for input_config in input_configs:
|
||||
if not (device := self._find_input_device(input_config)):
|
||||
# there is no point in trying the fallback because
|
||||
# self._update_preset already did that.
|
||||
continue
|
||||
needed_devices[device.path] = device
|
||||
|
||||
grabbed_devices = {}
|
||||
for device in needed_devices.values():
|
||||
if device := self._grab_device(device):
|
||||
grabbed_devices[get_device_hash(device)] = device
|
||||
|
||||
return grabbed_devices
|
||||
|
||||
def _update_preset(self):
|
||||
"""Update all InputConfigs in the preset to include correct origin_hash
|
||||
information."""
|
||||
mappings_by_input = defaultdict(list)
|
||||
for mapping in self.preset:
|
||||
for input_config in mapping.input_combination:
|
||||
mappings_by_input[input_config].append(mapping)
|
||||
|
||||
for input_config in mappings_by_input:
|
||||
if self._find_input_device(input_config):
|
||||
continue
|
||||
|
||||
if not (device := self._find_input_device_fallback(input_config)):
|
||||
# fallback failed, this mapping will be ignored
|
||||
continue
|
||||
|
||||
for mapping in mappings_by_input[input_config]:
|
||||
combination: List[InputConfig] = list(mapping.input_combination)
|
||||
device_hash = get_device_hash(device)
|
||||
idx = combination.index(input_config)
|
||||
combination[idx] = combination[idx].modify(origin_hash=device_hash)
|
||||
mapping.input_combination = combination
|
||||
|
||||
def _grab_device(self, device: evdev.InputDevice) -> Optional[evdev.InputDevice]:
|
||||
"""Try to grab the device, return None if not possible.
|
||||
|
||||
Without grab, original events from it would reach the display server
|
||||
even though they are mapped.
|
||||
"""
|
||||
error = None
|
||||
for attempt in range(10):
|
||||
try:
|
||||
device.grab()
|
||||
logger.debug("Grab %s", device.path)
|
||||
return device
|
||||
except IOError as err:
|
||||
# it might take a little time until the device is free if
|
||||
# it was previously grabbed.
|
||||
error = err
|
||||
logger.debug("Failed attempts to grab %s: %d", device.path, attempt + 1)
|
||||
time.sleep(self.regrab_timeout)
|
||||
|
||||
logger.error("Cannot grab %s, it is possibly in use", device.path)
|
||||
logger.error(str(error))
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _copy_capabilities(input_device: evdev.InputDevice) -> CapabilitiesDict:
|
||||
"""Copy capabilities for a new device."""
|
||||
ecodes = evdev.ecodes
|
||||
|
||||
# copy the capabilities because the uinput is going
|
||||
# to act like the device.
|
||||
capabilities = input_device.capabilities(absinfo=True)
|
||||
|
||||
# just like what python-evdev does in from_device
|
||||
if ecodes.EV_SYN in capabilities:
|
||||
del capabilities[ecodes.EV_SYN]
|
||||
if ecodes.EV_FF in capabilities:
|
||||
del capabilities[ecodes.EV_FF]
|
||||
|
||||
if ecodes.ABS_VOLUME in capabilities.get(ecodes.EV_ABS, []):
|
||||
# For some reason an ABS_VOLUME capability likes to appear
|
||||
# for some users. It prevents mice from moving around and
|
||||
# keyboards from writing symbols
|
||||
capabilities[ecodes.EV_ABS].remove(ecodes.ABS_VOLUME)
|
||||
|
||||
return capabilities
|
||||
|
||||
async def _msg_listener(self) -> None:
|
||||
"""Wait for messages from the main process to do special stuff."""
|
||||
loop = asyncio.get_event_loop()
|
||||
while True:
|
||||
frame_available = asyncio.Event()
|
||||
loop.add_reader(self._msg_pipe[0].fileno(), frame_available.set)
|
||||
await frame_available.wait()
|
||||
frame_available.clear()
|
||||
msg = self._msg_pipe[0].recv()
|
||||
|
||||
if msg == InjectorCommand.CLOSE:
|
||||
await self._close()
|
||||
return
|
||||
|
||||
async def _close(self):
|
||||
logger.debug("Received close signal")
|
||||
self._stop_event.set()
|
||||
# give the event pipeline some time to reset devices
|
||||
# before shutting the loop down
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# stop the event loop and cause the process to reach its end
|
||||
# cleanly. Using .terminate prevents coverage from working.
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.stop()
|
||||
|
||||
self._msg_pipe[0].send(InjectorState.STOPPED)
|
||||
|
||||
def _create_forwarding_device(self, source: evdev.InputDevice) -> evdev.UInput:
|
||||
# copy as much information as possible, because libinput uses the extra
|
||||
# information to enable certain features like "Disable touchpad while
|
||||
# typing"
|
||||
try:
|
||||
forward_to = evdev.UInput(
|
||||
# Keep the original name as far as possible so system hwdb rules
|
||||
# can still match the virtual device and restore properties such
|
||||
# as MOUSE_DPI.
|
||||
name=get_forward_name(source.name),
|
||||
events=self._copy_capabilities(source),
|
||||
# Reusing source.phys causes our autoload rule to treat the
|
||||
# forwarded device as hardware. Prefix it so it stays
|
||||
# distinguishable while still carrying some source identity.
|
||||
phys=get_forward_phys(source),
|
||||
vendor=source.info.vendor,
|
||||
product=source.info.product,
|
||||
version=source.info.version,
|
||||
bustype=source.info.bustype,
|
||||
input_props=source.input_props(),
|
||||
)
|
||||
except TypeError as e:
|
||||
if "input_props" in str(e):
|
||||
# UInput constructor doesn't support input_props and
|
||||
# source.input_props doesn't exist with old python-evdev versions.
|
||||
logger.error("Please upgrade your python-evdev version. Exiting")
|
||||
self._msg_pipe[0].send(InjectorState.UPGRADE_EVDEV)
|
||||
sys.exit(12)
|
||||
|
||||
raise e
|
||||
return forward_to
|
||||
|
||||
def run(self) -> None:
|
||||
"""The injection worker that keeps injecting until terminated.
|
||||
|
||||
Stuff is non-blocking by using asyncio in order to do multiple things
|
||||
somewhat concurrently.
|
||||
|
||||
Use this function as starting point in a process. It creates
|
||||
the loops needed to read and map events and keeps running them.
|
||||
"""
|
||||
logger.info('Starting injecting the preset for "%s"', self.group.key)
|
||||
|
||||
# create a new event loop, because somehow running an infinite loop
|
||||
# that sleeps on iterations (joystick_to_mouse) in one process causes
|
||||
# another injection process to screw up reading from the grabbed
|
||||
# device.
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
self._devices = self.group.get_devices()
|
||||
|
||||
# InputConfigs may not contain the origin_hash information, this will try to
|
||||
# make a good guess if the origin_hash information is missing or invalid.
|
||||
self._update_preset()
|
||||
|
||||
# grab devices as early as possible. If events appear that won't get
|
||||
# released anymore before the grab they appear to be held down forever
|
||||
sources = self._grab_devices()
|
||||
forward_devices = {}
|
||||
for device_hash, device in sources.items():
|
||||
forward_devices[device_hash] = self._create_forwarding_device(device)
|
||||
|
||||
# create this within the process after the event loop creation,
|
||||
# so that the macros use the correct loop
|
||||
self.context = Context(
|
||||
self.preset,
|
||||
sources,
|
||||
forward_devices,
|
||||
self.mapping_parser,
|
||||
)
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
if len(sources) == 0:
|
||||
# maybe the preset was empty or something
|
||||
logger.error("Did not grab any device")
|
||||
self._msg_pipe[0].send(InjectorState.NO_GRAB)
|
||||
return
|
||||
|
||||
numlock_state = is_numlock_on()
|
||||
coroutines = []
|
||||
|
||||
for device_hash in sources:
|
||||
# actually doing things
|
||||
event_reader = EventReader(
|
||||
self.context,
|
||||
sources[device_hash],
|
||||
self._stop_event,
|
||||
)
|
||||
coroutines.append(event_reader.run())
|
||||
self._event_readers.append(event_reader)
|
||||
|
||||
coroutines.append(self._msg_listener())
|
||||
|
||||
# set the numlock state to what it was before injecting, because
|
||||
# grabbing devices screws this up
|
||||
set_numlock(numlock_state)
|
||||
|
||||
self._msg_pipe[0].send(InjectorState.RUNNING)
|
||||
|
||||
try:
|
||||
loop.run_until_complete(asyncio.gather(*coroutines))
|
||||
except RuntimeError as error:
|
||||
# the loop might have been stopped via a `CLOSE` message,
|
||||
# which causes the error message below. This is expected behavior
|
||||
if str(error) != "Event loop stopped before Future completed.":
|
||||
raise error
|
||||
except OSError as error:
|
||||
logger.error("Failed to run injector coroutines: %s", str(error))
|
||||
|
||||
if len(coroutines) > 0:
|
||||
# expected when stop_injecting is called,
|
||||
# during normal operation as well as tests this point is not
|
||||
# reached otherwise.
|
||||
logger.debug("Injector coroutines ended")
|
||||
|
||||
for source in sources.values():
|
||||
# ungrab at the end to make the next injection process not fail
|
||||
# its grabs
|
||||
try:
|
||||
source.ungrab()
|
||||
except OSError as error:
|
||||
# it might have disappeared
|
||||
logger.debug("OSError for ungrab on %s: %s", source.path, str(error))
|
||||
0
inputremapper/injection/macros/__init__.py
Normal file
0
inputremapper/injection/macros/__init__.py
Normal file
320
inputremapper/injection/macros/argument.py
Normal file
320
inputremapper/injection/macros/argument.py
Normal file
@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional, Any, Union, List, Literal, Type, TYPE_CHECKING
|
||||
|
||||
from evdev._ecodes import EV_KEY
|
||||
|
||||
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||
from inputremapper.configs.validation_errors import (
|
||||
MacroError,
|
||||
SymbolNotAvailableInTargetError,
|
||||
)
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
from inputremapper.injection.macros.variable import Variable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inputremapper.injection.macros.raw_value import RawValue
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
|
||||
|
||||
class ArgumentFlags(Enum):
|
||||
# No default value is set, and the user has to provide one when using the macro
|
||||
required = "required"
|
||||
|
||||
# If used, acts like foo(*bar)
|
||||
spread = "spread"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArgumentConfig:
|
||||
"""Definition what kind of arguments a task may take."""
|
||||
|
||||
position: Union[int, Literal[ArgumentFlags.spread]]
|
||||
name: str
|
||||
types: List[Optional[Type]]
|
||||
is_symbol: bool = False
|
||||
default: Any = ArgumentFlags.required
|
||||
|
||||
# If True, then the value (which should be a string), is the name of a non-constant
|
||||
# variable. Tasks that overwrite their value need this, like `set`. The specified
|
||||
# types are those that the current value of that variable may have. For `set` this
|
||||
# doesn't matter, but something like `add` requires them to be numbers.
|
||||
is_variable_name: bool = False
|
||||
|
||||
def is_required(self) -> bool:
|
||||
return self.default == ArgumentFlags.required
|
||||
|
||||
def is_spread(self):
|
||||
"""Does this Argument store all remaining Variables of a Task as a list?"""
|
||||
return self.position == ArgumentFlags.spread
|
||||
|
||||
|
||||
class Argument(ArgumentConfig):
|
||||
"""Validation of variables and access to their value for Tasks during runtime."""
|
||||
|
||||
_variable: Optional[Variable] = None
|
||||
|
||||
# If the position is set to ArgumentFlags.spread, then _variables will be filled
|
||||
# with all remaining positional arguments that were passed to a task.
|
||||
_variables: List[Variable]
|
||||
|
||||
_mapping: Optional[Mapping] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
argument_config: ArgumentConfig,
|
||||
mapping: Mapping,
|
||||
) -> None:
|
||||
# If a default of None is specified, but None is not an allowed type, then
|
||||
# input-remapper has a bug here. Add "None" to your ArgumentConfig.types
|
||||
assert not (
|
||||
argument_config.default is None and None not in argument_config.types
|
||||
)
|
||||
|
||||
self.position = argument_config.position
|
||||
self.name = argument_config.name
|
||||
self.types = argument_config.types
|
||||
self.is_symbol = argument_config.is_symbol
|
||||
self.default = argument_config.default
|
||||
self.is_variable_name = argument_config.is_variable_name
|
||||
|
||||
self._mapping = mapping
|
||||
self._variables = []
|
||||
|
||||
def initialize_variables(self, raw_values: List[RawValue]) -> None:
|
||||
"""If the macro is supposed to contain multiple variables, set them.
|
||||
Should be done during parsing."""
|
||||
assert len(self._variables) == 0
|
||||
assert self._variable is None
|
||||
assert self.is_spread()
|
||||
|
||||
for raw_value in raw_values:
|
||||
variable = self._parse_raw_value(raw_value)
|
||||
self._variables.append(variable)
|
||||
|
||||
def initialize_variable(self, raw_value: RawValue) -> None:
|
||||
"""Set the Arguments Variable. Done during parsing."""
|
||||
assert len(self._variables) == 0
|
||||
assert self._variable is None
|
||||
assert not self.is_spread()
|
||||
|
||||
variable = self._parse_raw_value(raw_value)
|
||||
self._variable = variable
|
||||
|
||||
def initialize_default(self) -> None:
|
||||
"""Set the Arguments to its default value. Done during parsing."""
|
||||
assert len(self._variables) == 0
|
||||
assert self._variable is None
|
||||
assert not self.is_spread()
|
||||
|
||||
variable = Variable(value=self.default, const=True)
|
||||
self._variable = variable
|
||||
|
||||
def get_value(self) -> Any:
|
||||
"""To ask for the current value of the variable during runtime."""
|
||||
assert not self.is_spread(), f"Use .{self.get_values.__name__}()"
|
||||
# If a user passed None as value, it should be a Variable(None, const=True) here.
|
||||
# If not, a test or input-remapper is broken.
|
||||
assert self._variable is not None
|
||||
|
||||
value = self._variable.get_value()
|
||||
|
||||
if not self._variable.const:
|
||||
# Dynamic value. Hasn't been validated yet
|
||||
value = self._validate_dynamic_value(self._variable)
|
||||
|
||||
return value
|
||||
|
||||
def get_values(self) -> List[Any]:
|
||||
"""To ask for the current values of the variables during runtime."""
|
||||
assert self.is_spread(), f"Use .{self.get_value.__name__}()"
|
||||
|
||||
values = []
|
||||
for variable in self._variables:
|
||||
if not variable.const:
|
||||
values.append(self._validate_dynamic_value(variable))
|
||||
else:
|
||||
values.append(variable.get_value())
|
||||
|
||||
return values
|
||||
|
||||
def get_variable_name(self) -> str:
|
||||
"""If the variable is not const, return its name."""
|
||||
assert self._variable is not None
|
||||
return self._variable.get_name()
|
||||
|
||||
def contains_macro(self) -> bool:
|
||||
"""Does the underlying Variable contain another child-macro?"""
|
||||
assert self._variable is not None
|
||||
return isinstance(self._variable.get_value(), Macro)
|
||||
|
||||
def set_value(self, value: Any) -> Any:
|
||||
"""To set the value of the underlying Variable during runtime.
|
||||
Fails for constants."""
|
||||
assert self._variable is not None
|
||||
if self._variable.const:
|
||||
raise Exception("Can't set value of a constant")
|
||||
|
||||
self._variable.set_value(value)
|
||||
|
||||
def assert_is_symbol(self, symbol: str) -> None:
|
||||
"""Checks if the key/symbol-name is valid. Like "KEY_A" or "escape".
|
||||
|
||||
Using `is_symbol` on the ArgumentConfig is prefered, which causes it to
|
||||
automatically do this for you. But some macros may be a bit more flexible,
|
||||
and there we want to assert this ourselves only in certain cases."""
|
||||
symbol = str(symbol)
|
||||
code = keyboard_layout.get(symbol)
|
||||
|
||||
if code is None:
|
||||
raise MacroError(msg=f'Unknown key "{symbol}"')
|
||||
|
||||
if self._mapping is not None:
|
||||
target = self._mapping.target_uinput
|
||||
if target is not None and not GlobalUInputs.can_default_uinput_emit(
|
||||
target, EV_KEY, code
|
||||
):
|
||||
raise SymbolNotAvailableInTargetError(symbol, target)
|
||||
|
||||
def _parse_raw_value(self, raw_value: RawValue) -> Variable:
|
||||
"""Validate and parse."""
|
||||
value = raw_value.value
|
||||
|
||||
# The order of steps below matters.
|
||||
|
||||
if isinstance(value, Macro):
|
||||
return Variable(value=value, const=True)
|
||||
|
||||
if self.is_variable_name:
|
||||
# Treat this as a non-constant variable,
|
||||
# even without a `$` in front of its name
|
||||
if value.startswith('"'):
|
||||
# Remove quotes from the string
|
||||
value = value[1:-1]
|
||||
return Variable(value=value, const=False)
|
||||
|
||||
if value.startswith("$"):
|
||||
# Will be resolved during the macros runtime
|
||||
return Variable(value=value[1:], const=False)
|
||||
|
||||
if self.is_symbol:
|
||||
if value.startswith('"'):
|
||||
value = value[1:-1]
|
||||
self.assert_is_symbol(value)
|
||||
return Variable(value=value, const=True)
|
||||
|
||||
if (value == "" or value == "None") and None in self.types:
|
||||
# I think "" is the deprecated alternative to "None"
|
||||
return Variable(value=None, const=True)
|
||||
|
||||
if value.startswith('"') and str in self.types:
|
||||
# Something with explicit quotes should never be parsed as a number.
|
||||
# Treat it as a string no matter the content.
|
||||
value = value[1:-1]
|
||||
return Variable(value=value, const=True)
|
||||
|
||||
if float in self.types and "." in value:
|
||||
try:
|
||||
return Variable(value=float(value), const=True)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if int in self.types:
|
||||
try:
|
||||
return Variable(value=int(value), const=True)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if not value.startswith('"') and ("(" in value or ")" in value):
|
||||
# Looks like something that should have been a macro. It is not explicitly
|
||||
# wrapped in quotes. Most likely an error. If it was a valid macro, the
|
||||
# parser would have parsed it as such.
|
||||
raise MacroError(
|
||||
msg=f"A broken macro was passed as parameter to {self.name}"
|
||||
)
|
||||
|
||||
if str in self.types:
|
||||
# Treat as a string. Something like KEY_A in key(KEY_A)
|
||||
return Variable(value=value, const=True)
|
||||
|
||||
raise self._type_error_factory(value)
|
||||
|
||||
def _validate_dynamic_value(self, variable: Variable) -> Any:
|
||||
"""To make sure the value of a non-const variable, asked for at runtime, is
|
||||
fitting for the given ArgumentConfig."""
|
||||
# Most of the stuff has already been taken care of when, for example,
|
||||
# the "1" of set(foo, 1), or the '"bar"' or set(foo, "bar") was parsed the
|
||||
# first time. In the first case we get a number 1, and in the second a string
|
||||
# `bar` without quotes
|
||||
assert not variable.const
|
||||
value = variable.get_value()
|
||||
|
||||
if self.is_symbol:
|
||||
# value might be int `1`, which is a valid symbol for `key(1)`
|
||||
value = str(value)
|
||||
self.assert_is_symbol(value)
|
||||
return value
|
||||
|
||||
if None in self.types and value is None:
|
||||
return value
|
||||
|
||||
if type(value) in self.types:
|
||||
return value
|
||||
|
||||
if type(value) not in self.types and str in self.types:
|
||||
# `set` cannot make predictions where the variable will be used. Make sure
|
||||
# the type is compatible, and turn numbers back into strings if need be.
|
||||
return str(value)
|
||||
|
||||
# If the value is "1", we don't attempt to parse it as a number. This being a
|
||||
# string means that something like `set(foo, "1")` was used, which enforces a
|
||||
# string datatype. Otherwise, `set` would have already turned it into an int.
|
||||
|
||||
raise self._type_error_factory(value)
|
||||
|
||||
def _is_numeric_string(self, value: str) -> bool:
|
||||
"""Check if the value can be turned into a number."""
|
||||
try:
|
||||
float(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _type_error_factory(self, value: Any) -> MacroError:
|
||||
formatted_types: List[str] = []
|
||||
|
||||
for type_ in self.types:
|
||||
if type_ is None:
|
||||
formatted_types.append("None")
|
||||
else:
|
||||
formatted_types.append(type_.__name__)
|
||||
|
||||
return MacroError(
|
||||
msg=(
|
||||
f'Expected "{self.name}" to be one of {formatted_types}, but got '
|
||||
f'{type(value).__name__} "{value}"'
|
||||
)
|
||||
)
|
||||
126
inputremapper/injection/macros/macro.py
Normal file
126
inputremapper/injection/macros/macro.py
Normal file
@ -0,0 +1,126 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""Executes more complex patterns of keystrokes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import List, Callable, Optional, TYPE_CHECKING
|
||||
|
||||
from inputremapper.ipc.shared_dict import SharedDict
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inputremapper.injection.macros.task import Task
|
||||
from inputremapper.injection.context import Context
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
|
||||
InjectEventCallback = Callable[[int, int, int], None]
|
||||
|
||||
macro_variables = SharedDict()
|
||||
|
||||
|
||||
class Macro:
|
||||
"""Chains tasks (like `modify` or `repeat`).
|
||||
|
||||
Tasks may have child_macros. Running a Macro runs Tasks, which in turn may run
|
||||
their child_macros based on certain conditions (depending on the Task).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: Optional[str],
|
||||
context: Optional[Context] = None,
|
||||
mapping: Optional[Mapping] = None,
|
||||
):
|
||||
"""Create a macro instance that can be populated with tasks.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
code
|
||||
The original parsed code, for logging purposes.
|
||||
context : Context
|
||||
mapping : UIMapping
|
||||
"""
|
||||
self.code = code
|
||||
self.context = context
|
||||
self.mapping = mapping
|
||||
|
||||
# List of coroutines that will be called sequentially.
|
||||
# This is the compiled code
|
||||
self.tasks: List[Task] = []
|
||||
|
||||
self.running = False
|
||||
|
||||
self.keystroke_sleep_ms = None
|
||||
|
||||
async def run(self, callback: InjectEventCallback):
|
||||
"""Run the macro.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
callback
|
||||
Will receive int type, code and value for an event to write
|
||||
"""
|
||||
if not callable(callback):
|
||||
raise ValueError("handler is not callable")
|
||||
|
||||
if self.running:
|
||||
logger.error('Tried to run already running macro "%s"', self.code)
|
||||
return
|
||||
|
||||
self.keystroke_sleep_ms = self.mapping.macro_key_sleep_ms
|
||||
|
||||
self.running = True
|
||||
|
||||
try:
|
||||
for task in self.tasks:
|
||||
coroutine = task.run(callback)
|
||||
if asyncio.iscoroutine(coroutine):
|
||||
await coroutine
|
||||
except Exception:
|
||||
raise
|
||||
finally:
|
||||
# done
|
||||
self.running = False
|
||||
|
||||
def press_trigger(self):
|
||||
"""The user pressed the trigger key down."""
|
||||
for task in self.tasks:
|
||||
task.press_trigger()
|
||||
|
||||
def release_trigger(self):
|
||||
"""The user released the trigger key."""
|
||||
for task in self.tasks:
|
||||
task.release_trigger()
|
||||
|
||||
async def _keycode_pause(self, _=None):
|
||||
"""To add a pause between keystrokes.
|
||||
|
||||
This was needed at some point because it appeared that injecting keys too
|
||||
fast will prevent them from working. It probably depends on the environment.
|
||||
"""
|
||||
await asyncio.sleep(self.keystroke_sleep_ms / 1000)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Macro "{self.code}" at {hex(id(self))}>'
|
||||
|
||||
def add_task(self, task):
|
||||
self.tasks.append(task)
|
||||
476
inputremapper/injection/macros/parse.py
Normal file
476
inputremapper/injection/macros/parse.py
Normal file
@ -0,0 +1,476 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Parse macro code"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional, Any, Type, TYPE_CHECKING, Dict, List
|
||||
|
||||
from inputremapper.configs.validation_errors import MacroError
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
from inputremapper.injection.macros.raw_value import RawValue
|
||||
from inputremapper.injection.macros.task import Task
|
||||
from inputremapper.injection.macros.tasks.add import AddTask
|
||||
from inputremapper.injection.macros.tasks.event import EventTask
|
||||
from inputremapper.injection.macros.tasks.hold import HoldTask
|
||||
from inputremapper.injection.macros.tasks.hold_keys import HoldKeysTask
|
||||
from inputremapper.injection.macros.tasks.if_eq import IfEqTask
|
||||
from inputremapper.injection.macros.tasks.if_led import IfNumlockTask, IfCapslockTask
|
||||
from inputremapper.injection.macros.tasks.if_single import IfSingleTask
|
||||
from inputremapper.injection.macros.tasks.if_tap import IfTapTask
|
||||
from inputremapper.injection.macros.tasks.ifeq import DeprecatedIfEqTask
|
||||
from inputremapper.injection.macros.tasks.key import KeyTask
|
||||
from inputremapper.injection.macros.tasks.key_down import KeyDownTask
|
||||
from inputremapper.injection.macros.tasks.key_up import KeyUpTask
|
||||
from inputremapper.injection.macros.tasks.mod_tap import ModTapTask
|
||||
from inputremapper.injection.macros.tasks.modify import ModifyTask
|
||||
from inputremapper.injection.macros.tasks.mouse import MouseTask
|
||||
from inputremapper.injection.macros.tasks.parallel import ParallelTask
|
||||
from inputremapper.injection.macros.tasks.mouse_xy import MouseXYTask
|
||||
from inputremapper.injection.macros.tasks.repeat import RepeatTask
|
||||
from inputremapper.injection.macros.tasks.set import SetTask
|
||||
from inputremapper.injection.macros.tasks.toggle import ToggleTask
|
||||
from inputremapper.injection.macros.tasks.wait import WaitTask
|
||||
from inputremapper.injection.macros.tasks.wheel import WheelTask
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inputremapper.injection.context import Context
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
|
||||
|
||||
class Parser:
|
||||
TASK_CLASSES: dict[str, type[Task]] = {
|
||||
"modify": ModifyTask,
|
||||
"repeat": RepeatTask,
|
||||
"toggle": ToggleTask,
|
||||
"key": KeyTask,
|
||||
"key_down": KeyDownTask,
|
||||
"key_up": KeyUpTask,
|
||||
"event": EventTask,
|
||||
"wait": WaitTask,
|
||||
"hold": HoldTask,
|
||||
"hold_keys": HoldKeysTask,
|
||||
"mouse": MouseTask,
|
||||
"mouse_xy": MouseXYTask,
|
||||
"wheel": WheelTask,
|
||||
"if_eq": IfEqTask,
|
||||
"if_numlock": IfNumlockTask,
|
||||
"if_capslock": IfCapslockTask,
|
||||
"set": SetTask,
|
||||
"if_tap": IfTapTask,
|
||||
"if_single": IfSingleTask,
|
||||
"add": AddTask,
|
||||
"mod_tap": ModTapTask,
|
||||
"parallel": ParallelTask,
|
||||
# Those are only kept for backwards compatibility with old macros. The space for
|
||||
# writing macro was very constrained in the past, so shorthands were introduced:
|
||||
"m": ModifyTask,
|
||||
"r": RepeatTask,
|
||||
"k": KeyTask,
|
||||
"e": EventTask,
|
||||
"w": WaitTask,
|
||||
"h": HoldTask,
|
||||
# It was not possible to adjust ifeq to support variables without breaking old
|
||||
# macros, so this function is deprecated and if_eq introduced. Kept for backwards
|
||||
# compatibility:
|
||||
"ifeq": DeprecatedIfEqTask,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def is_this_a_macro(output: Any):
|
||||
"""Figure out if this is a macro."""
|
||||
if not isinstance(output, str):
|
||||
return False
|
||||
|
||||
if "+" in output.strip():
|
||||
# for example "a + b"
|
||||
return True
|
||||
|
||||
return "(" in output and ")" in output and len(output) >= 4
|
||||
|
||||
@staticmethod
|
||||
def _extract_args(inner: str):
|
||||
"""Extract parameters from the inner contents of a call.
|
||||
|
||||
This does not parse them.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
inner
|
||||
for example '1, r, r(2, k(a))' should result in ['1', 'r', 'r(2, k(a))']
|
||||
"""
|
||||
inner = inner.strip()
|
||||
brackets = 0
|
||||
params = []
|
||||
start = 0
|
||||
string = False
|
||||
for position, char in enumerate(inner):
|
||||
# ignore anything between string quotes
|
||||
if char == '"':
|
||||
string = not string
|
||||
if string:
|
||||
continue
|
||||
|
||||
# ignore commas inside child macros
|
||||
if char == "(":
|
||||
brackets += 1
|
||||
if char == ")":
|
||||
brackets -= 1
|
||||
if char == "," and brackets == 0:
|
||||
# , potentially starts another parameter, but only if
|
||||
# the current brackets are all closed.
|
||||
params.append(inner[start:position].strip())
|
||||
# skip the comma
|
||||
start = position + 1
|
||||
|
||||
# one last parameter
|
||||
params.append(inner[start:].strip())
|
||||
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _count_brackets(macro):
|
||||
"""Find where the first opening bracket closes."""
|
||||
openings = macro.count("(")
|
||||
closings = macro.count(")")
|
||||
if openings != closings:
|
||||
raise MacroError(
|
||||
macro, f"Found {openings} opening and {closings} closing brackets"
|
||||
)
|
||||
|
||||
brackets = 0
|
||||
position = 0
|
||||
for char in macro:
|
||||
position += 1
|
||||
if char == "(":
|
||||
brackets += 1
|
||||
continue
|
||||
|
||||
if char == ")":
|
||||
brackets -= 1
|
||||
if brackets == 0:
|
||||
# the closing bracket of the call
|
||||
break
|
||||
|
||||
return position
|
||||
|
||||
@staticmethod
|
||||
def _split_keyword_arg(param):
|
||||
"""Split "foo=bar" into "foo" and "bar".
|
||||
|
||||
If not a keyward param, return None and the param.
|
||||
"""
|
||||
if re.match(r"[a-zA-Z_][a-zA-Z_\d]*=.+", param):
|
||||
split = param.split("=", 1)
|
||||
return split[0], split[1]
|
||||
|
||||
return None, param
|
||||
|
||||
@staticmethod
|
||||
def _validate_keyword_argument_names(
|
||||
keyword_args: Dict[str, Any],
|
||||
task_class: Type[Task],
|
||||
) -> None:
|
||||
for keyword_arg in keyword_args:
|
||||
for argument in task_class.argument_configs:
|
||||
if argument.name == keyword_arg:
|
||||
break
|
||||
else:
|
||||
raise MacroError(msg=f"Unknown keyword argument {keyword_arg}")
|
||||
|
||||
@staticmethod
|
||||
def _parse_recurse(
|
||||
code: str,
|
||||
context: Optional[Context],
|
||||
mapping: Mapping,
|
||||
verbose: bool,
|
||||
macro_instance: Optional[Macro] = None,
|
||||
depth: int = 0,
|
||||
) -> RawValue:
|
||||
"""Handle a subset of the macro, e.g. one parameter or function call.
|
||||
|
||||
Not using eval for security reasons.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
code
|
||||
Just like parse. A single parameter or the complete macro as string.
|
||||
Comments and redundant whitespace characters are expected to be removed already.
|
||||
Example:
|
||||
- "parallel(key(a),key(b).key($foo))"
|
||||
- "key(a)"
|
||||
- "a"
|
||||
- "key(b).key($foo)"
|
||||
- "b"
|
||||
- "key($foo)"
|
||||
- "$foo"
|
||||
context : Context
|
||||
macro_instance
|
||||
A macro instance to add tasks to. This is the output of the parser, and is
|
||||
organized like a tree.
|
||||
depth
|
||||
For logging porposes
|
||||
"""
|
||||
assert isinstance(code, str)
|
||||
assert isinstance(depth, int)
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
if verbose:
|
||||
logger.debug(*args, **kwargs)
|
||||
|
||||
space = " " * depth
|
||||
|
||||
code = code.strip()
|
||||
|
||||
# is it another macro?
|
||||
task_call_match = re.match(r"^(\w+)\(", code)
|
||||
task_name = task_call_match[1] if task_call_match else None
|
||||
|
||||
if task_name is None:
|
||||
# It is probably either a key name like KEY_A or a variable name as in `set(var,1)`,
|
||||
# both won't contain special characters that can break macro syntax so they don't
|
||||
# have to be wrapped in quotes. The argument configuration of the tasks will
|
||||
# detemrine how to parse it.
|
||||
debug("%svalue %s", space, code)
|
||||
return RawValue(value=code)
|
||||
|
||||
if macro_instance is None:
|
||||
# start a new chain
|
||||
macro_instance = Macro(code, context, mapping)
|
||||
else:
|
||||
# chain this call to the existing instance
|
||||
assert isinstance(macro_instance, Macro)
|
||||
|
||||
task_class = Parser.TASK_CLASSES.get(task_name)
|
||||
if task_class is None:
|
||||
raise MacroError(code, f"Unknown function {task_name}")
|
||||
|
||||
# get all the stuff inbetween
|
||||
closing_bracket_position = Parser._count_brackets(code) - 1
|
||||
inner = code[code.index("(") + 1 : closing_bracket_position]
|
||||
debug("%scalls %s with %s", space, task_name, inner)
|
||||
|
||||
# split "3, foo=a(2, k(a).w(10))" into arguments
|
||||
raw_string_args = Parser._extract_args(inner)
|
||||
|
||||
# parse and sort the params
|
||||
positional_args: List[RawValue] = []
|
||||
keyword_args: Dict[str, RawValue] = {}
|
||||
for param in raw_string_args:
|
||||
key, value = Parser._split_keyword_arg(param)
|
||||
parsed = Parser._parse_recurse(
|
||||
value.strip(),
|
||||
context,
|
||||
mapping,
|
||||
verbose,
|
||||
None,
|
||||
depth + 1,
|
||||
)
|
||||
if key is None:
|
||||
if len(keyword_args) > 0:
|
||||
msg = f'Positional argument "{key}" follows keyword argument'
|
||||
raise MacroError(code, msg)
|
||||
positional_args.append(parsed)
|
||||
else:
|
||||
if key in keyword_args:
|
||||
raise MacroError(code, f'The "{key}" argument was specified twice')
|
||||
keyword_args[key] = parsed
|
||||
|
||||
debug(
|
||||
"%sadd call to %s with %s, %s",
|
||||
space,
|
||||
task_name,
|
||||
positional_args,
|
||||
keyword_args,
|
||||
)
|
||||
|
||||
Parser._validate_keyword_argument_names(
|
||||
keyword_args,
|
||||
task_class,
|
||||
)
|
||||
Parser._validate_num_args(
|
||||
code,
|
||||
task_name,
|
||||
task_class,
|
||||
raw_string_args,
|
||||
)
|
||||
|
||||
try:
|
||||
task = task_class(
|
||||
positional_args,
|
||||
keyword_args,
|
||||
context,
|
||||
mapping,
|
||||
)
|
||||
macro_instance.add_task(task)
|
||||
except TypeError as exception:
|
||||
raise MacroError(msg=str(exception)) from exception
|
||||
|
||||
# is after this another call? Chain it to the macro_instance
|
||||
more_code_exists = len(code) > closing_bracket_position + 1
|
||||
if more_code_exists:
|
||||
next_char = code[closing_bracket_position + 1]
|
||||
statement_closed = next_char == "."
|
||||
|
||||
if statement_closed:
|
||||
# skip over the ")."
|
||||
chain = code[closing_bracket_position + 2 :]
|
||||
debug("%sfollowed by %s", space, chain)
|
||||
Parser._parse_recurse(
|
||||
chain,
|
||||
context,
|
||||
mapping,
|
||||
verbose,
|
||||
macro_instance,
|
||||
depth,
|
||||
)
|
||||
elif re.match(r"[a-zA-Z_]", next_char):
|
||||
# something like foo()bar
|
||||
raise MacroError(
|
||||
code,
|
||||
f'Expected a "." to follow after '
|
||||
f"{code[:closing_bracket_position + 1]}",
|
||||
)
|
||||
|
||||
return RawValue(value=macro_instance)
|
||||
|
||||
@staticmethod
|
||||
def _validate_num_args(
|
||||
code: str,
|
||||
task_name: str,
|
||||
task_class: Type[Task],
|
||||
raw_string_args: List[str],
|
||||
) -> None:
|
||||
min_args, max_args = task_class.get_num_parameters()
|
||||
num_provided_args = len(raw_string_args)
|
||||
if num_provided_args < min_args or num_provided_args > max_args:
|
||||
if min_args != max_args:
|
||||
msg = (
|
||||
f"{task_name} takes between {min_args} and {max_args}, "
|
||||
f"not {num_provided_args} parameters"
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
f"{task_name} takes {min_args}, not {num_provided_args} parameters"
|
||||
)
|
||||
|
||||
raise MacroError(code, msg)
|
||||
|
||||
@staticmethod
|
||||
def handle_plus_syntax(macro):
|
||||
"""Transform a + b + c to hold_keys(a,b,c)."""
|
||||
if "+" not in macro:
|
||||
return macro
|
||||
|
||||
if "(" in macro or ")" in macro:
|
||||
raise MacroError(macro, f'Mixing "+" and macros is unsupported: "{ macro}"')
|
||||
|
||||
chunks = [chunk.strip() for chunk in macro.split("+")]
|
||||
|
||||
if "" in chunks:
|
||||
raise MacroError(f'Invalid syntax for "{macro}"')
|
||||
|
||||
output = f"hold_keys({','.join(chunks)})"
|
||||
|
||||
logger.debug('Transformed "%s" to "%s"', macro, output)
|
||||
return output
|
||||
|
||||
@staticmethod
|
||||
def remove_whitespaces(macro, delimiter='"'):
|
||||
"""Remove whitespaces, tabs, newlines and such outside of string quotes."""
|
||||
result = ""
|
||||
for i, chunk in enumerate(macro.split(delimiter)):
|
||||
# every second chunk is inside string quotes
|
||||
if i % 2 == 0:
|
||||
result += re.sub(r"\s", "", chunk)
|
||||
else:
|
||||
result += chunk
|
||||
result += delimiter
|
||||
|
||||
# one extra delimiter was added
|
||||
return result[: -len(delimiter)]
|
||||
|
||||
@staticmethod
|
||||
def remove_comments(macro):
|
||||
"""Remove comments from the macro and return the resulting code."""
|
||||
# keep hashtags inside quotes intact
|
||||
result = ""
|
||||
|
||||
for i, line in enumerate(macro.split("\n")):
|
||||
for j, chunk in enumerate(line.split('"')):
|
||||
if j > 0:
|
||||
# add back the string quote
|
||||
chunk = f'"{chunk}'
|
||||
|
||||
# every second chunk is inside string quotes
|
||||
if j % 2 == 0 and "#" in chunk:
|
||||
# everything from now on is a comment and can be ignored
|
||||
result += chunk.split("#")[0]
|
||||
break
|
||||
else:
|
||||
result += chunk
|
||||
|
||||
if i < macro.count("\n"):
|
||||
result += "\n"
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def clean(code):
|
||||
"""Remove everything irrelevant for the macro."""
|
||||
return Parser.remove_whitespaces(
|
||||
Parser.remove_comments(code),
|
||||
'"',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse(macro: str, context=None, mapping=None, verbose: bool = True) -> Macro:
|
||||
"""Parse and generate a Macro that can be run as often as you want.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
macro
|
||||
"repeat(3, key(a).wait(10))"
|
||||
"repeat(2, key(a).key(KEY_A)).key(b)"
|
||||
"wait(1000).modify(Shift_L, repeat(2, k(a))).wait(10, 20).key(b)"
|
||||
context : Context, or None for use in Frontend
|
||||
mapping
|
||||
the mapping for the macro, or None for use in Frontend
|
||||
verbose
|
||||
log the parsing True by default
|
||||
"""
|
||||
# TODO pass mapping in frontend and do the target check for keys?
|
||||
logger.debug("parsing macro %s", macro.replace("\n", ""))
|
||||
macro = Parser.clean(macro)
|
||||
macro = Parser.handle_plus_syntax(macro)
|
||||
|
||||
macro_obj = Parser._parse_recurse(
|
||||
macro,
|
||||
context,
|
||||
mapping,
|
||||
verbose,
|
||||
).value
|
||||
if not isinstance(macro_obj, Macro):
|
||||
raise MacroError(macro, "The provided code was not a macro")
|
||||
|
||||
return macro_obj
|
||||
35
inputremapper/injection/macros/raw_value.py
Normal file
35
inputremapper/injection/macros/raw_value.py
Normal file
@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Union
|
||||
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
|
||||
|
||||
@dataclass
|
||||
class RawValue:
|
||||
"""Store a value exactly as it is in the macro-code. Values still have to be
|
||||
validated according to the ArgumentConfig of the Tasks.
|
||||
|
||||
Child-macros are passed as Macro objects though.
|
||||
"""
|
||||
|
||||
value: Union[str, Macro]
|
||||
248
inputremapper/injection/macros/task.py
Normal file
248
inputremapper/injection/macros/task.py
Normal file
@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from itertools import chain
|
||||
from typing import List, Dict, TYPE_CHECKING, Optional, Tuple, Union
|
||||
|
||||
from inputremapper.configs.validation_errors import MacroError
|
||||
from inputremapper.injection.macros.argument import (
|
||||
Argument,
|
||||
ArgumentConfig,
|
||||
ArgumentFlags,
|
||||
)
|
||||
from inputremapper.injection.macros.macro import Macro, InjectEventCallback
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import EventListener
|
||||
from inputremapper.injection.macros.raw_value import RawValue
|
||||
from inputremapper.injection.context import Context
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
|
||||
|
||||
class Task:
|
||||
"""Base Class for functions like `if_eq` or `key`
|
||||
|
||||
A macro like `key(a).key(b)` will contain two instances of this class.
|
||||
"""
|
||||
|
||||
argument_configs: List[ArgumentConfig]
|
||||
arguments: Dict[str, Argument]
|
||||
mapping: Mapping
|
||||
|
||||
# The context is None during frontend-parsing/validation I believe
|
||||
context: Optional[Context]
|
||||
|
||||
child_macros: List[Macro]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
positional_args: List[RawValue],
|
||||
keyword_args: Dict[str, RawValue],
|
||||
context: Optional[Context],
|
||||
mapping: Mapping,
|
||||
) -> None:
|
||||
self.context = context
|
||||
self.mapping = mapping
|
||||
self.child_macros = []
|
||||
|
||||
self._validate_argument_configs()
|
||||
|
||||
self.arguments = {
|
||||
argument_config.name: Argument(argument_config, mapping)
|
||||
for argument_config in self.argument_configs
|
||||
}
|
||||
|
||||
self._setup_asyncio_events()
|
||||
|
||||
for argument in self.arguments.values():
|
||||
self._initialize_argument(argument, keyword_args, positional_args)
|
||||
|
||||
self._initialize_spread_arg(positional_args)
|
||||
|
||||
for raw_value in chain(keyword_args.values(), positional_args):
|
||||
if isinstance(raw_value.value, Macro):
|
||||
self.child_macros.append(raw_value.value)
|
||||
|
||||
async def run(self, callback: InjectEventCallback) -> None:
|
||||
"""Macro logic goes here.
|
||||
|
||||
Call the callback with the type, code and value that should be injected.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def add_event_listener(self, listener: EventListener) -> None:
|
||||
"""Listeners get each event from the source device.
|
||||
|
||||
After all listeners are done, the event will go into the mapping handlers.
|
||||
|
||||
Make sure to remove your event_listener once you are done.
|
||||
"""
|
||||
# The context will be there when the macro is parsed by the service
|
||||
assert self.context is not None
|
||||
self.context.listeners.add(listener)
|
||||
|
||||
def remove_event_listener(self, listener: EventListener) -> None:
|
||||
assert self.context is not None
|
||||
self.context.listeners.remove(listener)
|
||||
|
||||
@classmethod
|
||||
def get_macro_argument_names(cls):
|
||||
return [argument_config.name for argument_config in cls.argument_configs]
|
||||
|
||||
@classmethod
|
||||
def get_num_parameters(cls) -> Tuple[int, Union[int, float]]:
|
||||
"""Get the number of required parameters and the maximum number of parameters."""
|
||||
min_num_args = 0
|
||||
argument_configs = cls.argument_configs
|
||||
max_num_args: Union[int, float] = len(argument_configs)
|
||||
for argument_config in argument_configs:
|
||||
if argument_config.position == ArgumentFlags.spread:
|
||||
# 0 or more
|
||||
max_num_args = float("inf")
|
||||
continue
|
||||
|
||||
if argument_config.is_required():
|
||||
min_num_args += 1
|
||||
|
||||
return min_num_args, max_num_args
|
||||
|
||||
def get_argument(self, argument_name) -> Argument:
|
||||
return self.arguments[argument_name]
|
||||
|
||||
def press_trigger(self) -> None:
|
||||
"""The user pressed the trigger key down."""
|
||||
for macro in self.child_macros:
|
||||
macro.press_trigger()
|
||||
|
||||
if self.is_holding():
|
||||
logger.error("Already holding")
|
||||
return
|
||||
|
||||
self._trigger_release_event.clear()
|
||||
self._trigger_press_event.set()
|
||||
|
||||
def release_trigger(self) -> None:
|
||||
"""The user released the trigger key."""
|
||||
if not self.is_holding():
|
||||
return
|
||||
|
||||
self._trigger_release_event.set()
|
||||
self._trigger_press_event.clear()
|
||||
|
||||
for macro in self.child_macros:
|
||||
macro.release_trigger()
|
||||
|
||||
def is_holding(self) -> bool:
|
||||
"""Check if the macro is waiting for a key to be released."""
|
||||
return not self._trigger_release_event.is_set()
|
||||
|
||||
async def keycode_pause(self, _=None) -> None:
|
||||
"""To add a pause between keystrokes.
|
||||
|
||||
This was needed at some point because it appeared that injecting keys too
|
||||
fast will prevent them from working. It probably depends on the environment.
|
||||
"""
|
||||
await asyncio.sleep(self.mapping.macro_key_sleep_ms / 1000)
|
||||
|
||||
def _initialize_spread_arg(
|
||||
self,
|
||||
positional_args: List[RawValue],
|
||||
) -> None:
|
||||
"""Put all positional arguments that aren't used into the spread argument."""
|
||||
spread_argument: Optional[Argument] = None
|
||||
for argument in self.arguments.values():
|
||||
if argument.position == ArgumentFlags.spread:
|
||||
spread_argument = argument
|
||||
break
|
||||
|
||||
if spread_argument is None:
|
||||
return
|
||||
|
||||
remaining_positional_args = [*positional_args]
|
||||
|
||||
for argument in self.arguments.values():
|
||||
if argument.position != ArgumentFlags.spread and argument.position < len(
|
||||
remaining_positional_args
|
||||
):
|
||||
del remaining_positional_args[argument.position]
|
||||
|
||||
spread_argument.initialize_variables(remaining_positional_args)
|
||||
|
||||
def _find_argument_by_position(self, position: int) -> Optional[Argument]:
|
||||
for argument in self.arguments.values():
|
||||
if argument.position == position:
|
||||
return argument
|
||||
|
||||
return None
|
||||
|
||||
def _setup_asyncio_events(self) -> None:
|
||||
# Can be used to wait for the press and release of the input event/key, that is
|
||||
# configured as the trigger of the macro, via asyncio.
|
||||
self._trigger_release_event = asyncio.Event()
|
||||
self._trigger_press_event = asyncio.Event()
|
||||
# released by default
|
||||
self._trigger_release_event.set()
|
||||
self._trigger_press_event.clear()
|
||||
|
||||
def _initialize_argument(
|
||||
self,
|
||||
argument: Argument,
|
||||
keyword_args: Dict[str, RawValue],
|
||||
positional_args: List[RawValue],
|
||||
) -> None:
|
||||
if argument.position == ArgumentFlags.spread:
|
||||
# Will get all the remaining positional arguments afterward.
|
||||
return
|
||||
|
||||
for name, value in keyword_args.items():
|
||||
if argument.name == name:
|
||||
argument.initialize_variable(value)
|
||||
return
|
||||
|
||||
if argument.position < len(positional_args):
|
||||
argument.initialize_variable(positional_args[argument.position])
|
||||
return
|
||||
|
||||
if not argument.is_required():
|
||||
argument.initialize_default()
|
||||
return
|
||||
|
||||
# This shouldn't be possible, the parser should have ensured things are valid
|
||||
# already.
|
||||
raise MacroError(f"Could not initialize argument {argument.name}")
|
||||
|
||||
def _validate_argument_configs(self):
|
||||
# Might help during development
|
||||
positions = set()
|
||||
names = set()
|
||||
for argument_config in self.argument_configs:
|
||||
position = argument_config.position
|
||||
if position in positions:
|
||||
raise MacroError(f"Duplicate position {positions} in ArgumentConfig")
|
||||
positions.add(position)
|
||||
|
||||
name = argument_config.name
|
||||
if name in names:
|
||||
raise MacroError(f"Duplicate name {name} in ArgumentConfig")
|
||||
names.add(name)
|
||||
0
inputremapper/injection/macros/tasks/__init__.py
Normal file
0
inputremapper/injection/macros/tasks/__init__.py
Normal file
72
inputremapper/injection/macros/tasks/add.py
Normal file
72
inputremapper/injection/macros/tasks/add.py
Normal file
@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from inputremapper.configs.validation_errors import MacroError
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.task import Task
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class AddTask(Task):
|
||||
"""Add a number to a variable."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="variable",
|
||||
position=0,
|
||||
types=[int, float, None],
|
||||
is_variable_name=True,
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="value",
|
||||
position=1,
|
||||
types=[int, float],
|
||||
),
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
argument = self.get_argument("variable")
|
||||
try:
|
||||
current = argument.get_value()
|
||||
except MacroError:
|
||||
return
|
||||
|
||||
if current is None:
|
||||
logger.debug(
|
||||
'"%s" initialized with 0',
|
||||
self.arguments["variable"].get_variable_name(),
|
||||
)
|
||||
argument.set_value(0)
|
||||
current = 0
|
||||
|
||||
addend = self.get_argument("value").get_value()
|
||||
|
||||
if not isinstance(current, (int, float)):
|
||||
logger.error(
|
||||
'Expected variable "%s" to contain a number, but got "%s"',
|
||||
argument.get_value(),
|
||||
current,
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug("%s += %s", current, addend)
|
||||
argument.set_value(current + addend)
|
||||
64
inputremapper/injection/macros/tasks/event.py
Normal file
64
inputremapper/injection/macros/tasks/event.py
Normal file
@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from evdev.ecodes import ecodes
|
||||
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class EventTask(Task):
|
||||
"""Write any event.
|
||||
|
||||
For example event(EV_KEY, KEY_A, 1)
|
||||
"""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="type",
|
||||
position=0,
|
||||
types=[str, int],
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="code",
|
||||
position=1,
|
||||
types=[str, int],
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="value",
|
||||
position=2,
|
||||
types=[int],
|
||||
),
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
type_ = self.get_argument("type").get_value()
|
||||
code = self.get_argument("code").get_value()
|
||||
value = self.get_argument("value").get_value()
|
||||
|
||||
if isinstance(type_, str):
|
||||
type_ = ecodes[type_.upper()]
|
||||
if isinstance(code, str):
|
||||
code = ecodes[code.upper()]
|
||||
|
||||
callback(type_, code, value)
|
||||
await self.keycode_pause()
|
||||
71
inputremapper/injection/macros/tasks/hold.py
Normal file
71
inputremapper/injection/macros/tasks/hold.py
Normal file
@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from evdev.ecodes import EV_KEY
|
||||
|
||||
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class HoldTask(Task):
|
||||
"""Loop the macro until the trigger-key is released."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="macro",
|
||||
position=0,
|
||||
types=[Macro, str, None],
|
||||
)
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
macro = self.get_argument("macro").get_value()
|
||||
|
||||
if macro is None:
|
||||
await self._trigger_release_event.wait()
|
||||
return
|
||||
|
||||
if isinstance(macro, str):
|
||||
# if macro is a key name, hold down the key while the
|
||||
# keyboard key is physically held down
|
||||
symbol = macro
|
||||
self.get_argument("macro").assert_is_symbol(symbol)
|
||||
|
||||
code = keyboard_layout.get(symbol)
|
||||
callback(EV_KEY, code, 1)
|
||||
await self._trigger_release_event.wait()
|
||||
callback(EV_KEY, code, 0)
|
||||
|
||||
if isinstance(macro, Macro):
|
||||
# repeat the macro forever while the key is held down
|
||||
while self.is_holding():
|
||||
# run the child macro completely to avoid
|
||||
# not-releasing any key
|
||||
await macro.run(callback)
|
||||
# prevent input-remapper from freezing up and making the system hard
|
||||
# to control, by adding 1ms of asyncio.sleep during which the event
|
||||
# loop can do other things
|
||||
await asyncio.sleep(1 / 1000)
|
||||
55
inputremapper/injection/macros/tasks/hold_keys.py
Normal file
55
inputremapper/injection/macros/tasks/hold_keys.py
Normal file
@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from evdev.ecodes import EV_KEY
|
||||
|
||||
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig, ArgumentFlags
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class HoldKeysTask(Task):
|
||||
"""Hold down multiple keys, equivalent to `a + b + c + ...`."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="*symbols",
|
||||
position=ArgumentFlags.spread,
|
||||
types=[str],
|
||||
is_symbol=True,
|
||||
)
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
symbols = self.get_argument("*symbols").get_values()
|
||||
|
||||
codes = [keyboard_layout.get(symbol) for symbol in symbols]
|
||||
|
||||
for code in codes:
|
||||
callback(EV_KEY, code, 1)
|
||||
await self.keycode_pause()
|
||||
|
||||
await self._trigger_release_event.wait()
|
||||
|
||||
for code in codes[::-1]:
|
||||
callback(EV_KEY, code, 0)
|
||||
await self.keycode_pause()
|
||||
66
inputremapper/injection/macros/tasks/if_eq.py
Normal file
66
inputremapper/injection/macros/tasks/if_eq.py
Normal file
@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class IfEqTask(Task):
|
||||
"""Compare two values."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="value_1",
|
||||
position=0,
|
||||
types=[str, int, float],
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="value_2",
|
||||
position=1,
|
||||
types=[str, int, float],
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="then",
|
||||
position=2,
|
||||
types=[Macro, None],
|
||||
default=None,
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="else",
|
||||
position=3,
|
||||
types=[Macro, None],
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
value_1 = self.get_argument("value_1").get_value()
|
||||
value_2 = self.get_argument("value_2").get_value()
|
||||
then = self.get_argument("then").get_value()
|
||||
else_ = self.get_argument("else").get_value()
|
||||
|
||||
if value_1 == value_2:
|
||||
if then is not None:
|
||||
await then.run(callback)
|
||||
elif else_ is not None:
|
||||
await else_.run(callback)
|
||||
71
inputremapper/injection/macros/tasks/if_led.py
Normal file
71
inputremapper/injection/macros/tasks/if_led.py
Normal file
@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from evdev.ecodes import (
|
||||
LED_NUML,
|
||||
LED_CAPSL,
|
||||
)
|
||||
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class IfLedTask(Task):
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="then",
|
||||
position=0,
|
||||
types=[Macro, None],
|
||||
default=None,
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="else",
|
||||
position=1,
|
||||
types=[Macro, None],
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
led_code = None
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
then = self.get_argument("then").get_value()
|
||||
else_ = self.get_argument("else").get_value()
|
||||
|
||||
# self.context is only None when the frontend is parsing the macro
|
||||
assert self.context is not None
|
||||
led_on = self.led_code in self.context.get_leds()
|
||||
|
||||
if led_on:
|
||||
if then is not None:
|
||||
await then.run(callback)
|
||||
elif else_ is not None:
|
||||
await else_.run(callback)
|
||||
|
||||
|
||||
class IfNumlockTask(IfLedTask):
|
||||
led_code = LED_NUML
|
||||
|
||||
|
||||
class IfCapslockTask(IfLedTask):
|
||||
led_code = LED_CAPSL
|
||||
96
inputremapper/injection/macros/tasks/if_single.py
Normal file
96
inputremapper/injection/macros/tasks/if_single.py
Normal file
@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from evdev.ecodes import EV_KEY
|
||||
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
from inputremapper.injection.macros.task import Task
|
||||
from inputremapper.input_event import InputEvent
|
||||
|
||||
|
||||
class IfSingleTask(Task):
|
||||
"""If a key was pressed without combining it."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="then",
|
||||
position=0,
|
||||
types=[Macro, None],
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="else",
|
||||
position=1,
|
||||
types=[Macro, None],
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="timeout",
|
||||
position=2,
|
||||
types=[int, float, None],
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
assert self.context is not None
|
||||
another_key_pressed_event = asyncio.Event()
|
||||
then = self.get_argument("then").get_value()
|
||||
else_ = self.get_argument("else").get_value()
|
||||
|
||||
async def listener(event: InputEvent) -> None:
|
||||
if event.type != EV_KEY:
|
||||
# Ignore anything that is not a key
|
||||
return
|
||||
|
||||
if event.is_pressed():
|
||||
# Another key was pressed
|
||||
another_key_pressed_event.set()
|
||||
return
|
||||
|
||||
self.add_event_listener(listener)
|
||||
|
||||
timeout = self.get_argument("timeout").get_value()
|
||||
|
||||
# Wait for anything of importance to happen, that would determine the
|
||||
# outcome of the if_single macro.
|
||||
await asyncio.wait(
|
||||
[
|
||||
asyncio.Task(another_key_pressed_event.wait()),
|
||||
asyncio.Task(self._trigger_release_event.wait()),
|
||||
],
|
||||
timeout=timeout / 1000 if timeout else None,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
self.remove_event_listener(listener)
|
||||
|
||||
if not self.is_holding():
|
||||
if then:
|
||||
await then.run(callback)
|
||||
else:
|
||||
# If the trigger has not been released, then `await asyncio.wait` above
|
||||
# could only have finished waiting due to a timeout, or because another
|
||||
# key was pressed.
|
||||
if else_:
|
||||
await else_.run(callback)
|
||||
79
inputremapper/injection/macros/tasks/if_tap.py
Normal file
79
inputremapper/injection/macros/tasks/if_tap.py
Normal file
@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class IfTapTask(Task):
|
||||
"""If a key was pressed quickly.
|
||||
|
||||
macro key pressed -> if_tap starts -> key released -> then
|
||||
|
||||
macro key pressed -> released (does other stuff in the meantime)
|
||||
-> if_tap starts -> pressed -> released -> then
|
||||
"""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="then",
|
||||
position=0,
|
||||
types=[Macro, None],
|
||||
default=None,
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="else",
|
||||
position=1,
|
||||
types=[Macro, None],
|
||||
default=None,
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="timeout",
|
||||
position=2,
|
||||
types=[int, float],
|
||||
default=300,
|
||||
),
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
then = self.get_argument("then").get_value()
|
||||
else_ = self.get_argument("else").get_value()
|
||||
timeout = self.get_argument("timeout").get_value() / 1000
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(self._wait(), timeout)
|
||||
if then:
|
||||
await then.run(callback)
|
||||
except asyncio.TimeoutError:
|
||||
if else_:
|
||||
await else_.run(callback)
|
||||
|
||||
async def _wait(self):
|
||||
"""Wait for a release, or if nothing pressed yet, a press and release."""
|
||||
if self.is_holding():
|
||||
await self._trigger_release_event.wait()
|
||||
else:
|
||||
await self._trigger_press_event.wait()
|
||||
await self._trigger_release_event.wait()
|
||||
72
inputremapper/injection/macros/tasks/ifeq.py
Normal file
72
inputremapper/injection/macros/tasks/ifeq.py
Normal file
@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class DeprecatedIfEqTask(Task):
|
||||
"""Old version of if_eq, kept for compatibility reasons.
|
||||
|
||||
This can't support a comparison like ifeq("foo", $blub) with blub containing
|
||||
"foo" without breaking old functionality, because "foo" is treated as a
|
||||
variable name.
|
||||
"""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="variable",
|
||||
position=0,
|
||||
types=[str, float, int, None],
|
||||
is_variable_name=True,
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="value",
|
||||
position=1,
|
||||
types=[str, float, int, None],
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="then",
|
||||
position=2,
|
||||
types=[Macro, None],
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="else",
|
||||
position=3,
|
||||
types=[Macro, None],
|
||||
),
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
actual_value = self.get_argument("variable").get_value()
|
||||
value = self.get_argument("value").get_value()
|
||||
then = self.get_argument("then").get_value()
|
||||
else_ = self.get_argument("else").get_value()
|
||||
|
||||
# The old ifeq function became somewhat incompatible with the new macro code.
|
||||
# I need to compare them as strings to keep this working.
|
||||
if str(actual_value) == str(value):
|
||||
if then is not None:
|
||||
await then.run(callback)
|
||||
elif else_ is not None:
|
||||
await else_.run(callback)
|
||||
49
inputremapper/injection/macros/tasks/key.py
Normal file
49
inputremapper/injection/macros/tasks/key.py
Normal file
@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from evdev.ecodes import EV_KEY
|
||||
|
||||
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class KeyTask(Task):
|
||||
"""Write the symbol."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="symbol",
|
||||
position=0,
|
||||
types=[str],
|
||||
is_symbol=True,
|
||||
)
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
symbol = self.get_argument("symbol").get_value()
|
||||
code = keyboard_layout.get(symbol)
|
||||
|
||||
callback(EV_KEY, code, 1)
|
||||
await self.keycode_pause()
|
||||
callback(EV_KEY, code, 0)
|
||||
await self.keycode_pause()
|
||||
45
inputremapper/injection/macros/tasks/key_down.py
Normal file
45
inputremapper/injection/macros/tasks/key_down.py
Normal file
@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from evdev.ecodes import EV_KEY
|
||||
|
||||
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class KeyDownTask(Task):
|
||||
"""Press the symbol."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="symbol",
|
||||
position=0,
|
||||
types=[str],
|
||||
is_symbol=True,
|
||||
)
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
symbol = self.get_argument("symbol").get_value()
|
||||
code = keyboard_layout.get(symbol)
|
||||
callback(EV_KEY, code, 1)
|
||||
45
inputremapper/injection/macros/tasks/key_up.py
Normal file
45
inputremapper/injection/macros/tasks/key_up.py
Normal file
@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from evdev.ecodes import EV_KEY
|
||||
|
||||
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class KeyUpTask(Task):
|
||||
"""Release the symbol."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="symbol",
|
||||
position=0,
|
||||
types=[str],
|
||||
is_symbol=True,
|
||||
)
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
symbol = self.get_argument("symbol").get_value()
|
||||
code = keyboard_layout.get(symbol)
|
||||
callback(EV_KEY, code, 0)
|
||||
140
inputremapper/injection/macros/tasks/mod_tap.py
Normal file
140
inputremapper/injection/macros/tasks/mod_tap.py
Normal file
@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from typing import Deque
|
||||
|
||||
from evdev.ecodes import EV_KEY
|
||||
|
||||
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.task import Task
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class ModTapTask(Task):
|
||||
"""If pressed long enough in combination with other keys, it turns into a modifier.
|
||||
|
||||
Can be used to make home-row-modifiers.
|
||||
|
||||
Works similar to the default of
|
||||
https://github.com/qmk/qmk_firmware/blob/78a0adfbb4d2c4e12f93f2a62ded0020d406243e/docs/tap_hold.md#comparison-comparison
|
||||
"""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="default",
|
||||
position=0,
|
||||
types=[str],
|
||||
is_symbol=True,
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="modifier",
|
||||
position=1,
|
||||
types=[str],
|
||||
is_symbol=True,
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="tapping_term",
|
||||
position=2,
|
||||
types=[int, float],
|
||||
default=200,
|
||||
),
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
tapping_term = self.get_argument("tapping_term").get_value() / 1000
|
||||
jamming_asyncio_events: Deque[asyncio.Event] = deque()
|
||||
|
||||
async def listener(event: InputEvent) -> None:
|
||||
trigger = self.mapping.input_combination[-1]
|
||||
if event.type_and_code == trigger.type_and_code:
|
||||
# We don't block the event that would set _trigger_release_event.
|
||||
return
|
||||
|
||||
if event.type != EV_KEY:
|
||||
return
|
||||
|
||||
asyncio_event = asyncio.Event()
|
||||
jamming_asyncio_events.append(asyncio_event)
|
||||
# Make the EventReader wait until the mod_tap macro allows it to continue
|
||||
# processing the event. Because we want to wait until mod_tap injected the
|
||||
# modifier.
|
||||
await asyncio_event.wait()
|
||||
|
||||
self.add_event_listener(listener)
|
||||
|
||||
timeout = asyncio.Task(asyncio.sleep(tapping_term))
|
||||
await asyncio.wait(
|
||||
[asyncio.Task(self._trigger_release_event.wait()), timeout],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
has_timed_out = timeout.done()
|
||||
|
||||
if has_timed_out:
|
||||
# The timeout happened before the trigger got released.
|
||||
# We therefore modify stuff.
|
||||
symbol = self.get_argument("modifier").get_value()
|
||||
logger.debug("Modifying with %s", symbol)
|
||||
else:
|
||||
# The trigger got released before the timeout.
|
||||
# We therefore do not modify stuff.
|
||||
symbol = self.get_argument("default").get_value()
|
||||
logger.debug("Writing default %s", symbol)
|
||||
|
||||
code = keyboard_layout.get(symbol)
|
||||
callback(EV_KEY, code, 1)
|
||||
await self.keycode_pause()
|
||||
|
||||
# Now that we know if the key was pressed with the intention of modifying other
|
||||
# keys, we can let the jammed keys go on their journey through the handlers.
|
||||
# Those other handlers may map them to other keys and stuff.
|
||||
while len(jamming_asyncio_events) > 0:
|
||||
asyncio_event = jamming_asyncio_events.popleft()
|
||||
asyncio_event.set()
|
||||
await self.keycode_pause()
|
||||
await self.throttle()
|
||||
# While we are emptying the queue, more events might still arrive and add
|
||||
# to the queue.
|
||||
|
||||
# We remove this as late as possible, because if more keys are pressed while
|
||||
# jamming_asyncio_events is still being taken care of, they should wait until
|
||||
# all is done. This ensures the order of all events that are pressed, until
|
||||
# mod_tap is completely finished.
|
||||
self.remove_event_listener(listener)
|
||||
|
||||
# Keep the modifier pressed until the input/trigger is released
|
||||
await self._trigger_release_event.wait()
|
||||
callback(EV_KEY, code, 0)
|
||||
|
||||
await self.keycode_pause()
|
||||
|
||||
async def throttle(self) -> None:
|
||||
# In case the keycode_pause ist set to 0ms, we need to give the event handlers
|
||||
# a chance to inject the withheld events, before we go on. This ensures the
|
||||
# correct order of injections. Since we are using asyncio, something like
|
||||
# `callback(EV_KEY, code, 0)` might be faster than the event handlers, even if
|
||||
# it is the last step of the macro.
|
||||
if self.mapping.macro_key_sleep_ms == 0:
|
||||
await asyncio.sleep(0.01)
|
||||
57
inputremapper/injection/macros/tasks/modify.py
Normal file
57
inputremapper/injection/macros/tasks/modify.py
Normal file
@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from evdev.ecodes import EV_KEY
|
||||
|
||||
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class ModifyTask(Task):
|
||||
"""Do stuff while a modifier is activated."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="modifier",
|
||||
position=0,
|
||||
types=[str],
|
||||
is_symbol=True,
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="macro",
|
||||
position=1,
|
||||
types=[Macro],
|
||||
),
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
modifier = self.get_argument("modifier").get_value()
|
||||
code = keyboard_layout.get(modifier)
|
||||
macro = self.get_argument("macro").get_value()
|
||||
|
||||
callback(EV_KEY, code, 1)
|
||||
await self.keycode_pause()
|
||||
await macro.run(callback)
|
||||
callback(EV_KEY, code, 0)
|
||||
await self.keycode_pause()
|
||||
69
inputremapper/injection/macros/tasks/mouse.py
Normal file
69
inputremapper/injection/macros/tasks/mouse.py
Normal file
@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from evdev._ecodes import REL_Y, REL_X
|
||||
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.macro import InjectEventCallback
|
||||
from inputremapper.injection.macros.tasks.mouse_xy import MouseXYTask
|
||||
|
||||
|
||||
class MouseTask(MouseXYTask):
|
||||
"""Move the mouse cursor."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="direction",
|
||||
position=0,
|
||||
types=[str],
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="speed",
|
||||
position=1,
|
||||
types=[int, float],
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="acceleration",
|
||||
position=2,
|
||||
types=[int, float],
|
||||
default=1,
|
||||
),
|
||||
]
|
||||
|
||||
async def run(self, callback: InjectEventCallback) -> None:
|
||||
direction = self.get_argument("direction").get_value()
|
||||
speed = self.get_argument("speed").get_value()
|
||||
acceleration = self.get_argument("acceleration").get_value()
|
||||
|
||||
code, direction = {
|
||||
"up": (REL_Y, -1),
|
||||
"down": (REL_Y, 1),
|
||||
"left": (REL_X, -1),
|
||||
"right": (REL_X, 1),
|
||||
}[direction.lower()]
|
||||
|
||||
await self.axis(
|
||||
code,
|
||||
direction * speed,
|
||||
acceleration,
|
||||
callback,
|
||||
)
|
||||
99
inputremapper/injection/macros/tasks/mouse_xy.py
Normal file
99
inputremapper/injection/macros/tasks/mouse_xy.py
Normal file
@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Union
|
||||
|
||||
from evdev._ecodes import REL_Y, REL_X
|
||||
from evdev.ecodes import EV_REL
|
||||
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.macro import InjectEventCallback
|
||||
from inputremapper.injection.macros.task import Task
|
||||
from inputremapper.injection.macros.tasks.util import precise_iteration_frequency
|
||||
|
||||
|
||||
class MouseXYTask(Task):
|
||||
"""Move the mouse cursor."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="x",
|
||||
position=0,
|
||||
types=[int, float],
|
||||
default=0,
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="y",
|
||||
position=1,
|
||||
types=[int, float],
|
||||
default=0,
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="acceleration",
|
||||
position=2,
|
||||
types=[int, float],
|
||||
default=1,
|
||||
),
|
||||
]
|
||||
|
||||
async def run(self, callback: InjectEventCallback) -> None:
|
||||
x = self.get_argument("x").get_value()
|
||||
y = self.get_argument("y").get_value()
|
||||
acceleration = self.get_argument("acceleration").get_value()
|
||||
await asyncio.gather(
|
||||
self.axis(REL_X, x, acceleration, callback),
|
||||
self.axis(REL_Y, y, acceleration, callback),
|
||||
)
|
||||
|
||||
async def axis(
|
||||
self,
|
||||
code: int,
|
||||
speed: Union[int, float],
|
||||
fractional_acceleration: Union[int, float],
|
||||
callback: InjectEventCallback,
|
||||
) -> None:
|
||||
acceleration = speed * fractional_acceleration
|
||||
direction = -1 if speed < 0 else 1
|
||||
current_speed = 0.0
|
||||
displacement_accumulator = 0.0
|
||||
displacement = 0
|
||||
if acceleration <= 0:
|
||||
displacement = int(speed)
|
||||
|
||||
async for _ in precise_iteration_frequency(self.mapping.rel_rate):
|
||||
if not self.is_holding():
|
||||
return
|
||||
|
||||
# Cursors can only move by integers. To get smooth acceleration for
|
||||
# small acceleration values, the cursor needs to move by a pixel every
|
||||
# few iterations. This can be achieved by remembering the decimal
|
||||
# places that were cast away, and using them for the next iteration.
|
||||
if acceleration:
|
||||
current_speed += acceleration
|
||||
current_speed = direction * min(abs(current_speed), abs(speed))
|
||||
displacement_accumulator += current_speed
|
||||
displacement = int(displacement_accumulator)
|
||||
displacement_accumulator -= displacement
|
||||
|
||||
if displacement != 0:
|
||||
callback(EV_REL, code, displacement)
|
||||
45
inputremapper/injection/macros/tasks/parallel.py
Normal file
45
inputremapper/injection/macros/tasks/parallel.py
Normal file
@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import List
|
||||
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig, ArgumentFlags
|
||||
from inputremapper.injection.macros.macro import Macro, InjectEventCallback
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class ParallelTask(Task):
|
||||
"""Run all provided macros in parallel."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="*macros",
|
||||
position=ArgumentFlags.spread,
|
||||
types=[Macro],
|
||||
),
|
||||
]
|
||||
|
||||
async def run(self, callback: InjectEventCallback) -> None:
|
||||
macros: List[Macro] = self.get_argument("*macros").get_values()
|
||||
coroutines = [macro.run(callback) for macro in macros]
|
||||
await asyncio.gather(*coroutines)
|
||||
49
inputremapper/injection/macros/tasks/repeat.py
Normal file
49
inputremapper/injection/macros/tasks/repeat.py
Normal file
@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class RepeatTask(Task):
|
||||
"""Repeat macros."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="repeats",
|
||||
position=0,
|
||||
types=[int],
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="macro",
|
||||
position=1,
|
||||
types=[Macro],
|
||||
),
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
repeats = self.get_argument("repeats").get_value()
|
||||
macro = self.get_argument("macro").get_value()
|
||||
|
||||
for _ in range(repeats):
|
||||
await macro.run(callback)
|
||||
49
inputremapper/injection/macros/tasks/set.py
Normal file
49
inputremapper/injection/macros/tasks/set.py
Normal file
@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.macro import macro_variables
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class SetTask(Task):
|
||||
"""Set a variable to a certain value."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="variable",
|
||||
position=0,
|
||||
types=[str, float, int, None],
|
||||
is_variable_name=True,
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="value",
|
||||
position=1,
|
||||
types=[str, float, int, None],
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
value = self.get_argument("value").get_value()
|
||||
assert macro_variables.is_alive()
|
||||
self.arguments["variable"].set_value(value)
|
||||
56
inputremapper/injection/macros/tasks/toggle.py
Normal file
56
inputremapper/injection/macros/tasks/toggle.py
Normal file
@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class ToggleTask(Task):
|
||||
"""Toggle macros."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="macro",
|
||||
position=0,
|
||||
types=[Macro],
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.trigger_count = 0
|
||||
|
||||
def press_trigger(self) -> None:
|
||||
Task.press_trigger(self)
|
||||
self.trigger_count += 1
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
macro = self.get_argument("macro").get_value()
|
||||
|
||||
while self.trigger_count <= 1:
|
||||
await macro.run(callback)
|
||||
await asyncio.sleep(1 / 1000)
|
||||
|
||||
self.trigger_count = 0
|
||||
45
inputremapper/injection/macros/tasks/util.py
Normal file
45
inputremapper/injection/macros/tasks/util.py
Normal file
@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import AsyncIterator
|
||||
|
||||
|
||||
async def precise_iteration_frequency(frequency: float) -> AsyncIterator[None]:
|
||||
"""A generator to iterate over in a fixed frequency.
|
||||
|
||||
asyncio.sleep might end up sleeping too long, for whatever reason. Maybe there are
|
||||
other async function calls that take longer than expected in the background.
|
||||
"""
|
||||
sleep = 1 / frequency
|
||||
corrected_sleep = sleep
|
||||
error = 0.0
|
||||
|
||||
while True:
|
||||
start = time.time()
|
||||
|
||||
yield
|
||||
|
||||
corrected_sleep -= error
|
||||
await asyncio.sleep(corrected_sleep)
|
||||
error = (time.time() - start) - sleep
|
||||
54
inputremapper/injection/macros/tasks/wait.py
Normal file
54
inputremapper/injection/macros/tasks/wait.py
Normal file
@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.task import Task
|
||||
|
||||
|
||||
class WaitTask(Task):
|
||||
"""Wait time in milliseconds."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="time",
|
||||
position=0,
|
||||
types=[float, int],
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="max_time",
|
||||
position=1,
|
||||
types=[float, int, None],
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
time = self.get_argument("time").get_value()
|
||||
max_time = self.get_argument("max_time").get_value()
|
||||
|
||||
if max_time is not None and max_time > time:
|
||||
time = random.uniform(time, max_time)
|
||||
|
||||
await asyncio.sleep(time / 1000)
|
||||
76
inputremapper/injection/macros/tasks/wheel.py
Normal file
76
inputremapper/injection/macros/tasks/wheel.py
Normal file
@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from evdev.ecodes import (
|
||||
EV_REL,
|
||||
REL_WHEEL_HI_RES,
|
||||
REL_HWHEEL_HI_RES,
|
||||
REL_WHEEL,
|
||||
REL_HWHEEL,
|
||||
)
|
||||
|
||||
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||
from inputremapper.injection.macros.task import Task
|
||||
from inputremapper.injection.macros.tasks.util import precise_iteration_frequency
|
||||
|
||||
|
||||
class WheelTask(Task):
|
||||
"""Move the scroll wheel."""
|
||||
|
||||
argument_configs = [
|
||||
ArgumentConfig(
|
||||
name="direction",
|
||||
position=0,
|
||||
types=[str],
|
||||
),
|
||||
ArgumentConfig(
|
||||
name="speed",
|
||||
position=1,
|
||||
types=[int, float],
|
||||
),
|
||||
]
|
||||
|
||||
async def run(self, callback) -> None:
|
||||
direction = self.get_argument("direction").get_value()
|
||||
|
||||
# 120, see https://www.kernel.org/doc/html/latest/input/event-codes.html#ev-rel
|
||||
code, value = {
|
||||
"up": ([REL_WHEEL, REL_WHEEL_HI_RES], [1 / 120, 1]),
|
||||
"down": ([REL_WHEEL, REL_WHEEL_HI_RES], [-1 / 120, -1]),
|
||||
"left": ([REL_HWHEEL, REL_HWHEEL_HI_RES], [1 / 120, 1]),
|
||||
"right": ([REL_HWHEEL, REL_HWHEEL_HI_RES], [-1 / 120, -1]),
|
||||
}[direction.lower()]
|
||||
|
||||
speed = self.get_argument("speed").get_value()
|
||||
remainder = [0.0, 0.0]
|
||||
|
||||
async for _ in precise_iteration_frequency(self.mapping.rel_rate):
|
||||
if not self.is_holding():
|
||||
return
|
||||
|
||||
for i in range(0, 2):
|
||||
float_value = value[i] * speed + remainder[i]
|
||||
remainder[i] = math.fmod(float_value, 1)
|
||||
if abs(float_value) >= 1:
|
||||
callback(EV_REL, code[i], int(float_value))
|
||||
90
inputremapper/injection/macros/variable.py
Normal file
90
inputremapper/injection/macros/variable.py
Normal file
@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from inputremapper.configs.validation_errors import MacroError
|
||||
from inputremapper.injection.macros.macro import macro_variables
|
||||
|
||||
|
||||
class Variable:
|
||||
"""Something that the user passed into a macro function as parameter.
|
||||
|
||||
The value should already be parsed and validated, if const=True, according to the
|
||||
argument_configs of a given Task.
|
||||
|
||||
Examples:
|
||||
- const string "KEY_A" in `key(KEY_A)`
|
||||
- non-const string "foo" in `repeat($foo, key(KEY_A))` (`value` is the name here)
|
||||
- const Macro `key(a)` in `repeat(1, key(a))`
|
||||
- const int 1 in `repeat(1, key(a))
|
||||
"""
|
||||
|
||||
def __init__(self, value: Any, const: bool) -> None:
|
||||
if not const and not isinstance(value, str):
|
||||
raise MacroError(f"Variables require a string name, not {value}")
|
||||
|
||||
self.value = value
|
||||
self.const = const
|
||||
|
||||
if not const:
|
||||
self.validate_variable_name()
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""If the variable is not const, return its name."""
|
||||
assert not self.const
|
||||
assert isinstance(self.value, str)
|
||||
return self.value
|
||||
|
||||
def get_value(self) -> Any:
|
||||
"""Get the variables value from the common variable storage process."""
|
||||
if self.const:
|
||||
return self.value
|
||||
|
||||
return macro_variables.get(self.value)
|
||||
|
||||
def set_value(self, value: Any) -> None:
|
||||
"""Set the variables value across all macros."""
|
||||
assert not self.const
|
||||
macro_variables[self.value] = value
|
||||
|
||||
def validate_variable_name(self) -> None:
|
||||
"""Check if this is a legit variable name.
|
||||
|
||||
Because they could clash with language features. If the macro can be
|
||||
parsed at all due to a problematic choice of a variable name.
|
||||
|
||||
Allowed examples: "foo", "Foo1234_", "_foo_1234"
|
||||
Not allowed: "1_foo", "foo=blub", "$foo", "foo,1234", "foo()"
|
||||
"""
|
||||
if not isinstance(self.value, str) or not re.match(
|
||||
r"^[A-Za-z_][A-Za-z_0-9]*$", self.value
|
||||
):
|
||||
raise MacroError(msg=f'"{self.value}" is not a legit variable name')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<Variable "{self.value}" const={self.const} at {hex(id(self))}>'
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
if not isinstance(other, Variable):
|
||||
return False
|
||||
|
||||
return self.const == other.const and self.value == other.value
|
||||
18
inputremapper/injection/mapping_handlers/__init__.py
Normal file
18
inputremapper/injection/mapping_handlers/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
152
inputremapper/injection/mapping_handlers/abs_to_abs_handler.py
Normal file
152
inputremapper/injection/mapping_handlers/abs_to_abs_handler.py
Normal file
@ -0,0 +1,152 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import Tuple, Optional, Dict, List
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import EV_ABS
|
||||
|
||||
from inputremapper import exceptions
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.utils import get_evdev_constant_name
|
||||
|
||||
|
||||
class AbsToAbsHandler(MappingHandler):
|
||||
"""Handler which transforms EV_ABS to EV_ABS events."""
|
||||
|
||||
_map_axis: InputConfig # the InputConfig for the axis we map
|
||||
_output_axis: Tuple[int, int] # the (type, code) of the output axis
|
||||
_transform: Optional[Transformation]
|
||||
_target_absinfo: evdev.AbsInfo
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
|
||||
# find the input event we are supposed to map. If the input combination is
|
||||
# BTN_A + ABS_X + BTN_B, then use the value of ABS_X for the transformation
|
||||
assert (map_axis := combination.find_analog_input_config(type_=EV_ABS))
|
||||
self._map_axis = map_axis
|
||||
|
||||
assert mapping.output_code is not None
|
||||
assert mapping.output_type == EV_ABS
|
||||
self._output_axis = (mapping.output_type, mapping.output_code)
|
||||
|
||||
target_uinput = global_uinputs.get_uinput(mapping.target_uinput)
|
||||
assert target_uinput is not None
|
||||
abs_capabilities = target_uinput.capabilities(absinfo=True)[EV_ABS]
|
||||
self._target_absinfo = dict(abs_capabilities)[mapping.output_code]
|
||||
|
||||
self._transform = None
|
||||
|
||||
def __str__(self):
|
||||
name = get_evdev_constant_name(*self._map_axis.type_and_code)
|
||||
return (
|
||||
f'AbsToAbsHandler for "{name}" {self._map_axis} '
|
||||
f"maps {self._map_axis} to: {self.mapping.get_output_name_constant()} "
|
||||
f"{self.mapping.get_output_type_code()} at "
|
||||
f"{self.mapping.target_uinput}"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
if event.input_match_hash != self._map_axis.input_match_hash:
|
||||
return False
|
||||
|
||||
if EventActions.recenter in event.actions:
|
||||
self._write(self._scale_to_target(0))
|
||||
return True
|
||||
|
||||
if not self._transform:
|
||||
absinfo = dict(source.capabilities(absinfo=True)[EV_ABS])[event.code] # type: ignore
|
||||
self._transform = Transformation(
|
||||
max_=absinfo.max,
|
||||
min_=absinfo.min,
|
||||
deadzone=self.mapping.deadzone,
|
||||
gain=self.mapping.gain,
|
||||
expo=self.mapping.expo,
|
||||
)
|
||||
|
||||
try:
|
||||
self._write(self._scale_to_target(self._transform(event.value)))
|
||||
return True
|
||||
except (exceptions.UinputNotAvailable, exceptions.EventNotHandled):
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
self._write(self._scale_to_target(0))
|
||||
|
||||
def _scale_to_target(self, x: float) -> int:
|
||||
"""Scales an x value between -1 and 1 to an integer between
|
||||
target_absinfo.min and target_absinfo.max
|
||||
|
||||
input values above 1 or below -1 are clamped to the extreme values
|
||||
"""
|
||||
factor = (self._target_absinfo.max - self._target_absinfo.min) / 2
|
||||
offset = self._target_absinfo.min + factor
|
||||
y = factor * x + offset
|
||||
if y > offset:
|
||||
return int(min(self._target_absinfo.max, y))
|
||||
else:
|
||||
return int(max(self._target_absinfo.min, y))
|
||||
|
||||
def _write(self, value: int):
|
||||
"""Inject."""
|
||||
try:
|
||||
self.global_uinputs.write(
|
||||
(*self._output_axis, value), self.mapping.target_uinput
|
||||
)
|
||||
except OverflowError:
|
||||
# screwed up the calculation of the event value
|
||||
logger.error("OverflowError (%s, %s, %s)", *self._output_axis, value)
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return len(self.input_configs) > 1
|
||||
|
||||
def set_sub_handler(self, handler: MappingHandler) -> None:
|
||||
assert False # cannot have a sub-handler
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
if self.needs_wrapping():
|
||||
return {InputCombination(self.input_configs): HandlerEnums.axisswitch}
|
||||
return {}
|
||||
151
inputremapper/injection/mapping_handlers/abs_to_btn_handler.py
Normal file
151
inputremapper/injection/mapping_handlers/abs_to_btn_handler.py
Normal file
@ -0,0 +1,151 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import List
|
||||
|
||||
import evdev
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.abs_util import calculate_trigger_point
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.utils import get_evdev_constant_name
|
||||
|
||||
|
||||
class AbsToBtnHandler(MappingHandler):
|
||||
"""Handler which transforms an EV_ABS to a button event."""
|
||||
|
||||
_input_config: InputConfig
|
||||
_configured_direction_was_pressed: bool
|
||||
_sub_handler: MappingHandler
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
|
||||
self._configured_direction_was_pressed = False
|
||||
self._input_config = combination[0]
|
||||
assert self._input_config.analog_threshold
|
||||
assert len(combination) == 1
|
||||
|
||||
def __str__(self):
|
||||
name = get_evdev_constant_name(*self._input_config.type_and_code)
|
||||
return f'AbsToBtnHandler for "{name}" ' f"{self._input_config.type_and_code}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return [self._sub_handler]
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
if event.input_match_hash != self._input_config.input_match_hash:
|
||||
return False
|
||||
|
||||
analog_threshold = self._input_config.analog_threshold
|
||||
assert analog_threshold is not None
|
||||
|
||||
threshold, mid_point = calculate_trigger_point(
|
||||
event,
|
||||
analog_threshold,
|
||||
source,
|
||||
)
|
||||
|
||||
value = event.value
|
||||
|
||||
direction = 1 if value > mid_point else -1
|
||||
|
||||
want_positive = analog_threshold > 0
|
||||
want_negative = analog_threshold < 0
|
||||
|
||||
# For dpads, the threshold is 1, but so is the max value. So <= and >= it is.
|
||||
# If this is dumb, change the threhsold to be a float.
|
||||
pressed = value >= threshold if want_positive else value <= threshold
|
||||
|
||||
'''print(f"""abs_to_btn
|
||||
{pressed=}
|
||||
{value=}
|
||||
{want_positive=}
|
||||
{want_negative=}
|
||||
{direction=}
|
||||
{threshold=}
|
||||
{mid_point=}
|
||||
{analog_threshold=}
|
||||
{self._configured_direction_was_pressed=}"""
|
||||
)'''
|
||||
|
||||
# dpad-right to a:
|
||||
# dpad moves right: a down
|
||||
# dpad returns: a up
|
||||
# dpad goes left: dpad -1
|
||||
# dpad returns: dpad 0
|
||||
# There are two "dpad returns" cases that have different outcomes
|
||||
|
||||
# joystick-right to a:
|
||||
# joystick moves to +1234: ignore (If the architecture could do it, forward 0)
|
||||
# joystick moves over threshold: a down
|
||||
# joystick returns below threshold: a up
|
||||
# joystick moves -1234: forward -1234
|
||||
# joystick goes to 0: forward 0
|
||||
# (In many cases it won't exactly return to 0, but to +1 or something, because
|
||||
# they aren't 100% precise. But the positive direction is mapped, so turn
|
||||
# this into 0. Unfortunately there is currently no way to do this in our
|
||||
# architecture.)
|
||||
|
||||
if not self._configured_direction_was_pressed:
|
||||
# these needs to be <= and >= mid point, to forward the dpad release for
|
||||
# the unmapped direction
|
||||
if want_positive and value <= mid_point:
|
||||
return False
|
||||
if want_negative and value >= mid_point:
|
||||
return False
|
||||
|
||||
# if it was pressed, then we first need to deal with releasing the sub-handler.
|
||||
|
||||
self._configured_direction_was_pressed = pressed
|
||||
|
||||
event = event.modify(
|
||||
pressed=pressed,
|
||||
direction=direction,
|
||||
actions=(EventActions.as_key,),
|
||||
)
|
||||
|
||||
return self._sub_handler.notify(
|
||||
event,
|
||||
source=source,
|
||||
suppress=suppress,
|
||||
)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._configured_direction_was_pressed = False
|
||||
self._sub_handler.reset()
|
||||
245
inputremapper/injection/mapping_handlers/abs_to_rel_handler.py
Normal file
245
inputremapper/injection/mapping_handlers/abs_to_rel_handler.py
Normal file
@ -0,0 +1,245 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from functools import partial
|
||||
from typing import Dict, Tuple, Optional, List
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import (
|
||||
EV_REL,
|
||||
EV_ABS,
|
||||
REL_WHEEL,
|
||||
REL_HWHEEL,
|
||||
REL_WHEEL_HI_RES,
|
||||
REL_HWHEEL_HI_RES,
|
||||
)
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import (
|
||||
Mapping,
|
||||
REL_XY_SCALING,
|
||||
WHEEL_SCALING,
|
||||
WHEEL_HI_RES_SCALING,
|
||||
DEFAULT_REL_RATE,
|
||||
)
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.utils import get_evdev_constant_name
|
||||
|
||||
|
||||
class AbsToRelHandler(MappingHandler):
|
||||
"""Handler which transforms an EV_ABS to EV_REL events."""
|
||||
|
||||
_map_axis: InputConfig # the InputConfig for the axis we map
|
||||
_value: float # the current output value
|
||||
_running: bool # if the run method is active
|
||||
_stop: bool # if the run loop should return
|
||||
_transform: Optional[Transformation]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
|
||||
# find the input event we are supposed to map
|
||||
assert (map_axis := combination.find_analog_input_config(type_=EV_ABS))
|
||||
self._map_axis = map_axis
|
||||
|
||||
self._value = 0
|
||||
self._running = False
|
||||
self._stop = True
|
||||
self._transform = None
|
||||
|
||||
# bind the correct run method
|
||||
if self.mapping.output_code in (
|
||||
REL_WHEEL,
|
||||
REL_HWHEEL,
|
||||
REL_WHEEL_HI_RES,
|
||||
REL_HWHEEL_HI_RES,
|
||||
):
|
||||
if self.mapping.output_code in (REL_WHEEL, REL_WHEEL_HI_RES):
|
||||
codes = (REL_WHEEL, REL_WHEEL_HI_RES)
|
||||
else:
|
||||
codes = (REL_HWHEEL, REL_HWHEEL_HI_RES)
|
||||
|
||||
self._run = partial(self._run_wheel_output, codes=codes)
|
||||
|
||||
else:
|
||||
self._run = partial(self._run_normal_output)
|
||||
|
||||
def __str__(self):
|
||||
name = get_evdev_constant_name(*self._map_axis.type_and_code)
|
||||
return (
|
||||
f'AbsToRelHandler for "{name}" {self._map_axis}: '
|
||||
f"maps to {self.mapping.get_output_name_constant()} "
|
||||
f"{self.mapping.get_output_type_code()} at "
|
||||
f"{self.mapping.target_uinput}"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
if event.input_match_hash != self._map_axis.input_match_hash:
|
||||
return False
|
||||
|
||||
if EventActions.recenter in event.actions:
|
||||
self._stop = True
|
||||
return True
|
||||
|
||||
if not self._transform:
|
||||
absinfo = {
|
||||
entry[0]: entry[1] # type: ignore
|
||||
for entry in source.capabilities(absinfo=True)[EV_ABS]
|
||||
}
|
||||
self._transform = Transformation(
|
||||
max_=absinfo[event.code].max,
|
||||
min_=absinfo[event.code].min,
|
||||
deadzone=self.mapping.deadzone,
|
||||
gain=self.mapping.gain,
|
||||
expo=self.mapping.expo,
|
||||
)
|
||||
|
||||
transformed = self._transform(event.value)
|
||||
|
||||
self._value = transformed
|
||||
|
||||
if transformed == 0:
|
||||
self._stop = True
|
||||
return True
|
||||
|
||||
if not self._running:
|
||||
asyncio.ensure_future(self._run())
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._stop = True
|
||||
|
||||
def _write(self, type_, keycode, value):
|
||||
"""Inject."""
|
||||
# if the mouse won't move even though correct stuff is written here,
|
||||
# the capabilities are probably wrong
|
||||
if value == 0:
|
||||
# rel 0 does not make sense. We don't need to tell linux that the mouse
|
||||
# should not be moved this time.
|
||||
return
|
||||
|
||||
try:
|
||||
self.global_uinputs.write(
|
||||
(type_, keycode, value),
|
||||
self.mapping.target_uinput,
|
||||
)
|
||||
except OverflowError:
|
||||
# screwed up the calculation of mouse movements
|
||||
logger.error("OverflowError (%s, %s, %s)", type_, keycode, value)
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return len(self.input_configs) > 1
|
||||
|
||||
def set_sub_handler(self, handler: MappingHandler) -> None:
|
||||
assert False # cannot have a sub-handler
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
if self.needs_wrapping():
|
||||
return {InputCombination(self.input_configs): HandlerEnums.axisswitch}
|
||||
return {}
|
||||
|
||||
def _calculate_output(self, value, weight, remainder):
|
||||
# self._value is between 0 and 1, scale up with weight
|
||||
scaled = value * weight + remainder
|
||||
# float_value % 1 will result in wrong calculations for negative values
|
||||
remainder = math.fmod(scaled, 1)
|
||||
return int(scaled), remainder
|
||||
|
||||
async def _run_normal_output(self) -> None:
|
||||
"""Start injecting events."""
|
||||
self._running = True
|
||||
self._stop = False
|
||||
remainder = 0.0
|
||||
start = time.time()
|
||||
|
||||
# if the rate is configured to be slower than the default, increase the value, so
|
||||
# that the overall speed stays the same.
|
||||
rate_compensation = DEFAULT_REL_RATE / self.mapping.rel_rate
|
||||
weight = REL_XY_SCALING * rate_compensation
|
||||
|
||||
while not self._stop:
|
||||
value, remainder = self._calculate_output(
|
||||
self._value,
|
||||
weight,
|
||||
remainder,
|
||||
)
|
||||
|
||||
self._write(EV_REL, self.mapping.output_code, value)
|
||||
|
||||
time_taken = time.time() - start
|
||||
sleep = max(0.0, (1 / self.mapping.rel_rate) - time_taken)
|
||||
await asyncio.sleep(sleep)
|
||||
start = time.time()
|
||||
|
||||
self._running = False
|
||||
|
||||
async def _run_wheel_output(self, codes: Tuple[int, int]) -> None:
|
||||
"""Start injecting wheel events.
|
||||
|
||||
made to inject both REL_WHEEL and REL_WHEEL_HI_RES events, because otherwise
|
||||
wheel output doesn't work for some people. See issue #354
|
||||
"""
|
||||
weights = (WHEEL_SCALING, WHEEL_HI_RES_SCALING)
|
||||
|
||||
self._running = True
|
||||
self._stop = False
|
||||
remainder = [0.0, 0.0]
|
||||
start = time.time()
|
||||
while not self._stop:
|
||||
for i in range(len(codes)):
|
||||
value, remainder[i] = self._calculate_output(
|
||||
self._value,
|
||||
weights[i],
|
||||
remainder[i],
|
||||
)
|
||||
|
||||
self._write(EV_REL, codes[i], value)
|
||||
|
||||
time_taken = time.time() - start
|
||||
await asyncio.sleep(max(0.0, (1 / self.mapping.rel_rate) - time_taken))
|
||||
start = time.time()
|
||||
|
||||
self._running = False
|
||||
72
inputremapper/injection/mapping_handlers/abs_util.py
Normal file
72
inputremapper/injection/mapping_handlers/abs_util.py
Normal file
@ -0,0 +1,72 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import EV_ABS, ABS_GAS, ABS_BRAKE, ABS_Z, ABS_RZ
|
||||
|
||||
from inputremapper.input_event import InputEvent
|
||||
|
||||
|
||||
# TODO: potentially cache this function { cache_key_from_args: previous_result }
|
||||
def calculate_trigger_point(
|
||||
event: InputEvent,
|
||||
analog_threshold: int,
|
||||
source: evdev.InputDevice,
|
||||
) -> Tuple[float, float]:
|
||||
"""Calculate the threshold and resting-point of the axis.
|
||||
|
||||
If an EV_ABS events value suprasses the threshold, it should be considered pressed.
|
||||
|
||||
The threshold is the offset from the resting-point/middle in both directions.
|
||||
|
||||
The resting point might be the middle value for a joystick: 0, *128*, 256 or
|
||||
-128, *0*, 128. Or it might be the minimum value of the shoulder triggers: *0* 256.
|
||||
"""
|
||||
absinfo = dict(source.capabilities(absinfo=True)[EV_ABS]) # type: ignore
|
||||
abs_min = absinfo[event.code].min
|
||||
abs_max = absinfo[event.code].max
|
||||
|
||||
assert analog_threshold
|
||||
if abs_min == -1 and abs_max == 1:
|
||||
# this is a hat switch
|
||||
# return +-1
|
||||
return (
|
||||
analog_threshold // abs(analog_threshold),
|
||||
0,
|
||||
)
|
||||
|
||||
if event.code in [ABS_GAS, ABS_BRAKE, ABS_Z, ABS_RZ]:
|
||||
threshold = abs_max * analog_threshold / 100
|
||||
# For the L/R triggers, there is only one direction, and the resting
|
||||
# position is the same as the min_abs.
|
||||
middle = abs_min
|
||||
return threshold, middle
|
||||
|
||||
half_range = (abs_max - abs_min) / 2
|
||||
middle = half_range + abs_min
|
||||
trigger_offset = half_range * analog_threshold / 100
|
||||
# Examples for threshold of +50:
|
||||
# -128 to 128. half_range is 128. middle is 0. trigger_offset is 64 (and above)
|
||||
# 0 to 128. half_range is 64. middle is 64. trigger_offset is 96 (and above)
|
||||
|
||||
# threshold, middle
|
||||
threshold = middle + trigger_offset
|
||||
return threshold, middle
|
||||
190
inputremapper/injection/mapping_handlers/axis_switch_handler.py
Normal file
190
inputremapper/injection/mapping_handlers/axis_switch_handler.py
Normal file
@ -0,0 +1,190 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import Dict, Tuple, Hashable, List
|
||||
|
||||
import evdev
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination
|
||||
from inputremapper.configs.input_config import InputConfig
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
ContextProtocol,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.utils import get_device_hash
|
||||
|
||||
|
||||
class AxisSwitchHandler(MappingHandler):
|
||||
"""Enables or disables an axis.
|
||||
|
||||
This is used when a combination involving an analog input (rel or abs) is mapped
|
||||
to another analog output (rel or abs). I think.
|
||||
|
||||
Generally, if multiple events are mapped to something in a combination, all of
|
||||
them need to be triggered in order to map to the output.
|
||||
|
||||
If an analog input is combined with a key input, then the same thing should happen.
|
||||
The key needs to be pressed and the joystick needs to be moved in order to generate
|
||||
output.
|
||||
"""
|
||||
|
||||
_map_axis: InputConfig # the InputConfig for the axis we switch on or off
|
||||
_trigger_keys: Tuple[Hashable, ...] # all events that can switch the axis
|
||||
_active: bool # whether the axis is on or off
|
||||
_last_value: int # the value of the last axis event that arrived
|
||||
_axis_source: evdev.InputDevice # the cached source of the axis input events
|
||||
_forward_device: evdev.UInput # the cached forward uinput
|
||||
_sub_handler: MappingHandler
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
context: ContextProtocol,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
):
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
trigger_keys = tuple(
|
||||
event.input_match_hash
|
||||
for event in combination
|
||||
if not event.defines_analog_input
|
||||
)
|
||||
assert len(trigger_keys) >= 1
|
||||
assert (map_axis := combination.find_analog_input_config())
|
||||
self._map_axis = map_axis
|
||||
self._trigger_keys = trigger_keys
|
||||
self._active = False
|
||||
|
||||
self._last_value = 0
|
||||
self._axis_source = None
|
||||
self._forward_device = None
|
||||
|
||||
self.context = context
|
||||
|
||||
def __str__(self):
|
||||
return f"AxisSwitchHandler for {self._map_axis.type_and_code}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return [self._sub_handler]
|
||||
|
||||
def _handle_key_input(self, event: InputEvent):
|
||||
"""If a key is pressed, allow mapping analog events in subhandlers.
|
||||
|
||||
Analog events (e.g. ABS_X, REL_Y) that have gone through Handlers that
|
||||
transform them to buttons also count as keys.
|
||||
"""
|
||||
key_is_pressed = event.is_pressed()
|
||||
if self._active == key_is_pressed:
|
||||
# nothing changed
|
||||
return False
|
||||
|
||||
self._active = key_is_pressed
|
||||
|
||||
if self._axis_source is None:
|
||||
return True
|
||||
|
||||
if not key_is_pressed:
|
||||
# recenter the axis
|
||||
logger.debug("Stopping axis for %s", self.mapping.input_combination)
|
||||
event = InputEvent(
|
||||
0,
|
||||
0,
|
||||
*self._map_axis.type_and_code,
|
||||
0,
|
||||
actions=(EventActions.recenter,),
|
||||
origin_hash=self._map_axis.origin_hash,
|
||||
)
|
||||
self._sub_handler.notify(event, self._axis_source)
|
||||
return True
|
||||
|
||||
if self._map_axis.type == evdev.ecodes.EV_ABS:
|
||||
# send the last cached value so that the abs axis
|
||||
# is at the correct position
|
||||
logger.debug("Starting axis for %s", self.mapping.input_combination)
|
||||
event = InputEvent(
|
||||
0,
|
||||
0,
|
||||
*self._map_axis.type_and_code,
|
||||
self._last_value,
|
||||
origin_hash=self._map_axis.origin_hash,
|
||||
)
|
||||
self._sub_handler.notify(event, self._axis_source)
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
def _should_map(self, event: InputEvent):
|
||||
return (
|
||||
event.input_match_hash in self._trigger_keys
|
||||
or event.input_match_hash == self._map_axis.input_match_hash
|
||||
)
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
if not self._should_map(event):
|
||||
return False
|
||||
|
||||
if event.is_key_event:
|
||||
# A key or an analog even that is being treated as a key (on/off) due to
|
||||
# previous handlers.
|
||||
return self._handle_key_input(event)
|
||||
|
||||
# do some caching so that we can generate the
|
||||
# recenter event and an initial abs event
|
||||
if self._axis_source is None:
|
||||
self._axis_source = source
|
||||
|
||||
if self._forward_device is None:
|
||||
device_hash = get_device_hash(source)
|
||||
self._forward_device = self.context.get_forward_uinput(device_hash)
|
||||
|
||||
# always cache the value
|
||||
self._last_value = event.value
|
||||
|
||||
if self._active:
|
||||
return self._sub_handler.notify(event, source, suppress)
|
||||
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
self._last_value = 0
|
||||
self._active = False
|
||||
self._sub_handler.reset()
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return True
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
combination = [
|
||||
config for config in self.input_configs if not config.defines_analog_input
|
||||
]
|
||||
return {InputCombination(combination): HandlerEnums.combination}
|
||||
140
inputremapper/injection/mapping_handlers/axis_transform.py
Normal file
140
inputremapper/injection/mapping_handlers/axis_transform.py
Normal file
@ -0,0 +1,140 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import math
|
||||
from typing import Dict, Union
|
||||
|
||||
|
||||
class Transformation:
|
||||
"""Callable that returns the axis transformation at x."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# if input values are > max_, the return value will be > 1
|
||||
max_: Union[int, float],
|
||||
min_: Union[int, float],
|
||||
deadzone: float,
|
||||
gain: float = 1,
|
||||
expo: float = 0,
|
||||
) -> None:
|
||||
self._max = max_
|
||||
self._min = min_
|
||||
self._deadzone = deadzone
|
||||
self._gain = gain
|
||||
self._expo = expo
|
||||
self._cache: Dict[float, float] = {}
|
||||
|
||||
def __call__(self, /, x: Union[int, float]) -> float:
|
||||
if x not in self._cache:
|
||||
y = (
|
||||
self._calc_qubic(self._flatten_deadzone(self._normalize(x)))
|
||||
* self._gain
|
||||
)
|
||||
self._cache[x] = y
|
||||
|
||||
return self._cache[x]
|
||||
|
||||
def set_range(self, min_, max_):
|
||||
# TODO docstring
|
||||
if min_ != self._min or max_ != self._max:
|
||||
self._cache = {}
|
||||
|
||||
self._min = min_
|
||||
self._max = max_
|
||||
|
||||
def _normalize(self, x: Union[int, float]) -> float:
|
||||
"""Move and scale x to be between -1 and 1
|
||||
return: x
|
||||
"""
|
||||
if self._min == -1 and self._max == 1:
|
||||
return x
|
||||
|
||||
half_range = (self._max - self._min) / 2
|
||||
middle = half_range + self._min
|
||||
return (x - middle) / half_range
|
||||
|
||||
def _flatten_deadzone(self, x: float) -> float:
|
||||
"""
|
||||
y ^ y ^
|
||||
| |
|
||||
1 | / 1 | /
|
||||
| / | /
|
||||
| / ==> | ---
|
||||
| / | /
|
||||
-1 | / -1 | /
|
||||
|------------> |------------>
|
||||
-1 1 x -1 1 x
|
||||
"""
|
||||
if abs(x) <= self._deadzone:
|
||||
return 0
|
||||
|
||||
return (x - self._deadzone * x / abs(x)) / (1 - self._deadzone)
|
||||
|
||||
def _calc_qubic(self, x: float) -> float:
|
||||
"""Transforms an x value by applying a qubic function
|
||||
|
||||
k = 0 : will yield no transformation f(x) = x
|
||||
1 > k > 0 : will yield low sensitivity for low x values
|
||||
and high sensitivity for high x values
|
||||
-1 < k < 0 : will yield high sensitivity for low x values
|
||||
and low sensitivity for high x values
|
||||
|
||||
see also: https://www.geogebra.org/calculator/mkdqueky
|
||||
|
||||
Mathematical definition:
|
||||
f(x,d) = d * x + (1 - d) * x ** 3 | d = 1 - k | k ∈ [0,1]
|
||||
the function is designed such that if follows these constraints:
|
||||
f'(0, d) = d and f(1, d) = 1 and f(-x,d) = -f(x,d)
|
||||
|
||||
for k ∈ [-1,0) the above function is mirrored at y = x
|
||||
and d = 1 + k
|
||||
"""
|
||||
k = self._expo
|
||||
|
||||
if k == 0 or x == 0:
|
||||
return x
|
||||
|
||||
if 0 < k <= 1:
|
||||
d = 1 - k
|
||||
return d * x + (1 - d) * x**3
|
||||
|
||||
if -1 <= k < 0:
|
||||
# calculate return value with the real inverse solution
|
||||
# of y = b * x + a * x ** 3
|
||||
# LaTeX for better readability:
|
||||
#
|
||||
# y=\frac{{{\left( \sqrt{27 {{x}^{2}}+\frac{4 {{b}^{3}}}{a}}
|
||||
# +{{3}^{\frac{3}{2}}} x\right) }^{\frac{1}{3}}}}
|
||||
# {{{2}^{\frac{1}{3}}} \sqrt{3} {{a}^{\frac{1}{3}}}}
|
||||
# -\frac{{{2}^{\frac{1}{3}}} b}
|
||||
# {\sqrt{3} {{a}^{\frac{2}{3}}}
|
||||
# {{\left( \sqrt{27 {{x}^{2}}+\frac{4 {{b}^{3}}}{a}}
|
||||
# +{{3}^{\frac{3}{2}}} x\right) }^{\frac{1}{3}}}}
|
||||
sign = x / abs(x)
|
||||
x = math.fabs(x)
|
||||
d = 1 + k
|
||||
a = 1 - d
|
||||
b = d
|
||||
c = (math.sqrt(27 * x**2 + (4 * b**3) / a) + 3 ** (3 / 2) * x) ** (1 / 3)
|
||||
y = c / (2 ** (1 / 3) * math.sqrt(3) * a ** (1 / 3)) - (
|
||||
2 ** (1 / 3) * b
|
||||
) / (math.sqrt(3) * a ** (2 / 3) * c)
|
||||
return y * sign
|
||||
|
||||
raise ValueError("k must be between -1 and 1")
|
||||
275
inputremapper/injection/mapping_handlers/combination_handler.py
Normal file
275
inputremapper/injection/mapping_handlers/combination_handler.py
Normal file
@ -0,0 +1,275 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations # needed for the TYPE_CHECKING import
|
||||
|
||||
from typing import TYPE_CHECKING, Dict, Hashable, Tuple, List
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import EV_ABS, EV_REL
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
MappingHandler,
|
||||
HandlerEnums,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inputremapper.injection.context import Context
|
||||
|
||||
|
||||
class CombinationHandler(MappingHandler):
|
||||
"""Keeps track of a combination and notifies a sub handler."""
|
||||
|
||||
# map of InputEvent.input_match_hash -> bool , keep track of the combination state
|
||||
_pressed_keys: Dict[Hashable, bool]
|
||||
# the last update we sent to a sub-handler. If this is true, the output key is
|
||||
# still being held down.
|
||||
_output_previously_active: bool
|
||||
_sub_handler: MappingHandler
|
||||
_handled_input_hashes: list[Hashable]
|
||||
_requires_a_release: Dict[Tuple[int, int], bool]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
context: Context,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
logger.debug(str(mapping))
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
self._pressed_keys = {}
|
||||
self._output_previously_active = False
|
||||
self._context = context
|
||||
self._requires_a_release = {}
|
||||
|
||||
# prepare a key map for all events with non-zero value
|
||||
for input_config in combination:
|
||||
assert not input_config.defines_analog_input
|
||||
self._pressed_keys[input_config.input_match_hash] = False
|
||||
|
||||
self._handled_input_hashes = [
|
||||
input_config.input_match_hash for input_config in combination
|
||||
]
|
||||
|
||||
assert len(self._pressed_keys) > 0 # no combination handler without a key
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f'CombinationHandler for "{str(self.mapping.input_combination)}" '
|
||||
f"{tuple(t for t in self._pressed_keys.keys())}"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
description = (
|
||||
f'CombinationHandler for "{repr(self.mapping.input_combination)}" '
|
||||
f"{tuple(t for t in self._pressed_keys.keys())}"
|
||||
)
|
||||
return f"<{description} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return [self._sub_handler]
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
if event.input_match_hash not in self._handled_input_hashes:
|
||||
# we are not responsible for the event
|
||||
return False
|
||||
|
||||
# update the state
|
||||
# The value of non-key input should have been changed to either 0 or 1 at this
|
||||
# point by other handlers.
|
||||
is_pressed = event.is_pressed()
|
||||
self._pressed_keys[event.input_match_hash] = is_pressed
|
||||
# maybe this changes the activation status (triggered/not-triggered)
|
||||
changed = self._is_activated() != self._output_previously_active
|
||||
|
||||
if changed:
|
||||
if is_pressed:
|
||||
return self._handle_freshly_activated(suppress, event, source)
|
||||
else:
|
||||
return self._handle_freshly_deactivated(event, source)
|
||||
else:
|
||||
if is_pressed:
|
||||
return self._handle_no_change_press(event)
|
||||
else:
|
||||
return self._handle_no_change_release(event)
|
||||
|
||||
def _handle_no_change_press(self, event: InputEvent) -> bool:
|
||||
"""A key was pressed, but this doesn't change the combinations activation state.
|
||||
Can only happen if either the combination wasn't already active, or a duplicate
|
||||
key-down event arrived (EV_ABS?)
|
||||
"""
|
||||
# self._output_previously_active is negated, because if the output is active, a
|
||||
# key-down event triggered it, which then did not get forwarded, therefore
|
||||
# it doesn't require a release.
|
||||
self._require_release_later(not self._output_previously_active, event)
|
||||
# output is active: consume the event
|
||||
# output inactive: forward the event
|
||||
return self._output_previously_active
|
||||
|
||||
def _handle_no_change_release(self, event: InputEvent) -> bool:
|
||||
"""One of the combinations keys was released, but it didn't untrigger the
|
||||
combination yet."""
|
||||
# Negate: `False` means that the event-reader will forward the release.
|
||||
return not self._should_release_event(event)
|
||||
|
||||
def _handle_freshly_activated(
|
||||
self,
|
||||
suppress: bool,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
) -> bool:
|
||||
"""The combination was deactivated, but is activated now."""
|
||||
if suppress:
|
||||
return False
|
||||
|
||||
# Send key up events to the forwarded uinput if configured to do so.
|
||||
self._forward_release()
|
||||
|
||||
logger.debug(
|
||||
"Sending %s to sub-handler %s",
|
||||
repr(event),
|
||||
repr(self._sub_handler),
|
||||
)
|
||||
self._output_previously_active = event.is_pressed()
|
||||
sub_handler_result = self._sub_handler.notify(event, source, suppress)
|
||||
|
||||
# Only if the sub-handler return False, we need a release-event later.
|
||||
# If it handled the event, the user never sees this key-down event.
|
||||
self._require_release_later(not sub_handler_result, event)
|
||||
return sub_handler_result
|
||||
|
||||
def _handle_freshly_deactivated(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
) -> bool:
|
||||
"""The combination was activated, but is deactivated now."""
|
||||
# We ignore the `suppress` argument for release events. Otherwise, we
|
||||
# might end up with stuck keys (test_event_pipeline.test_combination).
|
||||
# In the case of output axis, this will enable us to activate multiple
|
||||
# axis with the same button.
|
||||
|
||||
logger.debug(
|
||||
"Sending %s to sub-handler %s",
|
||||
repr(event),
|
||||
repr(self._sub_handler),
|
||||
)
|
||||
self._output_previously_active = event.is_pressed()
|
||||
self._sub_handler.notify(event, source, suppress=False)
|
||||
|
||||
# Negate: `False` means that the event-reader will forward the release.
|
||||
return not self._should_release_event(event)
|
||||
|
||||
def _should_release_event(self, event: InputEvent) -> bool:
|
||||
"""Check if the key-up event should be forwarded by the event-reader.
|
||||
|
||||
After this, the release event needs to be injected by someone, otherwise the
|
||||
dictionary was modified erroneously. If there is no entry, we assume that there
|
||||
was no key-down event to release. Maybe a duplicate event arrived.
|
||||
"""
|
||||
# Ensure that all injected key-down events will get their release event
|
||||
# injected eventually.
|
||||
# If a key-up event arrives that will inactivate the combination, but
|
||||
# for which previously a key-down event was injected (because it was
|
||||
# an earlier key in the combination chain), then we need to ensure that its
|
||||
# release is injected as well. So we get two release events in that case:
|
||||
# one for the key, and one for the output.
|
||||
assert event.is_pressed() == 0, f"expected {event.is_pressed()} to be 0"
|
||||
return self._requires_a_release.pop(event.type_and_code, False)
|
||||
|
||||
def _require_release_later(self, require: bool, event: InputEvent) -> None:
|
||||
"""Remember if this key-down event will need a release event later on."""
|
||||
assert event.is_pressed() == 1
|
||||
self._requires_a_release[event.type_and_code] = require
|
||||
|
||||
def reset(self) -> None:
|
||||
self._sub_handler.reset()
|
||||
for key in self._pressed_keys:
|
||||
self._pressed_keys[key] = False
|
||||
self._requires_a_release = {}
|
||||
self._output_previously_active = False
|
||||
|
||||
def _is_activated(self) -> bool:
|
||||
"""Return if all keys in the keymap are set to True."""
|
||||
return False not in self._pressed_keys.values()
|
||||
|
||||
def _forward_release(self) -> None:
|
||||
"""Forward a button release for all keys if this is a combination.
|
||||
|
||||
This might cause duplicate key-up events but those are ignored by evdev anyway
|
||||
"""
|
||||
if len(self._pressed_keys) == 1 or not self.mapping.release_combination_keys:
|
||||
return
|
||||
|
||||
keys_to_release = filter(
|
||||
lambda cfg: self._pressed_keys.get(cfg.input_match_hash),
|
||||
self.mapping.input_combination,
|
||||
)
|
||||
|
||||
logger.debug("Forwarding release for %s", self.mapping.input_combination)
|
||||
|
||||
for input_config in keys_to_release:
|
||||
if not self._requires_a_release.get(input_config.type_and_code):
|
||||
continue
|
||||
|
||||
origin_hash = input_config.origin_hash
|
||||
if origin_hash is None:
|
||||
logger.error(
|
||||
f"Can't forward due to missing origin_hash in {repr(input_config)}"
|
||||
)
|
||||
continue
|
||||
|
||||
forward_to = self._context.get_forward_uinput(origin_hash)
|
||||
logger.write(input_config, forward_to)
|
||||
forward_to.write(*input_config.type_and_code, 0)
|
||||
forward_to.syn()
|
||||
|
||||
# We are done with this key, forget about it
|
||||
del self._requires_a_release[input_config.type_and_code]
|
||||
|
||||
def needs_ranking(self) -> bool:
|
||||
return bool(self.input_configs)
|
||||
|
||||
def rank_by(self) -> InputCombination:
|
||||
return InputCombination(
|
||||
[event for event in self.input_configs if not event.defines_analog_input]
|
||||
)
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
return_dict = {}
|
||||
for config in self.input_configs:
|
||||
if config.type == EV_ABS and not config.defines_analog_input:
|
||||
return_dict[InputCombination([config])] = HandlerEnums.abs2btn
|
||||
|
||||
if config.type == EV_REL and not config.defines_analog_input:
|
||||
return_dict[InputCombination([config])] = HandlerEnums.rel2btn
|
||||
|
||||
return return_dict
|
||||
108
inputremapper/injection/mapping_handlers/hierarchy_handler.py
Normal file
108
inputremapper/injection/mapping_handlers/hierarchy_handler.py
Normal file
@ -0,0 +1,108 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import List, Dict
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import EV_ABS, EV_REL
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
MappingHandler,
|
||||
HandlerEnums,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
|
||||
|
||||
class HierarchyHandler(MappingHandler):
|
||||
"""Handler consisting of an ordered list of MappingHandler
|
||||
|
||||
only the first handler which successfully handles the event will execute it,
|
||||
all other handlers will be notified, but suppressed
|
||||
"""
|
||||
|
||||
_input_config: InputConfig
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
handlers: List[MappingHandler],
|
||||
input_config: InputConfig,
|
||||
global_uinputs: GlobalUInputs,
|
||||
) -> None:
|
||||
self.handlers = handlers
|
||||
self._input_config = input_config
|
||||
combination = InputCombination([input_config])
|
||||
# use the mapping from the first child TODO: find a better solution
|
||||
mapping = handlers[0].mapping
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
|
||||
def __str__(self):
|
||||
return f"HierarchyHandler for {self._input_config}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return self.handlers
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
if event.input_match_hash != self._input_config.input_match_hash:
|
||||
return False
|
||||
|
||||
handled = False
|
||||
for handler in self.handlers:
|
||||
if handled:
|
||||
# To allow an arbitrary number of output axes to be activated at the
|
||||
# same time, we don't suppress them.
|
||||
handler.notify(
|
||||
event,
|
||||
source,
|
||||
suppress=not handler.mapping.input_combination.defines_analog_input,
|
||||
)
|
||||
continue
|
||||
|
||||
handled = handler.notify(event, source)
|
||||
|
||||
return handled
|
||||
|
||||
def reset(self) -> None:
|
||||
for sub_handler in self.handlers:
|
||||
sub_handler.reset()
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
if (
|
||||
self._input_config.type == EV_ABS
|
||||
and not self._input_config.defines_analog_input
|
||||
):
|
||||
return {InputCombination([self._input_config]): HandlerEnums.abs2btn}
|
||||
if (
|
||||
self._input_config.type == EV_REL
|
||||
and not self._input_config.defines_analog_input
|
||||
):
|
||||
return {InputCombination([self._input_config]): HandlerEnums.rel2btn}
|
||||
return {}
|
||||
|
||||
def set_sub_handler(self, handler: MappingHandler) -> None:
|
||||
assert False
|
||||
90
inputremapper/injection/mapping_handlers/key_handler.py
Normal file
90
inputremapper/injection/mapping_handlers/key_handler.py
Normal file
@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import Tuple, Dict, List
|
||||
|
||||
from inputremapper import exceptions
|
||||
from inputremapper.configs.input_config import InputCombination
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.exceptions import MappingParsingError
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.utils import get_evdev_constant_name
|
||||
|
||||
|
||||
class KeyHandler(MappingHandler):
|
||||
"""Injects the target key if notified."""
|
||||
|
||||
_active: bool
|
||||
_maps_to: Tuple[int, int]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
):
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
maps_to = mapping.get_output_type_code()
|
||||
if not maps_to:
|
||||
raise MappingParsingError(
|
||||
"Unable to create key handler from mapping", mapping=mapping
|
||||
)
|
||||
|
||||
self._maps_to = maps_to
|
||||
self._active = False
|
||||
|
||||
def __str__(self):
|
||||
name = get_evdev_constant_name(*self._maps_to)
|
||||
return f"KeyHandler to {name} {self._maps_to} on {self.mapping.target_uinput}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
|
||||
def notify(self, event: InputEvent, *_, **__) -> bool:
|
||||
"""Inject the correct value to the target uinput."""
|
||||
event_tuple = (*self._maps_to, 1 if event.is_pressed() else 0)
|
||||
try:
|
||||
self.global_uinputs.write(event_tuple, self.mapping.target_uinput)
|
||||
self._active = bool(event.is_pressed())
|
||||
return True
|
||||
except exceptions.Error:
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
logger.debug("resetting key_handler")
|
||||
if self._active:
|
||||
event_tuple = (*self._maps_to, 0)
|
||||
self.global_uinputs.write(event_tuple, self.mapping.target_uinput)
|
||||
self._active = False
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return True
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
return {InputCombination(self.input_configs): HandlerEnums.combination}
|
||||
121
inputremapper/injection/mapping_handlers/macro_handler.py
Normal file
121
inputremapper/injection/mapping_handlers/macro_handler.py
Normal file
@ -0,0 +1,121 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from typing import Dict, Callable, Tuple, List
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
from inputremapper.injection.macros.parse import Parser
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
ContextProtocol,
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class MacroHandler(MappingHandler):
|
||||
"""Runs the target macro if notified."""
|
||||
|
||||
# TODO: replace this by the macro itself
|
||||
_macro: Macro
|
||||
_active: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
*,
|
||||
context: ContextProtocol,
|
||||
):
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
self._pressed_keys: Dict[Tuple[int, int], int] = {}
|
||||
self._active = False
|
||||
assert self.mapping.output_symbol is not None
|
||||
self._macro = Parser.parse(self.mapping.output_symbol, context, mapping)
|
||||
|
||||
def __str__(self):
|
||||
return f"MacroHandler maps to {self._macro} on {self.mapping.target_uinput}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
|
||||
async def run_macro(self, handler: Callable):
|
||||
"""Run the macro with the provided function."""
|
||||
try:
|
||||
await self._macro.run(handler)
|
||||
except Exception as exception:
|
||||
logger.error('Macro "%s" failed with %s', self._macro.code, type(exception))
|
||||
traceback.print_exc()
|
||||
|
||||
def notify(self, event: InputEvent, *_, **__) -> bool:
|
||||
if event.is_pressed():
|
||||
self._active = True
|
||||
self._macro.press_trigger()
|
||||
if self._macro.running:
|
||||
return True
|
||||
|
||||
def handler(type_, code, value) -> None:
|
||||
"""Handler for macros."""
|
||||
self._remember_pressed_keys((type_, code, value))
|
||||
|
||||
self.global_uinputs.write(
|
||||
(type_, code, value),
|
||||
self.mapping.target_uinput,
|
||||
)
|
||||
|
||||
asyncio.ensure_future(self.run_macro(handler))
|
||||
return True
|
||||
else:
|
||||
self._active = False
|
||||
self._macro.release_trigger()
|
||||
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._active = False
|
||||
|
||||
# To avoid a key hanging forever. Can be pretty annoying, especially if it is
|
||||
# a modifier that makes you unable to interact with your system.
|
||||
for (type, code), value in self._pressed_keys.items():
|
||||
if value == 1:
|
||||
logger.debug("Releasing key %s", (type, code, value))
|
||||
self.global_uinputs.write(
|
||||
(type, code, 0),
|
||||
self.mapping.target_uinput,
|
||||
)
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return True
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
return {InputCombination(self.input_configs): HandlerEnums.combination}
|
||||
|
||||
def _remember_pressed_keys(self, event: Tuple[int, int, int]) -> None:
|
||||
type, code, value = event
|
||||
self._pressed_keys[(type, code)] = value
|
||||
213
inputremapper/injection/mapping_handlers/mapping_handler.py
Normal file
213
inputremapper/injection/mapping_handlers/mapping_handler.py
Normal file
@ -0,0 +1,213 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""Provides protocols for mapping handlers
|
||||
|
||||
*** The architecture behind mapping handlers ***
|
||||
|
||||
Handling an InputEvent is done in 3 steps:
|
||||
1. Input Event Handling
|
||||
A MappingHandler that does Input event handling receives Input Events directly
|
||||
from the EventReader.
|
||||
To do so it must implement the MappingHandler protocol.
|
||||
An MappingHandler may handle multiple events (InputEvent.type_and_code)
|
||||
|
||||
2. Event Transformation
|
||||
The event gets transformed as described by the mapping.
|
||||
e.g.: combining multiple events to a single one
|
||||
transforming EV_ABS to EV_REL
|
||||
macros
|
||||
...
|
||||
Multiple transformations may get chained
|
||||
|
||||
3. Event Injection
|
||||
The transformed event gets injected to a global_uinput
|
||||
|
||||
MappingHandlers can implement one or more of these steps.
|
||||
|
||||
Overview of implemented handlers and the steps they implement:
|
||||
|
||||
Step 1:
|
||||
- HierarchyHandler
|
||||
|
||||
Step 1 and 2:
|
||||
- CombinationHandler
|
||||
- AbsToBtnHandler
|
||||
- RelToBtnHandler
|
||||
|
||||
Step 1, 2 and 3:
|
||||
- AbsToRelHandler
|
||||
- NullHandler
|
||||
|
||||
Step 2 and 3:
|
||||
- KeyHandler
|
||||
- MacroHandler
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from typing import Dict, Protocol, Set, Optional, List
|
||||
|
||||
import evdev
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.exceptions import MappingParsingError
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class EventListener(Protocol):
|
||||
async def __call__(self, event: evdev.InputEvent) -> None: ...
|
||||
|
||||
|
||||
class ContextProtocol(Protocol):
|
||||
"""The parts from context needed for handlers."""
|
||||
|
||||
listeners: Set[EventListener]
|
||||
|
||||
def get_forward_uinput(self, origin_hash) -> evdev.UInput:
|
||||
pass
|
||||
|
||||
|
||||
class NotifyCallback(Protocol):
|
||||
"""Type signature of MappingHandler.notify
|
||||
|
||||
return True if the event was actually taken care of
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool: ...
|
||||
|
||||
|
||||
class HandlerEnums(enum.Enum):
|
||||
# converting to btn
|
||||
abs2btn = enum.auto()
|
||||
rel2btn = enum.auto()
|
||||
|
||||
macro = enum.auto()
|
||||
key = enum.auto()
|
||||
|
||||
# converting to "analog"
|
||||
btn2rel = enum.auto()
|
||||
rel2rel = enum.auto()
|
||||
abs2rel = enum.auto()
|
||||
|
||||
btn2abs = enum.auto()
|
||||
rel2abs = enum.auto()
|
||||
abs2abs = enum.auto()
|
||||
|
||||
# special handlers
|
||||
combination = enum.auto()
|
||||
hierarchy = enum.auto()
|
||||
axisswitch = enum.auto()
|
||||
disable = enum.auto()
|
||||
|
||||
|
||||
class MappingHandler:
|
||||
mapping: Mapping
|
||||
# all input events this handler cares about
|
||||
# should always be a subset of mapping.input_combination
|
||||
input_configs: List[InputConfig]
|
||||
_sub_handler: Optional[MappingHandler]
|
||||
|
||||
# https://bugs.python.org/issue44807
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
"""Initialize the handler
|
||||
|
||||
Parameters
|
||||
----------
|
||||
combination
|
||||
the combination from sub_handler.wrap_with()
|
||||
mapping
|
||||
"""
|
||||
self.mapping = mapping
|
||||
self.input_configs = list(combination)
|
||||
self._sub_handler = None
|
||||
self.global_uinputs = global_uinputs
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
"""Notify this handler about an incoming event.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
event
|
||||
The newest event that came from `source`, and that should be mapped to
|
||||
something else
|
||||
source
|
||||
Where `event` comes from
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the state of the handler e.g. release any buttons."""
|
||||
raise NotImplementedError
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
"""If this handler needs to be wrapped in another MappingHandler."""
|
||||
return len(self.wrap_with()) > 0
|
||||
|
||||
def needs_ranking(self) -> bool:
|
||||
"""If this handler needs ranking and wrapping with a HierarchyHandler."""
|
||||
return False
|
||||
|
||||
def rank_by(self) -> Optional[InputCombination]:
|
||||
"""The combination for which this handler needs ranking."""
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
"""A dict of InputCombination -> HandlerEnums.
|
||||
|
||||
for each InputCombination this handler should be wrapped
|
||||
with the given MappingHandler.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def set_sub_handler(self, handler: MappingHandler) -> None:
|
||||
"""Give this handler a sub_handler."""
|
||||
self._sub_handler = handler
|
||||
|
||||
def occlude_input_event(self, input_config: InputConfig) -> None:
|
||||
"""Remove the config from self.input_configs."""
|
||||
if not self.input_configs:
|
||||
logger.debug_mapping_handler(self)
|
||||
raise MappingParsingError(
|
||||
"Cannot remove a non existing config", mapping_handler=self
|
||||
)
|
||||
|
||||
# should be called for each event a wrapping-handler
|
||||
# has in its input_configs InputCombination
|
||||
self.input_configs.remove(input_config)
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
360
inputremapper/injection/mapping_handlers/mapping_parser.py
Normal file
360
inputremapper/injection/mapping_handlers/mapping_parser.py
Normal file
@ -0,0 +1,360 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""Functions to assemble the mapping handler tree."""
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Type, Optional, Set, Iterable, Sized, Tuple, Sequence
|
||||
|
||||
from evdev.ecodes import EV_KEY, EV_ABS, EV_REL
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.keyboard_layout import DISABLE_CODE, DISABLE_NAME
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.exceptions import MappingParsingError
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.macros.parse import Parser
|
||||
from inputremapper.injection.mapping_handlers.abs_to_abs_handler import AbsToAbsHandler
|
||||
from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
|
||||
from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler
|
||||
from inputremapper.injection.mapping_handlers.axis_switch_handler import (
|
||||
AxisSwitchHandler,
|
||||
)
|
||||
from inputremapper.injection.mapping_handlers.combination_handler import (
|
||||
CombinationHandler,
|
||||
)
|
||||
from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler
|
||||
from inputremapper.injection.mapping_handlers.key_handler import KeyHandler
|
||||
from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
ContextProtocol,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.injection.mapping_handlers.null_handler import NullHandler
|
||||
from inputremapper.injection.mapping_handlers.rel_to_abs_handler import RelToAbsHandler
|
||||
from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler
|
||||
from inputremapper.injection.mapping_handlers.rel_to_rel_handler import RelToRelHandler
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.utils import get_evdev_constant_name
|
||||
|
||||
EventPipelines = Dict[InputConfig, Set[MappingHandler]]
|
||||
|
||||
mapping_handler_classes: Dict[HandlerEnums, Optional[Type[MappingHandler]]] = {
|
||||
# all available mapping_handlers
|
||||
HandlerEnums.abs2btn: AbsToBtnHandler,
|
||||
HandlerEnums.rel2btn: RelToBtnHandler,
|
||||
HandlerEnums.macro: MacroHandler,
|
||||
HandlerEnums.key: KeyHandler,
|
||||
HandlerEnums.btn2rel: None, # can be a macro
|
||||
HandlerEnums.rel2rel: RelToRelHandler,
|
||||
HandlerEnums.abs2rel: AbsToRelHandler,
|
||||
HandlerEnums.btn2abs: None, # can be a macro
|
||||
HandlerEnums.rel2abs: RelToAbsHandler,
|
||||
HandlerEnums.abs2abs: AbsToAbsHandler,
|
||||
HandlerEnums.combination: CombinationHandler,
|
||||
HandlerEnums.hierarchy: HierarchyHandler,
|
||||
HandlerEnums.axisswitch: AxisSwitchHandler,
|
||||
HandlerEnums.disable: NullHandler,
|
||||
}
|
||||
|
||||
|
||||
class MappingParser:
|
||||
def __init__(
|
||||
self,
|
||||
global_uinputs: GlobalUInputs,
|
||||
) -> None:
|
||||
self.global_uinputs = global_uinputs
|
||||
|
||||
def parse_mappings(
|
||||
self,
|
||||
preset: Preset,
|
||||
context: ContextProtocol,
|
||||
) -> EventPipelines:
|
||||
"""Create a dict with a list of MappingHandler for each InputEvent."""
|
||||
handlers = []
|
||||
for mapping in preset:
|
||||
# start with the last handler in the chain, each mapping only has one output,
|
||||
# but may have multiple inputs, therefore the last handler is a good starting
|
||||
# point to assemble the pipeline
|
||||
handler_enum = self._get_output_handler(mapping)
|
||||
constructor = mapping_handler_classes[handler_enum]
|
||||
if not constructor:
|
||||
logger.warning(
|
||||
"a mapping handler '%s' for %s is not implemented",
|
||||
handler_enum,
|
||||
mapping.format_name(),
|
||||
)
|
||||
continue
|
||||
|
||||
output_handler = constructor(
|
||||
mapping.input_combination,
|
||||
mapping,
|
||||
context=context,
|
||||
global_uinputs=self.global_uinputs,
|
||||
)
|
||||
|
||||
# layer other handlers on top until the outer handler needs ranking or can
|
||||
# directly handle a input event
|
||||
handlers.extend(self._create_event_pipeline(output_handler, context))
|
||||
|
||||
# figure out which handlers need ranking and wrap them with hierarchy_handlers
|
||||
need_ranking = defaultdict(set)
|
||||
for handler in handlers.copy():
|
||||
if handler.needs_ranking():
|
||||
combination = handler.rank_by()
|
||||
if not combination:
|
||||
raise MappingParsingError(
|
||||
f"{type(handler).__name__} claims to need ranking but does not "
|
||||
f"return a combination to rank by",
|
||||
mapping_handler=handler,
|
||||
)
|
||||
|
||||
need_ranking[combination].add(handler)
|
||||
handlers.remove(handler)
|
||||
|
||||
# the HierarchyHandler's might not be the starting point of the event pipeline,
|
||||
# layer other handlers on top again.
|
||||
ranked_handlers = self._create_hierarchy_handlers(need_ranking)
|
||||
for handler in ranked_handlers:
|
||||
handlers.extend(
|
||||
self._create_event_pipeline(handler, context, ignore_ranking=True)
|
||||
)
|
||||
|
||||
# group all handlers by the input events they take care of. One handler might end
|
||||
# up in multiple groups if it takes care of multiple InputEvents
|
||||
event_pipelines: EventPipelines = defaultdict(set)
|
||||
for handler in handlers:
|
||||
assert handler.input_configs
|
||||
for input_config in handler.input_configs:
|
||||
logger.debug(
|
||||
"event-pipeline with entry point: %s %s",
|
||||
get_evdev_constant_name(*input_config.type_and_code),
|
||||
input_config.input_match_hash,
|
||||
)
|
||||
logger.debug_mapping_handler(handler)
|
||||
event_pipelines[input_config].add(handler)
|
||||
|
||||
return event_pipelines
|
||||
|
||||
def _create_event_pipeline(
|
||||
self,
|
||||
handler: MappingHandler,
|
||||
context: ContextProtocol,
|
||||
ignore_ranking=False,
|
||||
) -> List[MappingHandler]:
|
||||
"""Recursively wrap a handler with other handlers until the
|
||||
outer handler needs ranking or is finished wrapping.
|
||||
"""
|
||||
if not handler.needs_wrapping() or (
|
||||
handler.needs_ranking() and not ignore_ranking
|
||||
):
|
||||
return [handler]
|
||||
|
||||
handlers = []
|
||||
for combination, handler_enum in handler.wrap_with().items():
|
||||
constructor = mapping_handler_classes[handler_enum]
|
||||
if not constructor:
|
||||
raise NotImplementedError(
|
||||
f"mapping handler {handler_enum} is not implemented"
|
||||
)
|
||||
|
||||
super_handler = constructor(
|
||||
combination,
|
||||
handler.mapping,
|
||||
context=context,
|
||||
global_uinputs=self.global_uinputs,
|
||||
)
|
||||
super_handler.set_sub_handler(handler)
|
||||
for event in combination:
|
||||
# the handler now has a super_handler which takes care about the events.
|
||||
# so we need to hide them on the handler
|
||||
handler.occlude_input_event(event)
|
||||
|
||||
handlers.extend(self._create_event_pipeline(super_handler, context))
|
||||
|
||||
if handler.input_configs:
|
||||
# the handler was only partially wrapped,
|
||||
# we need to return it as a toplevel handler
|
||||
handlers.append(handler)
|
||||
|
||||
return handlers
|
||||
|
||||
def _get_output_handler(self, mapping: Mapping) -> HandlerEnums:
|
||||
"""Determine the correct output handler.
|
||||
|
||||
this is used as a starting point for the mapping parser
|
||||
"""
|
||||
if mapping.output_code == DISABLE_CODE or mapping.output_symbol == DISABLE_NAME:
|
||||
return HandlerEnums.disable
|
||||
|
||||
if mapping.output_symbol:
|
||||
if Parser.is_this_a_macro(mapping.output_symbol):
|
||||
return HandlerEnums.macro
|
||||
|
||||
return HandlerEnums.key
|
||||
|
||||
if mapping.output_type == EV_KEY:
|
||||
return HandlerEnums.key
|
||||
|
||||
input_event = self._maps_axis(mapping.input_combination)
|
||||
if not input_event:
|
||||
raise MappingParsingError(
|
||||
f"This {mapping = } does not map to an axis, key or macro",
|
||||
mapping=Mapping,
|
||||
)
|
||||
|
||||
if mapping.output_type == EV_REL:
|
||||
if input_event.type == EV_KEY:
|
||||
return HandlerEnums.btn2rel
|
||||
if input_event.type == EV_REL:
|
||||
return HandlerEnums.rel2rel
|
||||
if input_event.type == EV_ABS:
|
||||
return HandlerEnums.abs2rel
|
||||
|
||||
if mapping.output_type == EV_ABS:
|
||||
if input_event.type == EV_KEY:
|
||||
return HandlerEnums.btn2abs
|
||||
if input_event.type == EV_REL:
|
||||
return HandlerEnums.rel2abs
|
||||
if input_event.type == EV_ABS:
|
||||
return HandlerEnums.abs2abs
|
||||
|
||||
raise MappingParsingError(
|
||||
f"the output of {mapping = } is unknown", mapping=Mapping
|
||||
)
|
||||
|
||||
def _maps_axis(self, combination: InputCombination) -> Optional[InputConfig]:
|
||||
"""Whether this InputCombination contains an InputEvent that is treated as
|
||||
an axis and not a binary (key or button) event.
|
||||
"""
|
||||
for event in combination:
|
||||
if event.defines_analog_input:
|
||||
return event
|
||||
return None
|
||||
|
||||
def _create_hierarchy_handlers(
|
||||
self,
|
||||
handlers: Dict[InputCombination, Set[MappingHandler]],
|
||||
) -> Set[MappingHandler]:
|
||||
"""Sort handlers by input events and create Hierarchy handlers."""
|
||||
sorted_handlers = set()
|
||||
all_combinations = handlers.keys()
|
||||
events = set()
|
||||
|
||||
# gather all InputEvents from all handlers
|
||||
for combination in all_combinations:
|
||||
for event in combination:
|
||||
events.add(event)
|
||||
|
||||
# create a ranking for each event
|
||||
for event in events:
|
||||
# find all combinations (from handlers) which contain the event
|
||||
combinations_with_event = [
|
||||
combination for combination in all_combinations if event in combination
|
||||
]
|
||||
|
||||
if len(combinations_with_event) == 1:
|
||||
# there was only one handler containing that event return it as is
|
||||
sorted_handlers.update(handlers[combinations_with_event[0]])
|
||||
continue
|
||||
|
||||
# there are multiple handler with the same event.
|
||||
# rank them and create the HierarchyHandler
|
||||
sorted_combinations = self._order_combinations(
|
||||
combinations_with_event,
|
||||
event,
|
||||
)
|
||||
sub_handlers: List[MappingHandler] = []
|
||||
for combination in sorted_combinations:
|
||||
sub_handlers.extend(handlers[combination])
|
||||
|
||||
sorted_handlers.add(
|
||||
HierarchyHandler(
|
||||
sub_handlers,
|
||||
event,
|
||||
self.global_uinputs,
|
||||
)
|
||||
)
|
||||
for handler in sub_handlers:
|
||||
# the handler now has a HierarchyHandler which takes care about this event.
|
||||
# so we hide need to hide it on the handler
|
||||
handler.occlude_input_event(event)
|
||||
|
||||
return sorted_handlers
|
||||
|
||||
def _order_combinations(
|
||||
self,
|
||||
combinations: List[InputCombination],
|
||||
common_config: InputConfig,
|
||||
) -> List[InputCombination]:
|
||||
"""Reorder the keys according to some rules.
|
||||
|
||||
such that a combination a+b+c is in front of a+b which is in front of b
|
||||
for a+b+c vs. b+d+e: a+b+c would be in front of b+d+e, because the common key b
|
||||
has the higher index in the a+b+c (1), than in the b+c+d (0) list
|
||||
in this example b would be the common key
|
||||
as for combinations like a+b+c and e+d+c with the common key c: ¯\\_(ツ)_/¯
|
||||
|
||||
Parameters
|
||||
----------
|
||||
combinations
|
||||
the list which needs ordering
|
||||
common_config
|
||||
the InputConfig all InputCombination's in combinations have in common
|
||||
"""
|
||||
combinations.sort(key=len)
|
||||
|
||||
for start, end in self._ranges_with_constant_length(combinations.copy()):
|
||||
sub_list = combinations[start:end]
|
||||
sub_list.sort(key=lambda x: x.index(common_config))
|
||||
combinations[start:end] = sub_list
|
||||
|
||||
combinations.reverse()
|
||||
return combinations
|
||||
|
||||
def _ranges_with_constant_length(
|
||||
self,
|
||||
x: Sequence[Sized],
|
||||
) -> Iterable[Tuple[int, int]]:
|
||||
"""Get all ranges of x for which the elements have constant length
|
||||
|
||||
Parameters
|
||||
----------
|
||||
x: Sequence[Sized]
|
||||
l must be ordered by increasing length of elements
|
||||
"""
|
||||
start_idx = 0
|
||||
last_len = 0
|
||||
for idx, y in enumerate(x):
|
||||
if len(y) > last_len and idx - start_idx > 1:
|
||||
yield start_idx, idx
|
||||
|
||||
if len(y) == last_len and idx + 1 == len(x):
|
||||
yield start_idx, idx + 1
|
||||
|
||||
if len(y) > last_len:
|
||||
start_idx = idx
|
||||
|
||||
if len(y) < last_len:
|
||||
raise MappingParsingError(
|
||||
"ranges_with_constant_length was called with an unordered list"
|
||||
)
|
||||
last_len = len(y)
|
||||
62
inputremapper/injection/mapping_handlers/null_handler.py
Normal file
62
inputremapper/injection/mapping_handlers/null_handler.py
Normal file
@ -0,0 +1,62 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
import evdev
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
|
||||
|
||||
class NullHandler(MappingHandler):
|
||||
"""Handler which consumes the event and does nothing."""
|
||||
|
||||
def __str__(self):
|
||||
return f"NullHandler for {self.mapping.input_combination}<{id(self)}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return False in [
|
||||
input_.defines_analog_input for input_ in self.mapping.input_combination
|
||||
]
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
if not self.mapping.input_combination.defines_analog_input:
|
||||
return {self.mapping.input_combination: HandlerEnums.combination}
|
||||
|
||||
assert len(self.mapping.input_combination) > 1, "nees_wrapping ensures this!"
|
||||
return {self.mapping.input_combination: HandlerEnums.axisswitch}
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
pass
|
||||
248
inputremapper/injection/mapping_handlers/rel_to_abs_handler.py
Normal file
248
inputremapper/injection/mapping_handlers/rel_to_abs_handler.py
Normal file
@ -0,0 +1,248 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import asyncio
|
||||
from typing import Tuple, Dict, Optional, List
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import (
|
||||
EV_ABS,
|
||||
EV_REL,
|
||||
REL_WHEEL,
|
||||
REL_HWHEEL,
|
||||
REL_HWHEEL_HI_RES,
|
||||
REL_WHEEL_HI_RES,
|
||||
)
|
||||
|
||||
from inputremapper import exceptions
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import (
|
||||
Mapping,
|
||||
WHEEL_SCALING,
|
||||
WHEEL_HI_RES_SCALING,
|
||||
REL_XY_SCALING,
|
||||
DEFAULT_REL_RATE,
|
||||
)
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class RelToAbsHandler(MappingHandler):
|
||||
"""Handler which transforms EV_REL to EV_ABS events.
|
||||
|
||||
High EV_REL input results in high EV_ABS output.
|
||||
If no new EV_REL events are seen, the EV_ABS output is set to 0 after
|
||||
release_timeout.
|
||||
"""
|
||||
|
||||
_map_axis: InputConfig # InputConfig for the relative movement we map
|
||||
_output_axis: Tuple[int, int] # the (type, code) of the output axis
|
||||
_transform: Transformation
|
||||
_target_absinfo: evdev.AbsInfo
|
||||
|
||||
# infinite loop which centers the output when input stops
|
||||
_recenter_loop: Optional[asyncio.Task]
|
||||
_moving: asyncio.Event # event to notify the _recenter_loop
|
||||
|
||||
_previous_event: Optional[InputEvent]
|
||||
_observed_rate: float # input events per second
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
|
||||
# find the input event we are supposed to map. If the input combination is
|
||||
# BTN_A + REL_X + BTN_B, then use the value of REL_X for the transformation
|
||||
assert (map_axis := combination.find_analog_input_config(type_=EV_REL))
|
||||
self._map_axis = map_axis
|
||||
|
||||
assert mapping.output_code is not None
|
||||
assert mapping.output_type == EV_ABS
|
||||
self._output_axis = (mapping.output_type, mapping.output_code)
|
||||
|
||||
target_uinput = global_uinputs.get_uinput(mapping.target_uinput)
|
||||
assert target_uinput is not None
|
||||
abs_capabilities = target_uinput.capabilities(absinfo=True)[EV_ABS]
|
||||
self._target_absinfo = dict(abs_capabilities)[mapping.output_code]
|
||||
|
||||
max_ = self._get_default_cutoff()
|
||||
self._transform = Transformation(
|
||||
min_=-max(1, int(max_)),
|
||||
max_=max(1, int(max_)),
|
||||
deadzone=mapping.deadzone,
|
||||
gain=mapping.gain,
|
||||
expo=mapping.expo,
|
||||
)
|
||||
self._moving = asyncio.Event()
|
||||
self._recenter_loop = None
|
||||
|
||||
self._previous_event = None
|
||||
self._observed_rate = DEFAULT_REL_RATE
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"RelToAbsHandler for {self._map_axis} "
|
||||
f"maps to: {self.mapping.get_output_name_constant()} "
|
||||
f"{self.mapping.get_output_type_code()} at "
|
||||
f"{self.mapping.target_uinput}"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
|
||||
def _observe_rate(self, event: InputEvent):
|
||||
"""Watch incoming events and remember how many events appear per second."""
|
||||
if self._previous_event is not None:
|
||||
delta_time = event.timestamp() - self._previous_event.timestamp()
|
||||
if delta_time == 0:
|
||||
logger.error("Observed two events with the same timestamp")
|
||||
return
|
||||
|
||||
rate = 1 / delta_time
|
||||
# mice seem to have a constant rate. wheel events are jaggy and the
|
||||
# rate depends on how fast it is turned.
|
||||
if rate > self._observed_rate:
|
||||
logger.debug("Updating rate to %s", rate)
|
||||
self._observed_rate = rate
|
||||
self._calculate_cutoff()
|
||||
|
||||
self._previous_event = event
|
||||
|
||||
def _get_default_cutoff(self):
|
||||
"""Get the cutoff value assuming the default input rate."""
|
||||
if self._map_axis.code in [REL_WHEEL, REL_HWHEEL]:
|
||||
return self.mapping.rel_to_abs_input_cutoff * WHEEL_SCALING
|
||||
|
||||
if self._map_axis.code in [REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES]:
|
||||
return self.mapping.rel_to_abs_input_cutoff * WHEEL_HI_RES_SCALING
|
||||
|
||||
return self.mapping.rel_to_abs_input_cutoff * REL_XY_SCALING
|
||||
|
||||
def _calculate_cutoff(self):
|
||||
"""Correct the default cutoff with the observed input rate, and set it."""
|
||||
# Mice that have very high input rates report low values at the same time.
|
||||
# If the rate is high, use a lower cutoff-value. If the rate is low, use a
|
||||
# higher cutoff-value.
|
||||
cutoff = self._get_default_cutoff()
|
||||
cutoff *= DEFAULT_REL_RATE / self._observed_rate
|
||||
|
||||
self._transform.set_range(-max(1, int(cutoff)), max(1, int(cutoff)))
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
self._observe_rate(event)
|
||||
|
||||
if event.input_match_hash != self._map_axis.input_match_hash:
|
||||
return False
|
||||
|
||||
if EventActions.recenter in event.actions:
|
||||
if self._recenter_loop:
|
||||
self._recenter_loop.cancel()
|
||||
self._recenter()
|
||||
return True
|
||||
|
||||
if not self._recenter_loop or self._recenter_loop.cancelled():
|
||||
self._recenter_loop = asyncio.create_task(self._create_recenter_loop())
|
||||
|
||||
self._moving.set() # notify the _recenter_loop
|
||||
try:
|
||||
self._write(self._scale_to_target(self._transform(event.value)))
|
||||
return True
|
||||
except (exceptions.UinputNotAvailable, exceptions.EventNotHandled):
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
if self._recenter_loop:
|
||||
self._recenter_loop.cancel()
|
||||
self._recenter()
|
||||
|
||||
def _recenter(self) -> None:
|
||||
"""Recenter the output."""
|
||||
self._write(self._scale_to_target(0))
|
||||
|
||||
async def _create_recenter_loop(self) -> None:
|
||||
"""Coroutine which waits for the input to start moving,
|
||||
then waits until the input stops moving, centers the output and repeat.
|
||||
|
||||
Runs forever.
|
||||
"""
|
||||
while True:
|
||||
await self._moving.wait() # input moving started
|
||||
while (
|
||||
await asyncio.wait(
|
||||
(asyncio.create_task(self._moving.wait()),),
|
||||
timeout=self.mapping.release_timeout,
|
||||
)
|
||||
)[0]:
|
||||
self._moving.clear() # still moving
|
||||
self._recenter() # input moving stopped
|
||||
|
||||
def _scale_to_target(self, x: float) -> int:
|
||||
"""Scales a x value between -1 and 1 to an integer between
|
||||
target_absinfo.min and target_absinfo.max
|
||||
|
||||
input values above 1 or below -1 are clamped to the extreme values
|
||||
"""
|
||||
factor = (self._target_absinfo.max - self._target_absinfo.min) / 2
|
||||
offset = self._target_absinfo.min + factor
|
||||
y = factor * x + offset
|
||||
if y > offset:
|
||||
return int(min(self._target_absinfo.max, y))
|
||||
else:
|
||||
return int(max(self._target_absinfo.min, y))
|
||||
|
||||
def _write(self, value: int) -> None:
|
||||
"""Inject."""
|
||||
try:
|
||||
self.global_uinputs.write(
|
||||
(*self._output_axis, value),
|
||||
self.mapping.target_uinput,
|
||||
)
|
||||
except OverflowError:
|
||||
# screwed up the calculation of the event value
|
||||
logger.error("OverflowError (%s, %s, %s)", *self._output_axis, value)
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return len(self.input_configs) > 1
|
||||
|
||||
def set_sub_handler(self, handler: MappingHandler) -> None:
|
||||
assert False # cannot have a sub-handler
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
if self.needs_wrapping():
|
||||
return {InputCombination(self.input_configs): HandlerEnums.axisswitch}
|
||||
return {}
|
||||
148
inputremapper/injection/mapping_handlers/rel_to_btn_handler.py
Normal file
148
inputremapper/injection/mapping_handlers/rel_to_btn_handler.py
Normal file
@ -0,0 +1,148 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import EV_REL
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class RelToBtnHandler(MappingHandler):
|
||||
"""Handler which transforms an EV_REL to a button event
|
||||
and sends that to a sub_handler
|
||||
|
||||
adheres to the MappingHandler protocol
|
||||
"""
|
||||
|
||||
_active: bool
|
||||
_input_config: InputConfig
|
||||
_last_activation: float
|
||||
_sub_handler: MappingHandler
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
|
||||
self._active = False
|
||||
self._input_config = combination[0]
|
||||
self._last_activation = time.time()
|
||||
self._abort_release = False
|
||||
assert self._input_config.analog_threshold != 0
|
||||
assert len(combination) == 1
|
||||
|
||||
def __str__(self):
|
||||
return f'RelToBtnHandler for "{self._input_config}"'
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return [self._sub_handler]
|
||||
|
||||
async def _stage_release(
|
||||
self,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool,
|
||||
):
|
||||
while time.time() < self._last_activation + self.mapping.release_timeout:
|
||||
await asyncio.sleep(1 / self.mapping.rel_rate)
|
||||
|
||||
if self._abort_release:
|
||||
self._abort_release = False
|
||||
return
|
||||
|
||||
event = InputEvent(
|
||||
0,
|
||||
0,
|
||||
*self._input_config.type_and_code,
|
||||
value=0,
|
||||
actions=(EventActions.as_key,),
|
||||
origin_hash=self._input_config.origin_hash,
|
||||
)
|
||||
logger.debug("Sending %s to sub_handler", event)
|
||||
self._sub_handler.notify(event, source, suppress)
|
||||
self._active = False
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
assert event.type == EV_REL
|
||||
if event.input_match_hash != self._input_config.input_match_hash:
|
||||
return False
|
||||
|
||||
assert (threshold := self._input_config.analog_threshold)
|
||||
value = event.value
|
||||
if (value < threshold > 0) or (value > threshold < 0):
|
||||
if self._active:
|
||||
# the axis is below the threshold and the stage_release
|
||||
# function is running
|
||||
if self.mapping.force_release_timeout:
|
||||
# consume the event
|
||||
return True
|
||||
event = event.modify(pressed=False, actions=(EventActions.as_key,))
|
||||
logger.debug("Sending %s to sub_handler", event)
|
||||
self._abort_release = True
|
||||
else:
|
||||
# don't consume the event.
|
||||
# We could return True to consume events
|
||||
return False
|
||||
else:
|
||||
# the axis is above the threshold
|
||||
if not self._active:
|
||||
asyncio.ensure_future(self._stage_release(source, suppress))
|
||||
if value >= threshold > 0:
|
||||
direction = 1
|
||||
else:
|
||||
direction = -1
|
||||
self._last_activation = time.time()
|
||||
event = event.modify(
|
||||
pressed=True,
|
||||
direction=direction,
|
||||
actions=(EventActions.as_key,),
|
||||
)
|
||||
|
||||
self._active = event.is_pressed()
|
||||
# logger.debug("Sending %s to sub_handler", event)
|
||||
return self._sub_handler.notify(event, source=source, suppress=suppress)
|
||||
|
||||
def reset(self) -> None:
|
||||
if self._active:
|
||||
self._abort_release = True
|
||||
|
||||
self._active = False
|
||||
self._sub_handler.reset()
|
||||
277
inputremapper/injection/mapping_handlers/rel_to_rel_handler.py
Normal file
277
inputremapper/injection/mapping_handlers/rel_to_rel_handler.py
Normal file
@ -0,0 +1,277 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import math
|
||||
from typing import Dict, List
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import (
|
||||
EV_REL,
|
||||
REL_WHEEL,
|
||||
REL_HWHEEL,
|
||||
REL_WHEEL_HI_RES,
|
||||
REL_HWHEEL_HI_RES,
|
||||
)
|
||||
|
||||
from inputremapper import exceptions
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import (
|
||||
Mapping,
|
||||
REL_XY_SCALING,
|
||||
WHEEL_SCALING,
|
||||
WHEEL_HI_RES_SCALING,
|
||||
)
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
HandlerEnums,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
def is_wheel(event) -> bool:
|
||||
return event.type == EV_REL and event.code in (REL_WHEEL, REL_HWHEEL)
|
||||
|
||||
|
||||
def is_high_res_wheel(event) -> bool:
|
||||
return event.type == EV_REL and event.code in (REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES)
|
||||
|
||||
|
||||
class Remainder:
|
||||
_scale: float
|
||||
_remainder: float
|
||||
|
||||
def __init__(self, scale: float):
|
||||
self._scale = scale
|
||||
self._remainder = 0
|
||||
|
||||
def input(self, value: float) -> int:
|
||||
# if the mouse moves very slow, it might not move at all because of the
|
||||
# int-conversion (which is required when writing). store the remainder
|
||||
# (the decimal places) and add it up, until the mouse moves a little.
|
||||
scaled = value * self._scale + self._remainder
|
||||
self._remainder = math.fmod(scaled, 1)
|
||||
|
||||
return int(scaled)
|
||||
|
||||
|
||||
class RelToRelHandler(MappingHandler):
|
||||
"""Handler which transforms EV_REL to EV_REL events."""
|
||||
|
||||
_input_config: InputConfig # the relative movement we map
|
||||
|
||||
_max_observed_input: float
|
||||
|
||||
_transform: Transformation
|
||||
|
||||
_remainder: Remainder
|
||||
_wheel_remainder: Remainder
|
||||
_wheel_hi_res_remainder: Remainder
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combination: InputCombination,
|
||||
mapping: Mapping,
|
||||
global_uinputs: GlobalUInputs,
|
||||
**_,
|
||||
) -> None:
|
||||
super().__init__(combination, mapping, global_uinputs)
|
||||
|
||||
assert self.mapping.output_code is not None
|
||||
|
||||
# find the input event we are supposed to map. If the input combination is
|
||||
# BTN_A + REL_X + BTN_B, then use the value of REL_X for the transformation
|
||||
input_config = combination.find_analog_input_config(type_=EV_REL)
|
||||
assert input_config is not None
|
||||
self._input_config = input_config
|
||||
|
||||
self._max_observed_input = 1
|
||||
|
||||
self._remainder = Remainder(REL_XY_SCALING)
|
||||
self._wheel_remainder = Remainder(WHEEL_SCALING)
|
||||
self._wheel_hi_res_remainder = Remainder(WHEEL_HI_RES_SCALING)
|
||||
|
||||
self._transform = Transformation(
|
||||
max_=1,
|
||||
min_=-1,
|
||||
deadzone=self.mapping.deadzone,
|
||||
gain=self.mapping.gain,
|
||||
expo=self.mapping.expo,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"RelToRelHandler for {self._input_config} "
|
||||
f"maps to: {self.mapping.output_code} at {self.mapping.target_uinput}"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{str(self)} at {hex(id(self))}>"
|
||||
|
||||
def get_children(self) -> List[MappingHandler]:
|
||||
return []
|
||||
|
||||
def _should_map(self, event: InputEvent):
|
||||
"""Check if this input event is relevant for this handler."""
|
||||
return event.input_match_hash == self._input_config.input_match_hash
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
if not self._should_map(event):
|
||||
return False
|
||||
"""
|
||||
There was the idea to define speed as "movemnt per second".
|
||||
There are deprecated mapping variables in this explanation.
|
||||
|
||||
rel2rel example:
|
||||
- input every 0.1s (`input_rate` of 10 events/s), value of 200
|
||||
- input speed is 2000, because in 1 second a value of 2000 acumulates
|
||||
- `input_rel_speed` is a const defined as 4000 px/s, how fast mice usually move
|
||||
- `transformed = Transformation(input.value, max=input_rel_speed / input_rate)`
|
||||
- get 0.5 because the expo is 0
|
||||
- `abs_to_rel_speed` is 5000
|
||||
- inject 2500 therefore per second, making it a bit faster
|
||||
- divide 2500 by the rate of 10 to inject a value of 250 each time input occurs
|
||||
|
||||
```
|
||||
output_value = Transformation(
|
||||
input.value,
|
||||
max=input_rel_speed / input_rate
|
||||
) * abs_to_rel_speed / input_rate
|
||||
```
|
||||
|
||||
The input_rel_speed could be used here instead of abs_to_rel_speed, because the
|
||||
gain already controls the speed. In that case it would be a 1:1 ratio of
|
||||
input-to-output value if the gain is 1.
|
||||
|
||||
for wheel and wheel_hi_res, different input speed constants must be set.
|
||||
|
||||
abs2rel needs a base value for the output, so `abs_to_rel_speed` is still
|
||||
required.
|
||||
`abs_to_rel_speed / rel_rate * transform(input.value, max=absinfo.max)`
|
||||
is the output value. Both abs_to_rel_speed and the transformation-gain control
|
||||
speed.
|
||||
|
||||
if abs_to_rel_speed controls speed in the abs2rel output, it should also do so
|
||||
in other handlers that have EV_REL output.
|
||||
|
||||
unfortunately input_rate needs to be determined during runtime, which screws
|
||||
the overall speed up when slowly moving the input device in the beginning,
|
||||
because slow input is thought to be the regular input.
|
||||
|
||||
---
|
||||
|
||||
transforming from rate based to rate based speed values won't work well.
|
||||
|
||||
better to use fractional speed values.
|
||||
REL_X of 40 = REL_WHEEL of 1 = REL_WHEE_HI_RES of 1/120
|
||||
|
||||
this is why abs_to_rel_speed does not affect the rel_to_rel handler.
|
||||
|
||||
The expo calculation will be wrong in the beginning, because it is based on
|
||||
the highest observed value. The overall gain will be fine though.
|
||||
"""
|
||||
|
||||
input_value = float(event.value)
|
||||
|
||||
# scale down now, the remainder calculation scales up by the same factor later
|
||||
# depending on what kind of event this becomes.
|
||||
if event.is_wheel_event:
|
||||
input_value /= WHEEL_SCALING
|
||||
elif event.is_wheel_hi_res_event:
|
||||
input_value /= WHEEL_HI_RES_SCALING
|
||||
else:
|
||||
# even though the input rate is unknown we can apply REL_XY_SCALING, which
|
||||
# is based on 60hz or something, because the un-scaling also uses values
|
||||
# based on 60hz. So the rate cancels out
|
||||
input_value /= REL_XY_SCALING
|
||||
|
||||
if abs(input_value) > self._max_observed_input:
|
||||
self._max_observed_input = abs(input_value)
|
||||
|
||||
# If _max_observed_input is wrong when the injection starts and the correct
|
||||
# value learned during runtime, results can be weird at the beginning.
|
||||
# If expo and deadzone are not set, then it is linear and doesn't matter.
|
||||
transformed = self._transform(input_value / self._max_observed_input)
|
||||
transformed *= self._max_observed_input
|
||||
|
||||
is_wheel_output = self.mapping.is_wheel_output()
|
||||
is_hi_res_wheel_output = self.mapping.is_high_res_wheel_output()
|
||||
|
||||
horizontal = self.mapping.output_code in (
|
||||
REL_HWHEEL_HI_RES,
|
||||
REL_HWHEEL,
|
||||
)
|
||||
|
||||
try:
|
||||
if is_wheel_output or is_hi_res_wheel_output:
|
||||
# inject both kinds of wheels, otherwise wheels don't work for some
|
||||
# people. See issue #354
|
||||
self._write(
|
||||
REL_HWHEEL if horizontal else REL_WHEEL,
|
||||
self._wheel_remainder.input(transformed),
|
||||
)
|
||||
self._write(
|
||||
REL_HWHEEL_HI_RES if horizontal else REL_WHEEL_HI_RES,
|
||||
self._wheel_hi_res_remainder.input(transformed),
|
||||
)
|
||||
else:
|
||||
self._write(
|
||||
self.mapping.output_code,
|
||||
self._remainder.input(transformed),
|
||||
)
|
||||
|
||||
return True
|
||||
except OverflowError:
|
||||
# screwed up the calculation of the event value
|
||||
logger.error("OverflowError while handling %s", event)
|
||||
return True
|
||||
except (exceptions.UinputNotAvailable, exceptions.EventNotHandled):
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
pass
|
||||
|
||||
def _write(self, code: int, value: int):
|
||||
if value == 0:
|
||||
# rel 0 does not make sense. We don't need to tell linux that the mouse
|
||||
# should not be moved this time.
|
||||
return
|
||||
|
||||
self.global_uinputs.write(
|
||||
(EV_REL, code, value),
|
||||
self.mapping.target_uinput,
|
||||
)
|
||||
|
||||
def needs_wrapping(self) -> bool:
|
||||
return len(self.input_configs) > 1
|
||||
|
||||
def set_sub_handler(self, handler: MappingHandler) -> None:
|
||||
assert False # cannot have a sub-handler
|
||||
|
||||
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
|
||||
if self.needs_wrapping():
|
||||
return {InputCombination(self.input_configs): HandlerEnums.axisswitch}
|
||||
return {}
|
||||
83
inputremapper/injection/numlock.py
Normal file
83
inputremapper/injection/numlock.py
Normal file
@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Functions to handle numlocks.
|
||||
|
||||
For unknown reasons the numlock status can change when starting injections,
|
||||
which is why these functions exist.
|
||||
"""
|
||||
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
def is_numlock_on():
|
||||
"""Get the current state of the numlock."""
|
||||
try:
|
||||
xset_q = subprocess.check_output(
|
||||
["xset", "q"],
|
||||
stderr=subprocess.STDOUT,
|
||||
).decode()
|
||||
num_lock_status = re.search(r"Num Lock:\s+(.+?)\s", xset_q)
|
||||
|
||||
if num_lock_status is not None:
|
||||
return num_lock_status[1] == "on"
|
||||
|
||||
return False
|
||||
except (FileNotFoundError, subprocess.CalledProcessError):
|
||||
# tty
|
||||
return None
|
||||
|
||||
|
||||
def set_numlock(state):
|
||||
"""Set the numlock to a given state of True or False."""
|
||||
if state is None:
|
||||
return
|
||||
|
||||
value = {True: "on", False: "off"}[state]
|
||||
|
||||
try:
|
||||
subprocess.check_output(["numlockx", value])
|
||||
except subprocess.CalledProcessError:
|
||||
# might be in a tty
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
# doesn't seem to be installed everywhere
|
||||
logger.debug("numlockx not found")
|
||||
|
||||
|
||||
def ensure_numlock(func):
|
||||
"""Decorator to reset the numlock to its initial state afterwards."""
|
||||
|
||||
def wrapped(*args, **kwargs):
|
||||
# for some reason, grabbing a device can modify the num lock state.
|
||||
# remember it and apply back later
|
||||
numlock_before = is_numlock_on()
|
||||
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
set_numlock(numlock_before)
|
||||
|
||||
return result
|
||||
|
||||
return wrapped
|
||||
264
inputremapper/input_event.py
Normal file
264
inputremapper/input_event.py
Normal 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
|
||||
)
|
||||
30
inputremapper/installation_info.py
Normal file
30
inputremapper/installation_info.py
Normal 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"
|
||||
24
inputremapper/ipc/__init__.py
Normal file
24
inputremapper/ipc/__init__.py
Normal 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
183
inputremapper/ipc/pipe.py
Normal 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__()
|
||||
122
inputremapper/ipc/shared_dict.py
Normal file
122
inputremapper/ipc/shared_dict.py
Normal 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
Reference in New Issue
Block a user