Merge pull request #641 from sezanzeb/beta

Merge `beta` into `main`
pull/646/head
Tobi 2 years ago committed by GitHub
commit d5ec0383ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -6,3 +6,12 @@ debug = multiproc
omit =
# not used currently due to problems
/usr/lib/python3.9/site-packages/inputremapper/ipc/socket.py
[report]
exclude_lines =
pragma: no cover
# Don't complain about abstract methods, they aren't run:
@(abc\.)?abstractmethod
# Don't cover Protocol classes
class .*\(.*Protocol.*\):

@ -8,7 +8,7 @@ jobs:
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
strategy:
matrix:
python-version: ["3.10"]
python-version: ["3.11"]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
@ -21,5 +21,5 @@ jobs:
pip install black
- name: Analysing the code with black --check --diff
run: |
black --version
black --check --diff ./inputremapper ./tests

@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
python-version: ["3.11"]
steps:
- uses: actions/checkout@v2
@ -26,7 +26,7 @@ jobs:
shell: bash
run: |
scripts/ci-install-deps.sh
pip install flake8 pylint mypy black
pip install flake8 pylint mypy black types-pkg_resources
- name: Set env for PR
if: github.event_name == 'pull_request'
shell: bash

@ -9,7 +9,7 @@ jobs:
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
strategy:
matrix:
python-version: ["3.7", "3.10"] # min and max supported versions?
python-version: ["3.7", "3.11"] # min and max supported versions?
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}

8
.gitignore vendored

@ -3,7 +3,8 @@ inputremapper/commit_hash.py
*.glade~
*.glade#
.idea
*.png~
*.png~*
*.orig
# Byte-compiled / optimized / DLL files
__pycache__/
@ -21,7 +22,7 @@ dist/
downloads/
eggs/
.eggs/
lib/
/lib/
lib64/
parts/
sdist/
@ -58,6 +59,9 @@ coverage.xml
.hypothesis/
.pytest_cache/
# pyreverse graphs
*.dot
# Translations
*.mo

@ -0,0 +1,7 @@
[mypy]
plugins = pydantic.mypy
# ignore the missing evdev stubs
[mypy-evdev.*]
ignore_missing_imports = True

@ -2,7 +2,8 @@
max-line-length=88 # black
extension-pkg-whitelist=evdev
extension-pkg-whitelist=evdev, pydantic
load-plugins=pylint_pydantic
disable=
# that is the standard way to import GTK afaik

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="All Tests" type="tests" factoryName="Autodetect">
<configuration default="false" name="All Tests" type="tests" factoryName="Unittests">
<module name="input-remapper" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
@ -8,6 +8,7 @@
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="_new_pattern" value="&quot;&quot;" />
<option name="_new_additionalArguments" value="&quot;&quot;" />
<option name="_new_target" value="&quot;$PROJECT_DIR$/tests&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />

@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Only Integration Tests" type="tests" factoryName="Unittests">
<module name="input-remapper" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/tests" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="_new_pattern" value="&quot;&quot;" />
<option name="_new_additionalArguments" value="&quot;&quot;" />
<option name="_new_target" value="&quot;$PROJECT_DIR$/tests/integration&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method v="2" />
</configuration>
</component>

@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Only Unit Tests" type="tests" factoryName="Unittests">
<module name="input-remapper" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/tests" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="_new_pattern" value="&quot;&quot;" />
<option name="_new_additionalArguments" value="&quot;&quot;" />
<option name="_new_target" value="&quot;$PROJECT_DIR$/tests/unit&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method v="2" />
</configuration>
</component>

@ -1,5 +1,5 @@
Package: input-remapper
Version: 1.5.0
Version: 2.0.0-rc
Architecture: all
Maintainer: Sezanzeb <proxima@sezanzeb.de>
Depends: build-essential, libpython3-dev, libdbus-1-dev, python3, python3-setuptools, python3-evdev, python3-pydbus, python3-gi, gettext, python3-cairo, libgtk-3-0, libgtksourceview-4-dev, python3-pydantic

@ -1,3 +1,3 @@
Files: *
Copyright: 2021 sezanzeb
Copyright: 2023 Sezanzeb
License: GPL-3+

@ -2,24 +2,29 @@
<h1 align="center">Input Remapper</h1>
<p align="center"><b>Formerly Key Mapper</b></p>
<p align="center">
An easy to use tool to change the mapping of your input device buttons.<br/>
Supports mice, keyboards, gamepads, X11, Wayland, combined buttons and programmable macros.<br/>
Allows mapping non-keyboard events (click, joystick, wheel) to keys of keyboard devices.
An easy to use tool to change the behaviour of your input devices.<br/>
Supports X11, Wayland, combinations, programmable macros, joysticks, wheels,<br/>
triggers, keys, mouse-movements and more. Maps any input to any other input.
</p>
<p align="center"><a href="readme/usage.md">Usage</a> - <a href="readme/macros.md">Macros</a> - <a href="#installation">Installation</a> - <a href="readme/development.md">Development</a> - <a href="#screenshots">Screenshots</a> - <a href="readme/examples.md">Examples</a></p>
<p align="center"><a href="readme/usage.md">Usage</a> - <a href="readme/macros.md">Macros</a> - <a href="#installation">Installation</a> - <a href="readme/development.md">Development</a> - <a href="readme/examples.md">Examples</a></p>
<p align="center"><img src="readme/pylint.svg"/> <img src="readme/coverage.svg"/></p>
<p align="center">
<img src="readme/screenshot.png" width="48%"/>
&#160;
<img src="readme/screenshot_2.png" width="48%"/>
</p>
## Installation
##### Manjaro/Arch
```bash
pacaur -S input-remapper-git
yay -S input-remapper-git
```
##### Ubuntu/Debian
@ -31,11 +36,11 @@ or install the latest changes via:
sudo apt install git python3-setuptools gettext
git clone https://github.com/sezanzeb/input-remapper.git
cd input-remapper && ./scripts/build.sh
sudo apt install ./dist/input-remapper-1.5.0.deb
sudo apt install -f ./dist/input-remapper-2.0.0-rc.deb
```
input-remapper is now part of [Debian Unstable](https://packages.debian.org/sid/input-remapper)
and of [Ubuntu](https://packages.ubuntu.com/jammy/input-remapper)
input-remapper is available in [Debian](https://tracker.debian.org/pkg/input-remapper)
and [Ubuntu](https://packages.ubuntu.com/jammy/input-remapper)
##### Manual
@ -43,6 +48,10 @@ Dependencies: `python3-evdev` ≥1.3.0, `gtksourceview4`, `python3-devel`, `pyth
Python packages need to be installed globally for the service to be able to import them. Don't use `--user`
Conda can cause problems due to changed python paths and versions.
If it doesn't seem to install, you can also try `sudo python3 setup.py install`
```bash
sudo pip install evdev -U # If newest version not in distros repo
sudo pip uninstall key-mapper # In case the old package is still installed
@ -51,20 +60,14 @@ sudo systemctl enable input-remapper
sudo systemctl restart input-remapper
```
If it doesn't seem to install, you can also try `sudo python3 setup.py install`
## Migrating beta configs to version 2
##### Beta
By default, Input Remapper will not migrate configurations from the beta.
If you want to use those you will need to copy them manually.
The `beta` branch contains features that still require work, but that are ready for testing. It uses a different
config path, so your presets won't break. `input-remapper-beta-git` can be installed from the AUR. If you are
facing problems, please open up an [issue](https://github.com/sezanzeb/input-remapper/issues).
## Screenshots
<p align="center">
<img src="readme/screenshot.png"/>
</p>
```bash
rm ~/.config/input-remapper-2 -r
cp ~/.config/input-remapper/beta_1.6.0-beta ~/.config/input-remapper-2 -r
```
<p align="center">
<img src="readme/screenshot_2.png"/>
</p>
Then start input-remapper

@ -1,7 +1,7 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -43,7 +43,7 @@ HELLO = 'hello'
# internal stuff that the gui uses
START_DAEMON = 'start-daemon'
HELPER = 'helper'
START_READER_SERVICE = 'start-reader-service'
def run(cmd):
@ -56,7 +56,7 @@ def run(cmd):
COMMANDS = [AUTOLOAD, START, STOP, HELLO, STOP_ALL]
INTERNALS = [START_DAEMON, HELPER]
INTERNALS = [START_DAEMON, START_READER_SERVICE]
def utils(options):
@ -73,7 +73,7 @@ def utils(options):
def communicate(options, daemon):
"""Commands that require a running daemon"""
"""Commands that require a running daemon."""
# import stuff late to make sure the correct log level is applied
# before anything is logged
from inputremapper.groups import groups
@ -166,8 +166,8 @@ def internals(options):
"""
debug = ' -d' if options.debug else ''
if options.command == HELPER:
cmd = f'input-remapper-helper{debug}'
if options.command == START_READER_SERVICE:
cmd = f'input-remapper-reader-service{debug}'
elif options.command == START_DAEMON:
cmd = f'input-remapper-service --hide-info{debug}'
else:
@ -175,6 +175,7 @@ def internals(options):
# daemonize
cmd = f'{cmd} &'
logger.debug(f'Running `{cmd}`')
os.system(cmd)
@ -202,14 +203,16 @@ def _systemd_finished():
def boot_finished():
"""Check if booting is completed."""
# Get as much information as needed to really safely determine if booting up is complete.
# 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).
# 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 _systemd_finished():
logger.debug('Booting finished')
logger.debug('System is booted')
return True
if _num_logged_in_users() > 0:

@ -1,7 +1,7 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -18,14 +18,13 @@
# 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 sys
import atexit
from argparse import ArgumentParser
from inputremapper.gui.gettext import _, LOCALE_DIR
import gi
gi.require_version('Gtk', '3.0')
@ -33,12 +32,25 @@ 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
from inputremapper.logger import logger, update_verbosity, log_info
from inputremapper.configs.migrations import migrate
def start_processes() -> DaemonProxy:
"""Start reader-service and daemon via pkexec to run in the background."""
# this function is overwritten in tests
try:
ReaderService.pkexec_reader_service()
except Exception as e:
logger.error(e)
sys.exit(11)
return Daemon.connect()
if __name__ == '__main__':
@ -55,21 +67,42 @@ if __name__ == '__main__':
logger.debug('Using locale directory: {}'.format(LOCALE_DIR))
# import input-remapper stuff after setting the log verbosity
from inputremapper.gui.messages.message_broker import MessageBroker, MessageType
from inputremapper.configs.system_mapping import system_mapping
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
from inputremapper.groups import _Groups
from inputremapper.gui.reader_client import ReaderClient
from inputremapper.daemon import Daemon
from inputremapper.configs.global_config import global_config
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.configs.migrations import migrate
migrate()
global_config.load_config()
user_interface = UserInterface()
message_broker = MessageBroker()
# create the reader before we start the reader-service (start_processes) otherwise
# it can come to race conditions with the creation of pipes
reader_client = ReaderClient(message_broker, _Groups())
daemon = start_processes()
data_manager = DataManager(
message_broker, GlobalConfig(), reader_client, daemon, GlobalUInputs(), system_mapping
)
controller = Controller(message_broker, data_manager)
user_interface = UserInterface(message_broker, controller)
controller.set_gui(user_interface)
message_broker.signal(MessageType.init)
def stop():
if isinstance(user_interface.dbus, Daemon):
if isinstance(daemon, Daemon):
# have fun debugging completely unrelated tests if you remove this
user_interface.dbus.stop_all()
daemon.stop_all()
user_interface.on_close()
controller.close()
atexit.register(stop)

@ -1,7 +1,7 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -19,9 +19,8 @@
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Starts the root helper."""
"""Starts the root reader-service."""
import asyncio
import os
import sys
import atexit
@ -29,6 +28,7 @@ import signal
from argparse import ArgumentParser
from inputremapper.logger import update_verbosity
from inputremapper.groups import _Groups
if __name__ == '__main__':
@ -43,7 +43,7 @@ if __name__ == '__main__':
update_verbosity(options.debug)
# import input-remapper stuff after setting the log verbosity
from inputremapper.gui.helper import RootHelper
from inputremapper.gui.reader_service import ReaderService
def on_exit():
"""Don't remain idle and alive when the GUI exits via ctrl+c."""
@ -53,6 +53,7 @@ if __name__ == '__main__':
os.kill(os.getpid(), signal.SIGKILL)
atexit.register(on_exit)
helper = RootHelper()
helper.run()
groups = _Groups()
reader_service = ReaderService(groups)
loop = asyncio.get_event_loop()
loop.run_until_complete(reader_service.run())

@ -1,7 +1,7 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#

@ -1,7 +1,7 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#

@ -1,7 +1,7 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#

@ -1,7 +1,7 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#

File diff suppressed because it is too large Load Diff

@ -7,8 +7,9 @@
<action id="inputremapper">
<description>Run Input Remapper as root</description>
<message>Authentication is required to discover and read devices.</message>
<message xml:lang="sk">Vyžaduje sa prihlásenie na objavenie a prístup k zariadeniam.</message>
<message xml:lang="ru">Требуется аутентификация для обнаружения и чтения устройств.</message>
<message xml:lang="sk">Vyžaduje sa prihlásenie na objavenie a prístup k zariadeniam.</message>
<message xml:lang="ua">Потрібна автентифікація для виявлення та читання пристроїв.</message>
<message xml:lang="ru">Требуется аутентификация для обнаружения и чтения устройств.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>

@ -1,64 +1,16 @@
row {
padding: 0;
}
.status_bar frame {
/* the status bar is ugly in elementary os otherwise */
border: 0px;
}
/* adds a bottom border for themes that don't add one in primary-toolbar
classes. Interestingly, those that do add a border ignore this separator,
which is perfect. */
.top_separator {
border: 0px; /* fixes light pixels on each end in arc-dark */
}
.table-header, .row-box {
padding: 2px;
}
.changed {
background: @selected_bg_color;
}
list entry {
background-color: transparent;
border-radius: 4px;
border: 0px;
box-shadow: none;
}
list.basic-editor button:not(:focus) {
border-color: transparent;
background: transparent;
box-shadow: none;
}
list button {
border-color: transparent;
}
.invalid_input {
background-color: #ea9697;
}
.transparent {
background: transparent;
}
.code-editor-text-view > * {
border-radius: 2px;
}
.copyright {
font-size: 7pt;
}
.editor-key-list label {
padding: 11px;
}
.autocompletion label {
padding: 11px;
}
@ -68,14 +20,25 @@ list button {
box-shadow: none;
}
.no-border {
border: 0px;
box-shadow: none;
.no-v-padding {
padding-top: 0;
padding-bottom: 0;
}
.transformation-draw-area {
border: 1px solid @borders;
border-radius: 6px;
background: @theme_base_color;
}
.code-editor-text-view.multiline {
/* extra space between text editor and line numbers */
padding-left: 18px;
.multiline > *:first-child {
/* source view suddenly started showing a white background behind line-numbers */
/* solution found by furiously trying css rules out in the gtk inspector */
background: @theme_bg_color;
}
/* @theme_bg_color, @theme_fg_color */
/*
@theme_bg_color
@theme_selected_bg_color
@theme_base_color
*/

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -17,7 +17,10 @@
# 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 copy
from typing import Union, List, Optional, Callable, Any
from inputremapper.logger import logger, VERSION
@ -26,25 +29,6 @@ NONE = "none"
INITIAL_CONFIG = {
"version": VERSION,
"autoload": {},
"macros": {
# some time between keystrokes might be required for them to be
# detected properly in software.
"keystroke_sleep_ms": 10
},
"gamepad": {
"joystick": {
# very small movements of the joystick should result in very
# small mouse movements. With a non_linearity of 1 it is
# impossible/hard to even find a resting position that won't
# move the cursor.
"non_linearity": 4,
"pointer_speed": 80,
"left_purpose": NONE,
"right_purpose": NONE,
"x_scroll_speed": 2,
"y_scroll_speed": 0.5,
},
},
}
@ -55,27 +39,32 @@ class ConfigBase:
this base.
"""
def __init__(self, fallback=None):
def __init__(self, fallback: Optional[ConfigBase] = None):
"""Set up the needed members to turn your object into a config.
Parameters
----------
fallback : ConfigBase
fallback: ConfigBase
a configuration that contains fallback default configs, if your
object doesn't configure a certain key.
"""
self._config = {}
self.fallback = fallback
def _resolve(self, path, func, config=None):
def _resolve(
self,
path: Union[str, List[str]],
func: Callable,
config: Optional[dict] = None,
):
"""Call func for the given config value.
Parameters
----------
path : string or string[]
path
For example 'macros.keystroke_sleep_ms'
or ['macros', 'keystroke_sleep_ms']
config : dict
config
The dictionary to search. Defaults to self._config.
"""
chunks = path.copy() if isinstance(path, list) else path.split(".")
@ -98,12 +87,12 @@ class ConfigBase:
parent[chunk] = {}
child = parent[chunk]
def remove(self, path):
def remove(self, path: Union[str, List[str]]):
"""Remove a config key.
Parameters
----------
path : string or string[]
path
For example 'macros.keystroke_sleep_ms'
or ['macros', 'keystroke_sleep_ms']
"""
@ -114,15 +103,14 @@ class ConfigBase:
self._resolve(path, callback)
def set(self, path, value):
def set(self, path: Union[str, List[str]], value: Any):
"""Set a config key.
Parameters
----------
path : string or string[]
path
For example 'macros.keystroke_sleep_ms'
or ['macros', 'keystroke_sleep_ms']
value : any
"""
logger.info('Changing "%s" to "%s" in %s', path, value, self.__class__.__name__)
@ -131,14 +119,14 @@ class ConfigBase:
self._resolve(path, callback)
def get(self, path, log_unknown=True):
def get(self, path: Union[str, List[str]], log_unknown: bool = True):
"""Get a config value. If not set, return the default
Parameters
----------
path : string or string[]
path
For example 'macros.keystroke_sleep_ms'
log_unknown : bool
log_unknown
If True, write an error if `path` does not exist in the config
"""

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -21,37 +21,44 @@
"""Get stuff from /usr/share/input-remapper, depending on the prefix."""
import sys
import os
import site
import sys
import pkg_resources
from inputremapper.logger import logger
logged = False
def get_data_path(filename=""):
"""Depending on the installation prefix, return the data dir.
def _try_standard_locations():
"""Look for the data dir where it typically can be found."""
candidates = [
"/usr/share/input-remapper",
"/usr/local/share/input-remapper",
os.path.join(site.USER_BASE, "share/input-remapper"),
]
Since it is a nightmare to get stuff installed with pip across
distros this is somewhat complicated. Ubuntu uses /usr/local/share
for data_files (setup.py) and manjaro uses /usr/share.
"""
global logged
# try any of the options
for candidate in candidates:
if os.path.exists(candidate):
data = candidate
break
return data
def _try_python_package_location():
"""Look for the data dir at the packages installation location."""
source = None
try:
source = pkg_resources.require("input-remapper")[0].location
# failed in some ubuntu installations
except pkg_resources.DistributionNotFound:
logger.debug("DistributionNotFound")
pass
# depending on where this file is installed to, make sure to use the proper
# prefix path for data
# https://docs.python.org/3/distutils/setupscript.html?highlight=package_data#installing-additional-files # noqa pylint: disable=line-too-long
data = None
# python3.8/dist-packages python3.7/site-packages, /usr/share,
# /usr/local/share, endless options
@ -63,22 +70,27 @@ def get_data_path(filename=""):
logger.debug('-e, but data missing at "%s"', data)
data = None
candidates = [
"/usr/share/input-remapper",
"/usr/local/share/input-remapper",
os.path.join(site.USER_BASE, "share/input-remapper"),
]
return data
def get_data_path(filename=""):
"""Depending on the installation prefix, return the data dir.
Since it is a nightmare to get stuff installed with pip across
distros this is somewhat complicated. Ubuntu uses /usr/local/share
for data_files (setup.py) and manjaro uses /usr/share.
"""
global logged
# depending on where this file is installed to, make sure to use the proper
# prefix path for data
# https://docs.python.org/3/distutils/setupscript.html?highlight=package_data#installing-additional-files # noqa pylint: disable=line-too-long
data = _try_python_package_location() or _try_standard_locations()
if data is None:
# try any of the options
for candidate in candidates:
if os.path.exists(candidate):
data = candidate
break
if data is None:
logger.error("Could not find the application data")
sys.exit(10)
logger.error("Could not find the application data")
sys.exit(10)
if not logged:
logger.debug('Found data at "%s"', data)

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -18,13 +18,14 @@
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Store which presets should be enabled for which device on login."""
import os
import json
import copy
import json
import os
from typing import Optional
from inputremapper.configs.base_config import ConfigBase, INITIAL_CONFIG
from inputremapper.configs.paths import CONFIG_PATH, USER, touch
from inputremapper.logger import logger
from inputremapper.configs.base_config import ConfigBase, INITIAL_CONFIG
MOUSE = "mouse"
WHEEL = "wheel"
@ -44,15 +45,19 @@ class GlobalConfig(ConfigBase):
self.path = os.path.join(CONFIG_PATH, "config.json")
super().__init__()
def set_autoload_preset(self, group_key, preset):
def get_dir(self) -> str:
"""The folder containing this config."""
return os.path.split(self.path)[0]
def set_autoload_preset(self, group_key: str, preset: Optional[str]):
"""Set a preset to be automatically applied on start.
Parameters
----------
group_key : string
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 : string or None
preset
if None, don't autoload something for this device.
"""
if preset is not None:
@ -67,18 +72,18 @@ class GlobalConfig(ConfigBase):
"""Get tuples of (device, preset)."""
return self._config.get("autoload", {}).items()
def is_autoloaded(self, group_key, preset):
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.get(["autoload", group_key], log_unknown=False) == preset
def load_config(self, path=None):
def load_config(self, path: Optional[str] = None):
"""Load the config from the file system.
Parameters
----------
path : string or None
path
If set, will change the path to load from and save to.
"""
if path is not None:

@ -0,0 +1,431 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
from __future__ import annotations
import itertools
from typing import Tuple, Iterable, Union, List, Dict, Optional, Hashable, TypeAlias
from evdev import ecodes
from evdev._ecodes import EV_ABS, EV_KEY, EV_REL
from inputremapper.input_event import InputEvent
from pydantic import BaseModel, root_validator, validator
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.messages.message_types import MessageType
from inputremapper.logger import logger
from inputremapper.utils import get_evdev_constant_name
# 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,
]
DeviceHash: TypeAlias = str
EMPTY_TYPE = 99
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"
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_name(self) -> Optional[str]:
"""Human-readable name (e.g. KEY_A) of the specified input event."""
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 = system_mapping.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)
if isinstance(key_name, list):
key_name = key_name[0]
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:
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]
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)

@ -0,0 +1,482 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
from __future__ import annotations
import enum
from collections import namedtuple
from typing import Optional, Callable, Tuple, TypeVar, Union, Any, Dict
import pkg_resources
from evdev.ecodes import (
EV_KEY,
EV_ABS,
EV_REL,
REL_WHEEL,
REL_HWHEEL,
REL_HWHEEL_HI_RES,
REL_WHEEL_HI_RES,
)
from pydantic import (
BaseModel,
PositiveInt,
confloat,
conint,
root_validator,
validator,
ValidationError,
PositiveFloat,
VERSION,
BaseConfig,
)
from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.system_mapping import system_mapping, DISABLE_NAME
from inputremapper.configs.validation_errors import (
OutputSymbolUnknownError,
SymbolNotAvailableInTargetError,
OnlyOneAnalogInputError,
TriggerPointInRangeError,
OutputSymbolVariantError,
MacroButTypeOrCodeSetError,
SymbolAndCodeMismatchError,
MissingMacroOrKeyError,
MissingOutputAxisError,
MacroParsingError,
)
from inputremapper.gui.gettext import _
from inputremapper.gui.messages.message_types import MessageType
from inputremapper.injection.global_uinputs import can_default_uinput_emit
from inputremapper.injection.macros.parse import is_this_a_macro, parse
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 = pkg_resources.parse_version(
str(VERSION)
) < pkg_resources.parse_version("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
output_symbol: Optional[str] = None # The symbol or macro string if applicable
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 system_mapping
return None for unknown symbols and macros
"""
if self.output_code and self.output_type:
return self.output_type, self.output_code
if self.output_symbol and not is_this_a_macro(self.output_symbol):
return EV_KEY, system_mapping.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_code is not None and not output_symbol:
values["mapping_type"] = "analog"
if output_type is None and output_code is None and output_symbol:
values["mapping_type"] = "key_macro"
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 is_this_a_macro(symbol):
mapping_mock = namedtuple("Mapping", values.keys())(**values)
# raises MacroParsingError
parse(symbol, mapping=mapping_mock, verbose=False)
return values
code = system_mapping.get(symbol)
if code is None:
raise OutputSymbolUnknownError(symbol)
target = values.get("target_uinput")
if target is not None and not 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 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 != system_mapping.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()
use_as_analog = analog_input_config is not None
output_type = values.get("output_type")
output_symbol = values.get("output_symbol")
if not use_as_analog and not output_symbol and output_type != EV_KEY:
raise MissingMacroOrKeyError()
if (
use_as_analog
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_

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -17,34 +17,53 @@
# 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.
"""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 json
import copy
import shutil
import pkg_resources
from pathlib import Path
from evdev.ecodes import EV_KEY, EV_REL
from typing import Iterator, Tuple, Dict, List
from inputremapper.logger import logger, VERSION
from inputremapper.user import HOME
import pkg_resources
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 inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping, UIMapping
from inputremapper.configs.paths import get_preset_path, mkdir, CONFIG_PATH, remove
from inputremapper.configs.preset import Preset
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.injection.global_uinputs import global_uinputs
from inputremapper.injection.macros.parse import is_this_a_macro
from inputremapper.logger import logger, VERSION
from inputremapper.user import HOME
def all_presets():
def all_presets() -> Iterator[Tuple[os.PathLike, Dict | List]]:
"""Get all presets for all groups as list."""
if not os.path.exists(get_preset_path()):
return []
return
preset_path = Path(get_preset_path())
presets = []
for folder in preset_path.iterdir():
if not folder.is_dir():
continue
@ -55,14 +74,12 @@ def all_presets():
try:
with open(preset, "r") as f:
preset_dict = json.load(f)
yield preset, preset_dict
preset_structure = json.load(f)
yield preset, preset_structure
except json.decoder.JSONDecodeError:
logger.warning('Invalid json format in preset "%s"', preset)
continue
return presets
def config_version():
"""Get the version string in config.json as packaging.Version object."""
@ -92,7 +109,7 @@ def _config_suffix():
def _preset_path():
"""Migrate the folder structure from < 0.4.0.
Move existing presets into the new subfolder "presets"
Move existing presets into the new subfolder 'presets'
"""
new_preset_folder = os.path.join(CONFIG_PATH, "presets")
if os.path.exists(get_preset_path()) or not os.path.exists(CONFIG_PATH):
@ -114,18 +131,27 @@ def _preset_path():
def _mapping_keys():
"""Update all preset mappings.
Update all keys in preset to include value e.g.: "1,5"->"1,5,1"
Update all keys in preset to include value e.g.: '1,5'->'1,5,1'
"""
for preset, preset_dict in all_presets():
if "mapping" in preset_dict.keys():
mapping = copy.deepcopy(preset_dict["mapping"])
for preset, preset_structure in 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_dict["mapping"][f"{key},1"] = preset_dict["mapping"].pop(key)
preset_structure["mapping"][f"{key},1"] = preset_structure[
"mapping"
].pop(key)
changes += 1
with open(preset, "w") as file:
json.dump(preset_dict, file, indent=4)
file.write("\n")
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():
@ -134,16 +160,16 @@ def _update_version():
if not os.path.exists(config_file):
return
logger.info("Updating version in config to %s", VERSION)
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_config():
def _rename_to_input_remapper():
"""Rename .config/key-mapper to .config/input-remapper."""
old_config_path = os.path.join(HOME, ".config/key-mapper")
if not os.path.exists(CONFIG_PATH) and os.path.exists(old_config_path):
@ -152,7 +178,7 @@ def _rename_config():
def _find_target(symbol):
"""try to find a uinput with the required capabilities for the symbol."""
"""Try to find a uinput with the required capabilities for the symbol."""
capabilities = {EV_KEY: set(), EV_REL: set()}
if is_this_a_macro(symbol):
@ -169,25 +195,32 @@ def _find_target(symbol):
if capabilities[EV_KEY].issubset(uinput.capabilities()[EV_KEY]):
return name
logger.info("could not find a suitable target UInput for '%s'", symbol)
logger.info('could not find a suitable target UInput for "%s"', symbol)
return None
def _add_target():
"""add the target field to each preset mapping"""
for preset, preset_dict in all_presets():
if "mapping" not in preset_dict.keys():
"""Add the target field to each preset mapping."""
for preset, preset_structure in all_presets():
if isinstance(preset_structure, list):
continue
if "mapping" not in preset_structure.keys():
continue
changed = False
for key, symbol in preset_dict["mapping"].copy().items():
for key, symbol in preset_structure["mapping"].copy().items():
if isinstance(symbol, list):
continue
target = _find_target(symbol)
if target is None:
target = "keyboard"
symbol = f"{symbol}\n# Broken mapping:\n# No target can handle all specified keycodes"
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"',
@ -196,25 +229,29 @@ def _add_target():
target,
)
symbol = [symbol, target]
preset_dict["mapping"][key] = symbol
preset_structure["mapping"][key] = symbol
changed = True
if not changed:
continue
with open(preset, "w") as file:
json.dump(preset_dict, file, indent=4)
logger.info('Adding targets for "%s"', preset)
json.dump(preset_structure, file, indent=4)
file.write("\n")
def _otherwise_to_else():
"""Conditional macros should use an "else" parameter instead of "otherwise"."""
for preset, preset_dict in all_presets():
if "mapping" not in preset_dict.keys():
for preset, preset_structure in all_presets():
if isinstance(preset_structure, list):
continue
if "mapping" not in preset_structure.keys():
continue
changed = False
for key, symbol in preset_dict["mapping"].copy().items():
for key, symbol in preset_structure["mapping"].copy().items():
if not is_this_a_macro(symbol[0]):
continue
@ -233,16 +270,196 @@ def _otherwise_to_else():
symbol[0],
)
preset_dict["mapping"][key] = symbol
preset_structure["mapping"][key] = symbol
if not changed:
continue
with open(preset, "w") as file:
json.dump(preset_dict, file, indent=4)
logger.info('Changing otherwise to else for "%s"', preset)
json.dump(preset_structure, file, indent=4)
file.write("\n")
def _input_combination_from_string(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():
"""Convert preset.json
from {key: [symbol, target]}
to [{input_combination: ..., output_symbol: symbol, ...}]
"""
for old_preset_path, old_preset in 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 = _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 = {
"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():
"""Move the beta config to the v2 path, or copy the v1 config to the v2 path."""
# TODO test
if os.path.exists(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(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 migrations.
logger.debug("copying all from %s to %s", old_path, CONFIG_PATH)
shutil.copytree(old_path, CONFIG_PATH)
return
# if v1 configs don't exist, try to find beta configs.
beta_path = os.path.join(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, CONFIG_PATH)
shutil.move(beta_path, CONFIG_PATH)
def _remove_logs():
"""We will try to rely on journalctl for this in the future."""
try:
@ -257,7 +474,13 @@ def _remove_logs():
def migrate():
"""Migrate config files to the current release."""
_rename_to_input_remapper()
_copy_to_v2()
v = config_version()
if v < pkg_resources.parse_version("0.4.0"):
_config_suffix()
_preset_path()
@ -265,9 +488,6 @@ def migrate():
if v < pkg_resources.parse_version("1.2.2"):
_mapping_keys()
if v < pkg_resources.parse_version("1.3.0"):
_rename_config()
if v < pkg_resources.parse_version("1.4.0"):
global_uinputs.prepare_all()
_add_target()
@ -275,7 +495,11 @@ def migrate():
if v < pkg_resources.parse_version("1.4.1"):
_otherwise_to_else()
_remove_logs()
if v < pkg_resources.parse_version("1.5.0"):
_remove_logs()
if v < pkg_resources.parse_version("1.6.0-beta"):
_convert_to_individual_mappings()
# add new migrations here

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -17,15 +17,21 @@
# 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.logger import logger
from inputremapper.user import USER, CONFIG_PATH
from inputremapper.logger import logger, VERSION
from inputremapper.user import USER, HOME
rel_path = ".config/input-remapper-2"
CONFIG_PATH = os.path.join(HOME, rel_path)
def chown(path):
@ -37,9 +43,9 @@ def chown(path):
shutil.chown(path, user=USER)
def touch(path, log=True):
def touch(path: Union[str, os.PathLike], log=True):
"""Create an empty file and all its parent dirs, give it to the user."""
if path.endswith("/"):
if str(path).endswith("/"):
raise ValueError(f"Expected path to not end with a slash: {path}")
if os.path.exists(path):
@ -74,8 +80,26 @@ def mkdir(path, log=True):
chown(path)
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
def remove(path):
"""Remove whatever is at the path"""
"""Remove whatever is at the path."""
if not os.path.exists(path):
return
@ -86,17 +110,17 @@ def remove(path):
def sanitize_path_component(group_name: str) -> str:
"""replace characters listed in
"""Replace characters listed in
https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words
with an underscore
with an underscore.
"""
for c in '/\\?%*:|"<>':
if c in group_name:
group_name = group_name.replace(c, "_")
for character in '/\\?%*:|"<>':
if character in group_name:
group_name = group_name.replace(character, "_")
return group_name
def get_preset_path(group_name=None, preset=None):
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(CONFIG_PATH, "presets")
@ -109,8 +133,8 @@ def get_preset_path(group_name=None, preset=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.
assert not preset.endswith(".json")
preset = f"{preset}.json"
if not preset.endswith(".json"):
preset = f"{preset}.json"
if preset is None:
return os.path.join(presets_base, group_name)
@ -118,6 +142,6 @@ def get_preset_path(group_name=None, preset=None):
return os.path.join(presets_base, group_name, preset)
def get_config_path(*paths):
"""Get a path in ~/.config/input-remapper/"""
def get_config_path(*paths) -> str:
"""Get a path in ~/.config/input-remapper/."""
return os.path.join(CONFIG_PATH, *paths)

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -16,398 +16,317 @@
#
# 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
"""Contains and manages mappings."""
import os
import re
import json
import glob
import time
from typing import Tuple, Dict, List
from evdev.ecodes import EV_KEY, BTN_LEFT
from __future__ import annotations
import json
import os
from typing import (
Tuple,
Dict,
List,
Optional,
Iterator,
Type,
TypeVar,
Generic,
overload,
)
from pydantic import ValidationError
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping, UIMapping
from inputremapper.configs.paths import touch
from inputremapper.logger import logger
from inputremapper.configs.paths import touch, get_preset_path, mkdir
from inputremapper.configs.global_config import global_config
from inputremapper.configs.base_config import ConfigBase
from inputremapper.event_combination import EventCombination
from inputremapper.injection.macros.parse import clean
from inputremapper.groups import groups
class Preset(ConfigBase):
"""Contains and manages mappings of a single preset."""
_mapping: Dict[EventCombination, Tuple[str, str]]
def __init__(self):
# a mapping of a EventCombination object to (symbol, target) tuple
self._mapping: Dict[EventCombination, Tuple[str, str]] = {}
self._changed = False
# are there actually any keys set in the preset file?
self.num_saved_keys = 0
super().__init__(fallback=global_config)
def __iter__(self) -> Preset._mapping.items:
"""Iterate over EventCombination objects and their mappings."""
return iter(self._mapping.items())
def __len__(self):
return len(self._mapping)
def set(self, *args):
"""Set a config value. See `ConfigBase.set`."""
self._changed = True
return super().set(*args)
def remove(self, *args):
"""Remove a config value. See `ConfigBase.remove`."""
self._changed = True
return super().remove(*args)
def change(self, new_combination, target, symbol, previous_combination=None):
"""Replace the mapping of a keycode with a different one.
MappingModel = TypeVar("MappingModel", bound=UIMapping)
Parameters
----------
new_combination : EventCombination
target : string
name of target uinput
symbol : string
A single symbol known to xkb or linux.
Examples: KEY_KP1, Shift_L, a, B, BTN_LEFT.
previous_combination : EventCombination or None
the previous combination
If not set, will not remove any previous mapping. If you recently
used (1, 10, 1) for new_key and want to overwrite that with
(1, 11, 1), provide (1, 10, 1) here.
"""
if not isinstance(new_combination, EventCombination):
raise TypeError(
f"Expected {new_combination} to be a EventCombination object"
)
if symbol is None or symbol.strip() == "":
raise ValueError("Expected `symbol` not to be empty")
if target is None or target.strip() == "":
raise ValueError("Expected `target` not to be None")
target = target.strip()
symbol = symbol.strip()
output = (symbol, target)
if previous_combination is None and self._mapping.get(new_combination):
# the combination didn't change
previous_combination = new_combination
key_changed = new_combination != previous_combination
if not key_changed and (symbol, target) == self._mapping.get(new_combination):
# nothing was changed, no need to act
return
self.clear(new_combination) # this also clears all equivalent keys
logger.debug('changing %s to "%s"', new_combination, clean(symbol))
self._mapping[new_combination] = output
if key_changed and previous_combination is not None:
# clear previous mapping of that code, because the line
# representing that one will now represent a different one
self.clear(previous_combination)
self._changed = True
class Preset(Generic[MappingModel]):
"""Contains and manages mappings of a single preset."""
def has_unsaved_changes(self):
# 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._changed
def set_has_unsaved_changes(self, changed):
"""Write down if there are unsaved changes, or if they have been saved."""
self._changed = changed
return self._mappings != self._saved_mappings
def clear(self, combination):
"""Remove a keycode from the preset.
def remove(self, combination: InputCombination) -> None:
"""Remove a mapping from the preset by providing the InputCombination."""
Parameters
----------
combination : EventCombination
"""
if not isinstance(combination, EventCombination):
if not isinstance(combination, InputCombination):
raise TypeError(
f"Expected combination to be a EventCombination object but got {combination}"
f"combination must by of type InputCombination, got {type(combination)}"
)
for permutation in combination.get_permutations():
if permutation in self._mapping:
logger.debug("%s cleared", permutation)
del self._mapping[permutation]
self._changed = True
# there should be only one variation of the permutations
# in the preset actually
def empty(self):
"""Remove all mappings and custom configs without saving."""
self._mapping = {}
self._changed = True
self.clear_config()
def load(self, path):
"""Load a dumped JSON from home to overwrite the mappings.
Parameters
path : string
Path of the preset file
"""
logger.info('Loading preset from "%s"', path)
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",
)
if not os.path.exists(path):
raise FileNotFoundError(f'Tried to load non-existing preset "{path}"')
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._changed = False
self._saved_mappings = {}
self.path = None
with open(path, "r") as file:
preset_dict = json.load(file)
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 isinstance(preset_dict.get("mapping"), dict):
logger.error(
"Expected mapping to be a dict, but was %s. "
'Invalid preset config at "%s"',
preset_dict.get("mapping"),
path,
)
return
if not self.path or not os.path.exists(self.path):
raise FileNotFoundError(f'Tried to load non-existing preset "{self.path}"')
for combination, symbol in preset_dict["mapping"].items():
try:
combination = EventCombination.from_string(combination)
except ValueError as error:
logger.error(str(error))
continue
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
if isinstance(symbol, list):
symbol = tuple(symbol) # use a immutable type
touch(self.path)
if not self.has_unsaved_changes():
logger.debug("Not saving unchanged preset")
return
logger.debug("%s maps to %s", combination, symbol)
self._mapping[combination] = symbol
logger.info("Saving preset to %s", self.path)
# add any metadata of the preset
for key in preset_dict:
if key == "mapping":
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
self._config[key] = preset_dict[key]
self._changed = False
self.num_saved_keys = len(self)
def save(self, path):
"""Dump as JSON into home."""
logger.info("Saving preset to %s", path)
touch(path)
with open(path, "w") as file:
if self._config.get("mapping") is not None:
logger.error(
'"mapping" is reserved and cannot be used as config ' "key: %s",
self._config.get("mapping"),
)
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
preset_dict = self._config.copy() # shallow copy
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)
# make sure to keep the option to add metadata if ever needed,
# so put the mapping into a special key
json_ready_mapping = {}
# tuple keys are not possible in json, encode them as string
for combination, value in self._mapping.items():
new_key = combination.json_str()
json_ready_mapping[new_key] = value
saved_mappings[combination] = mapping.copy()
saved_mappings[combination].remove_combination_changed_callback()
preset_dict["mapping"] = json_ready_mapping
json.dump(preset_dict, file, indent=4)
with open(self.path, "w") as file:
json.dump(preset_list, file, indent=4)
file.write("\n")
self._changed = False
self.num_saved_keys = len(self)
self._saved_mappings = saved_mappings
def get_mapping(self, combination: EventCombination):
"""Read the (symbol, target)-tuple that is mapped to this keycode.
def is_valid(self) -> bool:
return False not in [mapping.is_valid() for mapping in self]
Parameters
----------
combination : EventCombination
"""
if not isinstance(combination, EventCombination):
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"Expected combination to be a EventCombination object but got {combination}"
f"combination must by of type InputCombination, got {type(combination)}"
)
for permutation in combination.get_permutations():
existing = self._mapping.get(permutation)
existing = self._mappings.get(permutation)
if existing is not None:
return existing
logger.error(
"Combination %s not found. Available: %s",
repr(combination),
list(
self._mappings.keys(),
),
)
return None
def dangerously_mapped_btn_left(self):
def dangerously_mapped_btn_left(self) -> bool:
"""Return True if this mapping disables BTN_Left."""
if self.get_mapping(EventCombination([EV_KEY, BTN_LEFT, 1])) is not None:
values = [value[0].lower() for value in self._mapping.values()]
return "btn_left" not in values
return False
###########################################################################
# Method from previously presets.py
# TODO: See what can be implemented as classmethod or
# member function of Preset
###########################################################################
def get_available_preset_name(group_name, preset="new preset", copy=False):
"""Increment the preset name until it is available."""
if group_name is None:
# endless loop otherwise
raise ValueError("group_name may not be None")
preset = preset.strip()
if copy and not re.match(r"^.+\scopy( \d+)?$", preset):
preset = f"{preset} copy"
# find a name that is not already taken
if os.path.exists(get_preset_path(group_name, preset)):
# if there already is a trailing number, increment it instead of
# adding another one
match = re.match(r"^(.+) (\d+)$", preset)
if match:
preset = match[1]
i = int(match[2]) + 1
else:
i = 2
while os.path.exists(get_preset_path(group_name, f"{preset} {i}")):
i += 1
return f"{preset} {i}"
return preset
def get_presets(group_name: str) -> List[str]:
"""Get all preset filenames for the device and user, starting with the newest.
Parameters
----------
group_name : string
"""
device_folder = get_preset_path(group_name)
mkdir(device_folder)
paths = glob.glob(os.path.join(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 presets
def get_any_preset() -> Tuple[str | None, str | None]:
"""Return the first found tuple of (device, preset)."""
group_names = groups.list_group_names()
if len(group_names) == 0:
return None, None
any_device = list(group_names)[0]
any_preset = (get_presets(any_device) or [None])[0]
return any_device, any_preset
def find_newest_preset(group_name=None):
"""Get a tuple of (device, preset) that was most recently modified
in the users home directory.
If no device has been configured yet, return an arbitrary device.
Parameters
----------
group_name : string
If set, will return the newest preset for the device or None
"""
# sort the oldest files to the front in order to use pop to get the newest
if group_name is None:
paths = sorted(
glob.glob(os.path.join(get_preset_path(), "*/*.json")), key=os.path.getmtime
)
else:
paths = sorted(
glob.glob(os.path.join(get_preset_path(group_name), "*.json")),
key=os.path.getmtime,
if InputCombination([InputConfig.btn_left()]) not in [
m.input_combination 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
)
if len(paths) == 0:
logger.debug("No presets found")
return get_any_preset()
group_names = groups.list_group_names()
newest_path = None
while len(paths) > 0:
# take the newest path
path = paths.pop()
preset = os.path.split(path)[1]
group_name = os.path.split(os.path.split(path)[0])[1]
if group_name in group_names:
newest_path = path
break
if newest_path is None:
return get_any_preset()
preset = os.path.splitext(preset)[0]
logger.debug('The newest preset is "%s", "%s"', group_name, preset)
return group_name, preset
def delete_preset(group_name, preset):
"""Delete one of the users presets."""
preset_path = get_preset_path(group_name, preset)
if not os.path.exists(preset_path):
logger.debug('Cannot remove non existing path "%s"', preset_path)
return
logger.info('Removing "%s"', preset_path)
os.remove(preset_path)
device_path = get_preset_path(group_name)
if os.path.exists(device_path) and len(os.listdir(device_path)) == 0:
logger.debug('Removing empty dir "%s"', device_path)
os.rmdir(device_path)
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
def rename_preset(group_name, old_preset_name, new_preset_name):
"""Rename one of the users presets while avoiding name conflicts."""
if new_preset_name == old_preset_name:
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", 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
new_preset_name = get_available_preset_name(group_name, new_preset_name)
logger.info('Moving "%s" to "%s"', old_preset_name, new_preset_name)
os.rename(
get_preset_path(group_name, old_preset_name),
get_preset_path(group_name, new_preset_name),
)
# set the modification date to now
now = time.time()
os.utime(get_preset_path(group_name, new_preset_name), (now, now))
return new_preset_name

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -16,18 +16,17 @@
#
# 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 re
import json
import re
import subprocess
from typing import Optional, List, Iterable, Tuple
import evdev
from inputremapper.logger import logger
from inputremapper.configs.paths import get_config_path, touch
from inputremapper.logger import logger
from inputremapper.utils import is_service
DISABLE_NAME = "disable"
@ -39,35 +38,37 @@ XKB_KEYCODE_OFFSET = 8
XMODMAP_FILENAME = "xmodmap.json"
LAZY_LOAD = None
class SystemMapping:
"""Stores information about all available keycodes."""
def __init__(self):
"""Construct the system_mapping."""
self._mapping = None
self._xmodmap = None
self._case_insensitive_mapping = None
_mapping: Optional[dict] = LAZY_LOAD
_xmodmap: Optional[List[Tuple[str, str]]] = LAZY_LOAD
_case_insensitive_mapping: Optional[dict] = LAZY_LOAD
def __getattribute__(self, wanted):
def __getattribute__(self, wanted: str):
"""To lazy load system_mapping info only when needed.
For example, this helps to keep logs of input-remapper-control clear when it
doesnt need it the information.
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 None:
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=None):
"""Return a list of all possible names in the mapping, optionally filtered by codes.
def list_names(self, codes: Optional[Iterable[int]] = None) -> List[str]:
"""Get all possible names in the mapping, optionally filtered by codes.
Parameters
----------
@ -78,13 +79,50 @@ class SystemMapping:
return [name for name, code in self._mapping.items() if code in codes]
def correct_case(self, symbol):
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 = get_config_path(XMODMAP_FILENAME)
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")
@ -93,47 +131,18 @@ class SystemMapping:
if not is_service():
# xmodmap is only available from within the login session.
# The service that runs via systemd can't use this.
xmodmap_dict = {}
try:
xmodmap = subprocess.check_output(
["xmodmap", "-pke"], stderr=subprocess.STDOUT
).decode()
xmodmap = xmodmap
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")
except FileNotFoundError:
logger.info(
"Optional `xmodmap` command not found. This is not critical."
)
except subprocess.CalledProcessError as e:
logger.error('Call to `xmodmap -pke` failed with "%s"', e)
# Clients usually take care of that, don't let the service do funny things.
# Write this stuff into the input-remapper config directory, because
# the systemd service won't know the user sessions xmodmap.
path = get_config_path(XMODMAP_FILENAME)
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)
self._use_xmodmap_symbols()
for name, ecode in evdev.ecodes.ecodes.items():
if name.startswith("KEY") or name.startswith("BTN"):
self._set(name, ecode)
self._use_linux_evdev_symbols()
self._set(DISABLE_NAME, DISABLE_CODE)
def update(self, mapping):
def update(self, mapping: dict):
"""Update this with new keys.
Parameters
----------
mapping : dict
mapping
maps from name to code. Make sure your keys are lowercase.
"""
len_before = len(self._mapping)
@ -144,12 +153,12 @@ class SystemMapping:
"Updated keycodes with %d new ones", len(self._mapping) - len_before
)
def _set(self, name, code):
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):
def get(self, name: str) -> int:
"""Return the code mapped to the key."""
# the correct casing should be shown when asking the system_mapping
# for stuff. indexing case insensitive to support old presets.
@ -165,15 +174,31 @@ class SystemMapping:
for key in keys:
del self._mapping[key]
def get_name(self, code):
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) == list:
return btn_name[0]
else:
return btn_name
key_name = evdev.ecodes.KEY.get(code, None)
if key_name is not None:
if type(key_name) == list:
return key_name[0]
else:
return key_name
return None
def _find_legit_mappings(self):
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:

@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
"""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.system_mapping import system_mapping
from inputremapper.injection.global_uinputs import find_fitting_default_uinputs
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 = system_mapping.get(symbol)
fitting_targets = 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} -> {system_mapping.get(symbol)} "
f"but output_code is {code} -> {system_mapping.get_name(code)} "
)
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 MacroParsingError(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

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -24,20 +24,22 @@ https://github.com/LEW21/pydbus/tree/cc407c8b1d25b7e28a6d661a29f9e661b1c9b964/ex
"""
import atexit
import json
import os
import sys
import json
import time
import atexit
from pathlib import PurePath
from typing import Protocol, Dict, Optional
from pydbus import SystemBus
import gi
from pydbus import SystemBus
gi.require_version("GLib", "2.0")
from gi.repository import GLib
from inputremapper.logger import logger, is_debug
from inputremapper.injection.injector import Injector, UNKNOWN
from inputremapper.injection.injector import Injector, InjectorState
from inputremapper.configs.preset import Preset
from inputremapper.configs.global_config import global_config
from inputremapper.configs.system_mapping import system_mapping
@ -61,16 +63,16 @@ class AutoloadHistory:
# preset of device -> (timestamp, preset)
self._autoload_history = {}
def remember(self, group_key, preset):
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):
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, preset):
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
@ -104,6 +106,7 @@ class AutoloadHistory:
def remove_timeout(func):
"""Remove timeout to ensure the call works if the daemon is not a proxy."""
# the timeout kwarg is a feature of pydbus. This is needed to make tests work
# that create a Daemon by calling its constructor instead of using pydbus.
def wrapped(*args, **kwargs):
@ -115,6 +118,34 @@ def remove_timeout(func):
return wrapped
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:
...
class Daemon:
"""Starts injecting keycodes based on the configuration.
@ -135,7 +166,7 @@ class Daemon:
</method>
<method name='get_state'>
<arg type='s' name='group_key' direction='in'/>
<arg type='i' name='response' direction='out'/>
<arg type='s' name='response' direction='out'/>
</method>
<method name='start_injecting'>
<arg type='s' name='group_key' direction='in'/>
@ -163,7 +194,7 @@ class Daemon:
def __init__(self):
"""Constructs the daemon."""
logger.debug("Creating daemon")
self.injectors = {}
self.injectors: Dict[str, Injector] = {}
self.config_dir = None
@ -183,17 +214,16 @@ class Daemon:
macro_variables.start()
@classmethod
def connect(cls, fallback=True):
def connect(cls, fallback: bool = True) -> DaemonProxy:
"""Get an interface to start and stop injecting keystrokes.
Parameters
----------
fallback : bool
If true, returns an instance of the daemon instead if it cannot
connect
fallback
If true, starts the daemon via pkexec if it cannot connect.
"""
bus = SystemBus()
try:
bus = SystemBus()
interface = bus.get(BUS_NAME, timeout=BUS_TIMEOUT)
logger.info("Connected to the service")
except GLib.GError as error:
@ -251,19 +281,18 @@ class Daemon:
logger.debug("Running daemon")
loop.run()
def refresh(self, group_key=None):
def refresh(self, group_key: Optional[str] = None):
"""Refresh groups if the specified group is unknown.
Parameters
----------
group_key : str
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 little bit of time until devices are visible after
# changes
# it may take a bit of time until devices are visible after changes
time.sleep(0.1)
groups.refresh()
self.refreshed_devices_at = now
@ -275,24 +304,25 @@ class Daemon:
groups.refresh()
self.refreshed_devices_at = now
def stop_injecting(self, group_key):
def stop_injecting(self, group_key: str):
"""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
'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):
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 UNKNOWN
return injector.get_state() if injector else InjectorState.UNKNOWN
@remove_timeout
def set_config_dir(self, config_dir):
def set_config_dir(self, config_dir: str):
"""All future operations will use this config dir.
Existing injections (possibly of the previous user) will be kept
@ -300,11 +330,11 @@ class Daemon:
Parameters
----------
config_dir : string
config_dir
This path contains config.json, xmodmap.json and the
presets directory
"""
config_path = os.path.join(config_dir, "config.json")
config_path = PurePath(config_dir, "config.json")
if not os.path.exists(config_path):
logger.error('"%s" does not exist', config_path)
return
@ -312,12 +342,12 @@ class Daemon:
self.config_dir = config_dir
global_config.load_config(config_path)
def _autoload(self, group_key):
def _autoload(self, group_key: str):
"""Check if autoloading is a good idea, and if so do it.
Parameters
----------
group_key : str
group_key
unique identifier used by the groups object
"""
self.refresh(group_key)
@ -353,14 +383,14 @@ class Daemon:
self.autoload_history.remember(group.key, preset)
@remove_timeout
def autoload_single(self, group_key):
def autoload_single(self, group_key: str):
"""Inject the configured autoload preset for the device.
If the preset is already being injected, it won't autoload it again.
Parameters
----------
group_key : str
group_key
unique identifier used by the groups object
"""
# avoid some confusing logs and filter obviously invalid requests
@ -403,7 +433,7 @@ class Daemon:
for group_key, _ in autoload_presets:
self._autoload(group_key)
def start_injecting(self, group_key, preset):
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
@ -411,9 +441,9 @@ class Daemon:
Parameters
----------
group_key : string
group_key
The unique key of the group
preset : string
preset_name
The name of the preset
"""
logger.info('Request to start injecting for "%s"', group_key)
@ -433,31 +463,13 @@ class Daemon:
logger.error('Could not find group "%s"', group_key)
return False
preset_path = os.path.join(
preset_path = PurePath(
self.config_dir,
"presets",
sanitize_path_component(group.name),
f"{preset}.json",
f"{preset_name}.json",
)
preset = Preset()
try:
preset.load(preset_path)
except FileNotFoundError as error:
logger.error(str(error))
return False
for event_combination, (symbol, target) 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.
global_uinputs.prepare_single(target)
if self.injectors.get(group_key) is not None:
self.stop_injecting(group_key)
# 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
@ -469,12 +481,37 @@ class Daemon:
# date when the system layout changes.
xmodmap = json.load(file)
logger.debug('Using keycodes from "%s"', xmodmap_path)
# this creates the system_mapping._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 system_mapping?
# this kind of bug is stupid to track down
system_mapping.get_name(0)
system_mapping.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.
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)
injector.start()
@ -492,7 +529,7 @@ class Daemon:
for group_key in list(self.injectors.keys()):
self.stop_injecting(group_key)
def hello(self, out):
def hello(self, out: str):
"""Used for tests."""
logger.info('Received "%s" from client', out)
return out

@ -1,221 +0,0 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.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/>.
from __future__ import annotations
import itertools
from typing import Tuple, Iterable
import evdev
from evdev import ecodes
from inputremapper.logger import logger
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.input_event import InputEvent
from inputremapper.exceptions import InputEventCreationError
# 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,
]
class EventCombination(Tuple[InputEvent]):
"""one or multiple InputEvent objects for use as an unique identifier for mappings"""
# tuple is immutable, therefore we need to override __new__()
# https://jfine-python-classes.readthedocs.io/en/latest/subclass-tuple.html
def __new__(cls, *init_args) -> EventCombination:
events = []
for init_arg in init_args:
event = None
for constructor in InputEvent.__get_validators__():
try:
event = constructor(init_arg)
break
except InputEventCreationError:
pass
if event:
events.append(event)
else:
raise ValueError(f"failed to create InputEvent with {init_arg = }")
return super().__new__(cls, events)
def __str__(self):
# only used in tests and logging
return f"<EventCombination {', '.join([str(e.event_tuple) for e in self])}>"
@classmethod
def __get_validators__(cls):
"""used by pydantic to create EventCombination objects"""
yield cls.from_string
yield cls.from_events
@classmethod
def from_string(cls, init_string: str) -> EventCombination:
init_args = init_string.split("+")
return cls(*init_args)
@classmethod
def from_events(
cls, init_events: Iterable[InputEvent | evdev.InputEvent]
) -> EventCombination:
return cls(*init_events)
def contains_type_and_code(self, type, code) -> bool:
"""if a InputEvent contains the type and code"""
for event in self:
if event.type_and_code == (type, code):
return True
return False
def is_problematic(self):
"""Is this combination going to work properly on all systems?"""
if len(self) <= 1:
return False
for event in self:
if event.type != ecodes.EV_KEY:
continue
if event.code in DIFFICULT_COMBINATIONS:
return True
return False
def get_permutations(self):
"""Get a list of EventCombination objects 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]
permutations = []
for permutation in itertools.permutations(self[:-1]):
permutations.append(EventCombination(*permutation, self[-1]))
return permutations
def json_str(self) -> str:
return "+".join([event.json_str() for event in self])
def beautify(self) -> str:
"""Get a human readable string representation."""
result = []
for event in self:
if event.type not in ecodes.bytype:
logger.error("Unknown type for %s", event)
result.append(str(event.code))
continue
if event.code not in ecodes.bytype[event.type]:
logger.error("Unknown combination code for %s", event)
result.append(str(event.code))
continue
key_name = None
# first try to find the name in xmodmap to not display wrong
# names due to the keyboard layout
if event.type == ecodes.EV_KEY:
key_name = system_mapping.get_name(event.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 = ecodes.bytype[event.type][event.code]
if isinstance(key_name, list):
key_name = key_name[0]
if event.type != ecodes.EV_KEY:
direction = {
# 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((event.code, event.value))
if direction is not None:
key_name += f" {direction}"
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")
key_name = key_name.replace("ABS_HAT0Y", "DPad")
key_name = key_name.replace("ABS_HAT1X", "DPad 2")
key_name = key_name.replace("ABS_HAT1Y", "DPad 2")
key_name = key_name.replace("ABS_HAT2X", "DPad 3")
key_name = key_name.replace("ABS_HAT2Y", "DPad 3")
key_name = key_name.replace("ABS_X", "Joystick")
key_name = key_name.replace("ABS_Y", "Joystick")
key_name = key_name.replace("ABS_RX", "Joystick 2")
key_name = key_name.replace("ABS_RY", "Joystick 2")
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(" ", " ")
result.append(key_name)
return " + ".join(result)

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -18,28 +18,48 @@
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Exceptions specific to inputremapper"""
"""Exceptions specific to inputremapper."""
class Error(Exception):
"""Base class for exceptions in inputremapper
"""Base class for exceptions in inputremapper.
we can catch all inputremapper exceptions with this
We can catch all inputremapper exceptions with this.
"""
pass
class UinputNotAvailable(Error):
def __init__(self, name):
"""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):
def __init__(self, msg):
"""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)

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -17,7 +17,6 @@
# 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,
@ -29,15 +28,20 @@ events are being read from all of the paths of an individual group in the gui
and the injector.
"""
from __future__ import annotations
import re
import multiprocessing
import threading
import asyncio
import enum
import json
from typing import List
import multiprocessing
import os
import re
import threading
import traceback
from typing import List, Optional
import evdev
from evdev import InputDevice
from evdev.ecodes import (
EV_KEY,
EV_ABS,
@ -52,9 +56,9 @@ from evdev.ecodes import (
REL_WHEEL,
)
from inputremapper.logger import logger
from inputremapper.configs.paths import get_preset_path
from inputremapper.logger import logger
from inputremapper.utils import get_device_hash
TABLET_KEYS = [
evdev.ecodes.BTN_STYLUS,
@ -63,13 +67,15 @@ TABLET_KEYS = [
evdev.ecodes.BTN_TOOL_RUBBER,
]
GAMEPAD = "gamepad"
KEYBOARD = "keyboard"
MOUSE = "mouse"
TOUCHPAD = "touchpad"
GRAPHICS_TABLET = "graphics-tablet"
CAMERA = "camera"
UNKNOWN = "unknown"
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"):
@ -155,7 +161,7 @@ def _is_camera(capabilities):
return key_capa and len(key_capa) == 1 and key_capa[0] == KEY_CAMERA
def classify(device):
def classify(device) -> DeviceType:
"""Figure out what kind of device this is.
Use this instead of functions like _is_keyboard to avoid getting false
@ -166,37 +172,37 @@ def classify(device):
if _is_graphics_tablet(capabilities):
# check this before is_gamepad to avoid classifying abs_x
# as joysticks when they are actually stylus positions
return GRAPHICS_TABLET
return DeviceType.GRAPHICS_TABLET
if _is_touchpad(capabilities):
return TOUCHPAD
return DeviceType.TOUCHPAD
if _is_gamepad(capabilities):
return GAMEPAD
return DeviceType.GAMEPAD
if _is_mouse(capabilities):
return MOUSE
return DeviceType.MOUSE
if _is_camera(capabilities):
return CAMERA
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 KEYBOARD
return DeviceType.KEYBOARD
return UNKNOWN
return DeviceType.UNKNOWN
DENYLIST = [".*Yubico.*YubiKey.*", "Eee PC WMI hotkeys"]
def is_denylisted(device):
def is_denylisted(device: InputDevice):
"""Check if a device should not be used in input-remapper.
Parameters
----------
device : InputDevice
device
"""
for name in DENYLIST:
if re.match(name, str(device.name), re.IGNORECASE):
@ -205,15 +211,11 @@ def is_denylisted(device):
return False
def get_unique_key(device):
def get_unique_key(device: 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.
Parameters
----------
device : InputDevice
"""
# Keys that should not be used:
# - device.phys is empty sometimes and varies across virtual
@ -252,18 +254,24 @@ class _Group:
presets folder structure
"""
def __init__(self, paths: List[str], names: List[str], types: List[str], key: str):
def __init__(
self,
paths: List[os.PathLike],
names: List[str],
types: List[DeviceType | str],
key: str,
):
"""Specify a group
Parameters
----------
paths : str[]
paths
Paths in /dev/input of the grouped devices
names : str[]
names
Names of the grouped devices
types : str[]
types
Types of the grouped devices
key : str
key
Unique identifier of the group.
It should be human readable and if possible equal to group.name.
@ -282,9 +290,9 @@ class _Group:
self.paths = paths
self.names = names
self.types = types
self.types = [DeviceType(type_) for type_ in types]
def get_preset_path(self, preset=None):
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
@ -292,20 +300,30 @@ class _Group:
"""
return 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)
dict(paths=self.paths, names=self.names, types=self.types, key=self.key),
)
@classmethod
def loads(cls, serialized):
def loads(cls, serialized: str):
"""Load a serialized representation."""
group = cls(**json.loads(serialized))
return group
def __repr__(self):
return f"Group({self.key})"
return f"<Group ({self.key}) at {hex(id(self))}>"
class _FindGroups(threading.Thread):
@ -316,12 +334,12 @@ class _FindGroups(threading.Thread):
slowing down the initialization.
"""
def __init__(self, pipe):
def __init__(self, pipe: multiprocessing.Pipe):
"""Construct the process.
Parameters
----------
pipe : multiprocessing.Pipe
pipe
used to communicate the result
"""
self.pipe = pipe
@ -347,7 +365,12 @@ class _FindGroups(threading.Thread):
# without setting an error"
# - "FileNotFoundError: [Errno 2] No such file or directory:
# '/dev/input/event12'"
logger.error("Failed to access %s: %s", path, str(error))
logger.error(
'Failed to access path "%s": %s %s',
path,
error.__class__.__name__,
str(error),
)
continue
if device.name == "Power Button":
@ -355,7 +378,7 @@ class _FindGroups(threading.Thread):
device_type = classify(device)
if device_type == CAMERA:
if device_type == DeviceType.CAMERA:
continue
# https://www.kernel.org/doc/html/latest/input/event-codes.html
@ -363,11 +386,13 @@ class _FindGroups(threading.Thread):
key_capa = capabilities.get(EV_KEY)
if key_capa is None and device_type != GAMEPAD:
if key_capa is None and device_type != DeviceType.GAMEPAD:
# skip devices that don't provide buttons 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)
@ -375,7 +400,12 @@ class _FindGroups(threading.Thread):
grouped[key] = []
logger.debug(
'Found "%s", "%s", "%s", type: %s', key, path, device.name, device_type
'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))
@ -400,14 +430,17 @@ class _FindGroups(threading.Thread):
key=key,
paths=devs,
names=names,
types=sorted(list({item[2] for item in group if item[2] != UNKNOWN})),
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 an take ages to complete in the thread
# destructors can go on and take ages to complete in the thread
# without blocking anything
@ -417,14 +450,14 @@ class _Groups:
def __init__(self):
self._groups: List[_Group] = None
def __getattribute__(self, key):
def __getattribute__(self, key: str):
"""To lazy load group info only when needed.
For example, this helps to keep logs of input-remapper-control clear when it doesnt
need it the information.
For example, this helps to keep logs of input-remapper-control clear when it
doesn't need it the information.
"""
if key == "_groups" and object.__getattribute__(self, "_groups") is None:
object.__setattr__(self, "_groups", {})
object.__setattr__(self, "_groups", [])
object.__getattribute__(self, "refresh")()
return object.__getattribute__(self, key)
@ -447,7 +480,7 @@ class _Groups:
keys = [f'"{group.key}"' for group in self._groups]
logger.info("Found %s", ", ".join(keys))
def filter(self, include_inputremapper=False):
def filter(self, include_inputremapper: bool = False) -> List[_Group]:
"""Filter groups."""
result = []
for group in self._groups:
@ -459,8 +492,9 @@ class _Groups:
return result
def set_groups(self, new_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]:
@ -481,21 +515,27 @@ class _Groups:
"""Create a deserializable string representation."""
return json.dumps([group.dumps() for group in self._groups])
def loads(self, dump):
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=None, key=None, path=None, include_inputremapper=False):
def find(
self,
name: Optional[str] = None,
key: Optional[str] = None,
path: Optional[str] = None,
include_inputremapper: bool = False,
) -> Optional[_Group]:
"""Find a group that matches the provided parameters.
Parameters
----------
name : str
name
"USB Keyboard"
Not unique, will return the first group that matches.
key : str
key
"USB Keyboard", "USB Keyboard 2", ...
path : str
path
"/dev/input/event3"
"""
for group in self._groups:
@ -516,4 +556,5 @@ class _Groups:
return None
# TODO global objects are bad practice
groups = _Groups()

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

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -22,30 +22,36 @@
import re
from typing import Dict, Optional, List, Tuple
from gi.repository import Gdk, Gtk, GLib, GObject
from evdev.ecodes import EV_KEY
from gi.repository import Gdk, Gtk, GLib, GObject
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.controller import Controller
from inputremapper.configs.mapping import MappingData
from inputremapper.configs.system_mapping import system_mapping, DISABLE_NAME
from inputremapper.gui.components.editor import CodeEditor
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 (
FUNCTIONS,
TASK_FACTORIES,
get_macro_argument_names,
remove_comments,
)
from inputremapper.injection.global_uinputs import global_uinputs
from inputremapper.logger import logger
from inputremapper.gui.utils import debounce
# no deprecated shorthand function-names
FUNCTION_NAMES = [name for name in FUNCTIONS.keys() if len(name) > 1]
FUNCTION_NAMES = [name for name in TASK_FACTORIES.keys() if len(name) > 1]
# no deprecated functions
FUNCTION_NAMES.remove("ifeq")
Capabilities = Dict[int, List]
def _get_left_text(iter):
buffer = iter.get_buffer()
result = buffer.get_text(buffer.get_start_iter(), iter, True)
def _get_left_text(iter_: Gtk.TextIter) -> str:
buffer = iter_.get_buffer()
result = buffer.get_text(buffer.get_start_iter(), iter_, True)
result = remove_comments(result)
result = result.replace("\n", " ")
return result.lower()
@ -56,9 +62,9 @@ PARAMETER = r".*?[(,=+]\s*"
FUNCTION_CHAIN = r".*?\)\s*\.\s*"
def get_incomplete_function_name(iter):
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)
left_text = _get_left_text(iter_)
# match foo in:
# bar().foo
@ -76,9 +82,9 @@ def get_incomplete_function_name(iter):
return match[1]
def get_incomplete_parameter(iter):
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)
left_text = _get_left_text(iter_)
# match foo in:
# bar(foo
@ -87,7 +93,7 @@ def get_incomplete_parameter(iter):
# foo
# bar + foo
match = re.match(rf"(?:{PARAMETER}|^)(\w+)$", left_text)
logger.debug(f"get_incomplete_parameter text: %s match: %s", left_text, match)
logger.debug('get_incomplete_parameter text: "%s" match: %s', left_text, match)
if match is None:
return None
@ -95,7 +101,7 @@ def get_incomplete_parameter(iter):
return match[1]
def propose_symbols(text_iter, codes):
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)
@ -104,14 +110,16 @@ def propose_symbols(text_iter, codes):
incomplete_name = incomplete_name.lower()
names = list(system_mapping.list_names(codes=codes)) + [DISABLE_NAME]
return [
(name, name)
for name in list(system_mapping.list_names(codes=codes))
for name in names
if incomplete_name in name.lower() and incomplete_name != name.lower()
]
def propose_function_names(text_iter):
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)
@ -121,7 +129,7 @@ def propose_function_names(text_iter):
incomplete_name = incomplete_name.lower()
return [
(name, f"{name}({', '.join(get_macro_argument_names(FUNCTIONS[name]))})")
(name, f"{name}({', '.join(get_macro_argument_names(TASK_FACTORIES[name]))})")
for name in FUNCTION_NAMES
if incomplete_name in name.lower() and incomplete_name != name.lower()
]
@ -132,7 +140,7 @@ class SuggestionLabel(Gtk.Label):
__gtype_name__ = "SuggestionLabel"
def __init__(self, display_name, suggestion):
def __init__(self, display_name: str, suggestion: str):
super().__init__(label=display_name)
self.suggestion = suggestion
@ -144,15 +152,21 @@ class Autocompletion(Gtk.Popover):
"""
__gtype_name__ = "Autocompletion"
def __init__(self, text_input, target_selector):
_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
----------
text_input : Gtk.SourceView | Gtk.TextView
code_editor
The widget that contains the text that should be autocompleted
"""
super().__init__(
@ -163,10 +177,11 @@ class Autocompletion(Gtk.Popover):
constrain_to=Gtk.PopoverConstraint.NONE,
)
self.text_input = text_input
self.target_selector = target_selector
self._target_key_capabilities = []
target_selector.connect("changed", self._update_target_key_capabilities)
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,
@ -191,22 +206,27 @@ class Autocompletion(Gtk.Popover):
self.set_position(Gtk.PositionType.BOTTOM)
text_input.connect("key-press-event", self.navigate)
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
text_input.connect("focus-out-event", self.on_text_input_unfocus)
self.code_editor.gui.connect("focus-out-event", self.on_gtk_text_input_unfocus)
text_input.get_buffer().connect("changed", self.update)
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 on_text_input_unfocus(self, *_):
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 -
@ -214,7 +234,7 @@ class Autocompletion(Gtk.Popover):
# it must return FALSE so the text view gets the event as well"
return False
def navigate(self, _, event):
def navigate(self, _, event: Gdk.EventKey):
"""Using the keyboard to select an autocompletion suggestion."""
if not self.visible:
return
@ -275,7 +295,7 @@ class Autocompletion(Gtk.Popover):
# don't change editor contents
return Gdk.EVENT_STOP
def _scroll_to_row(self, row):
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)
@ -283,24 +303,48 @@ class Autocompletion(Gtk.Popover):
# 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:
y_offset = row.translate_coordinates(self.list_box, 0, 0)[1]
# 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()
if y_offset > current_y_scroll + (height - row_height):
vadjustment.set_value(y_offset - (height - row_height))
# 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 y_offset < current_y_scroll:
# scroll up because the element is not visible anymore
vadjustment.set_value(y_offset)
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.text_input.get_cursor_locations()[0]
return self.text_input.get_iter_at_location(cursor.x, cursor.y)[1]
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
@ -313,29 +357,34 @@ class Autocompletion(Gtk.Popover):
@debounce(100)
def update(self, *_):
"""Find new autocompletion suggestions and display them. Hide if none."""
if not self.text_input.is_focus():
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.text_input.get_cursor_locations()[0]
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.text_input.buffer_to_window_coords(
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.text_input.get_show_line_numbers():
cursor.x += 25
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)
@ -351,17 +400,23 @@ class Autocompletion(Gtk.Popover):
self.list_box.insert(label, -1)
label.show_all()
def _update_target_key_capabilities(self, *_):
target = self.target_selector.get_active_id()
self._target_key_capabilities = global_uinputs.get_uinput(
target
).capabilities()[EV_KEY]
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.text_input.get_buffer()
buffer = self.code_editor.gui.get_buffer()
# make sure to replace the complete unfinished word. Look to the right and
# remove whatever there is
@ -370,7 +425,7 @@ class Autocompletion(Gtk.Popover):
match = re.match(r"^(\w+)", right)
right = match[1] if match else ""
Gtk.TextView.do_delete_from_cursor(
self.text_input, Gtk.DeleteType.CHARS, len(right)
self.code_editor.gui, Gtk.DeleteType.CHARS, len(right)
)
# do the same to the left
@ -379,15 +434,19 @@ class Autocompletion(Gtk.Popover):
match = re.match(r".*?(\w+)$", re.sub("\n", " ", left))
left = match[1] if match else ""
Gtk.TextView.do_delete_from_cursor(
self.text_input, Gtk.DeleteType.CHARS, -len(left)
self.code_editor.gui, Gtk.DeleteType.CHARS, -len(left)
)
# insert the autocompletion
Gtk.TextView.do_insert_at_cursor(self.text_input, suggestion)
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, []
"suggestion-inserted",
Autocompletion,
GObject.SignalFlags.RUN_FIRST,
None,
[],
)

@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
"""Components used in multiple places."""
from __future__ import annotations
import gi
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))

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

File diff suppressed because it is too large Load Diff

@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
"""Components that wrap everything."""
from __future__ import annotations
import gi
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)

@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
"""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.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)

@ -0,0 +1,833 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
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,
)
import gi
from evdev.ecodes import EV_KEY, EV_REL, EV_ABS
from gi.repository import Gtk
from inputremapper.configs.mapping import (
MappingData,
UIMapping,
MacroButTypeOrCodeSetError,
SymbolAndCodeMismatchError,
MissingOutputAxisError,
MissingMacroOrKeyError,
OutputSymbolVariantError,
)
from inputremapper.configs.paths import sanitize_path_component
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.validation_errors import pydantify
from inputremapper.exceptions import DataManagementError
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.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):
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 _publish_mapping_errors_as_status_msg(self, *__):
"""Send mapping ValidationErrors to the MessageBroker."""
if not self.data_manager.active_preset:
return
if self.data_manager.active_preset.is_valid():
self.message_broker.publish(StatusData(CTX_MAPPING))
return
for mapping in self.data_manager.active_preset:
if not mapping.get_error():
continue
position = mapping.format_name()
error_strings = self._get_ui_error_strings(mapping)
tooltip = ""
if len(error_strings) == 0:
# shouldn't be possible to get to this point
logger.error("Expected an error")
return
elif 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]
self.show_status(
CTX_MAPPING,
msg.replace("\n", " "),
tooltip,
)
@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(MissingMacroOrKeyError) in error_type
and mapping.output_symbol is None
):
error_message = _(
"The input specifies a key or macro input, but no macro or key is "
"programmed."
)
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
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(" 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."
),
)
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 = 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):
"""Show if the injection was successfully started."""
self.message_broker.unsubscribe(self.show_injector_result)
state = msg.state
def running():
msg = _("Applied preset %s") % self.data_manager.active_preset.name
if self.data_manager.active_preset.get_mapping(
InputCombination([InputConfig.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
)
assert self.data_manager.active_preset # make mypy happy
state_calls: Dict[InjectorState, Callable] = {
InjectorState.RUNNING: running,
InjectorState.FAILED: partial(
self.show_status,
CTX_ERROR,
_("Failed to apply preset %s") % self.data_manager.active_preset.name,
),
InjectorState.NO_GRAB: partial(
self.show_status,
CTX_ERROR,
"The device was not grabbed",
"Either another application is already grabbing it, "
"your preset doesn't contain anything that is sent by the "
"device or your preset contains errors",
),
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"] == "analog":
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"] == "key_macro":
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

@ -0,0 +1,598 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
import glob
import os
import re
import time
from typing import Optional, List, Tuple, Set
import gi
from gi.repository import GLib
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.configs.mapping import UIMapping, MappingData
from inputremapper.configs.paths import get_preset_path, mkdir, split_all
from inputremapper.configs.preset import Preset
from inputremapper.configs.system_mapping import SystemMapping
from inputremapper.daemon import DaemonProxy
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.exceptions import DataManagementError
from inputremapper.gui.gettext import _
from inputremapper.groups import _Group
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.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,
system_mapping: SystemMapping,
):
self.message_broker = message_broker
self._reader_client = reader_client
self._daemon = daemon
self._uinputs = uinputs
self._system_mapping = system_mapping
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.filter())
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 = get_preset_path(self.active_group.name)
mkdir(device_folder)
paths = glob.glob(os.path.join(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 = []
for path in glob.glob(os.path.join(get_preset_path(), "*/*.json")):
if self._reader_client.groups.find(key=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 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")
paths = [
(path, os.path.getmtime(path))
for path in glob.glob(
os.path.join(get_preset_path(self.active_group.name), "*.json")
)
]
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(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(
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 = 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:
raise KeyError(
f"the mapping with {combination = } does not "
f"exist in the {self._active_preset.path}"
)
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 == 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 = 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 = 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 == 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 = 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 = 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._system_mapping.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.FAILED,
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)

@ -1,692 +0,0 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.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/>.
"""The editor with multiline code input, recording toggle and autocompletion."""
import re
import time
from gi.repository import Gtk, GLib, Gdk
from inputremapper.gui.gettext import _
from inputremapper.gui.editor.autocompletion import Autocompletion
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.active_preset import active_preset
from inputremapper.event_combination import EventCombination
from inputremapper.logger import logger
from inputremapper.gui.reader import reader
from inputremapper.gui.utils import CTX_KEYCODE, CTX_WARNING, CTX_ERROR
from inputremapper.injection.global_uinputs import global_uinputs
class SelectionLabel(Gtk.ListBoxRow):
"""One label per mapping in the preset.
This wrapper serves as a storage for the information the inherited label represents.
"""
__gtype_name__ = "SelectionLabel"
def __init__(self):
super().__init__()
self.combination = None
self.symbol = ""
label = Gtk.Label()
# Make the child label widget break lines, important for
# long combinations
label.set_line_wrap(True)
label.set_line_wrap_mode(2)
label.set_justify(Gtk.Justification.CENTER)
self.label = label
self.add(label)
self.show_all()
def set_combination(self, combination: EventCombination):
"""Set the combination this button represents
Parameters
----------
combination : EventCombination
"""
self.combination = combination
if combination:
self.label.set_label(combination.beautify())
else:
self.label.set_label(_("new entry"))
def get_combination(self) -> EventCombination:
return self.combination
def set_label(self, label):
return self.label.set_label(label)
def get_label(self):
return self.label.get_label()
def __str__(self):
return f"SelectionLabel({str(self.combination)})"
def __repr__(self):
return self.__str__()
def ensure_everything_saved(func):
"""Make sure the editor has written its changes to active_preset and save."""
def wrapped(self, *args, **kwargs):
if self.user_interface.preset_name:
self.gather_changes_and_save()
return func(self, *args, **kwargs)
return wrapped
SET_KEY_FIRST = _("Set the key first")
RECORD_ALL = float("inf")
RECORD_NONE = 0
class Editor:
"""Maintains the widgets of the editor."""
def __init__(self, user_interface):
self.user_interface = user_interface
self.autocompletion = None
self._setup_target_selector()
self._setup_source_view()
self._setup_recording_toggle()
self.window = self.get("window")
self.timeouts = [
GLib.timeout_add(100, self.check_add_new_key),
GLib.timeout_add(1000, self.update_toggle_opacity),
]
self.active_selection_label: SelectionLabel = None
selection_label_listbox = self.get("selection_label_listbox")
selection_label_listbox.connect("row-selected", self.on_mapping_selected)
self.device = user_interface.group
# keys were not pressed yet
self._input_has_arrived = False
self.record_events_until = RECORD_NONE
text_input = self.get_text_input()
text_input.connect("focus-out-event", self.on_text_input_unfocus)
delete_button = self.get_delete_button()
delete_button.connect("clicked", self._on_delete_button_clicked)
target_selector = self.get_target_selector()
target_selector.connect("changed", self._on_target_input_changed)
def __del__(self):
for timeout in self.timeouts:
GLib.source_remove(timeout)
self.timeouts = []
def _on_toggle_clicked(self, toggle, event=None):
if toggle.get_active():
self._show_press_key()
else:
self._show_change_key()
@ensure_everything_saved
def _on_toggle_unfocus(self, toggle, event=None):
toggle.set_active(False)
@ensure_everything_saved
def on_text_input_unfocus(self, *_):
"""When unfocusing the text it saves.
Input Remapper doesn't save the editor on change, because that would cause
an incredible amount of logs for every single input. The custom_mapping would
need to be changed, which causes two logs, then it has to be saved
to disk which is another two log messages. So every time a single character
is typed it writes 4 lines.
Instead, it will save the preset when it is really needed, i.e. when a button
that requires a saved preset is pressed. For this there exists the
@ensure_everything_saved decorator.
To avoid maybe forgetting to add this decorator somewhere, it will also save
when unfocusing the text input.
If the scroll wheel is used to interact with gtk widgets it won't unfocus,
so this focus-out handler is not the solution to everything as well.
One could debounce saving on text-change to avoid those logs, but that just
sounds like a huge source of race conditions and is also hard to test.
"""
pass # the decorator will be triggered
@ensure_everything_saved
def _on_target_input_changed(self, *_):
"""save when target changed"""
pass
def clear(self):
"""Clear all inputs, labels, etc. Reset the state.
This is really important to do before loading a different preset.
Otherwise the inputs will be read and then saved into the next preset.
"""
if self.active_selection_label:
self.set_combination(None)
self.disable_symbol_input(clear=True)
self.set_target_selection("keyboard") # sane default
self.disable_target_selector()
self._reset_keycode_consumption()
self.clear_mapping_list()
def clear_mapping_list(self):
"""Clear the labels from the mapping selection and add an empty one."""
selection_label_listbox = self.get("selection_label_listbox")
selection_label_listbox.forall(selection_label_listbox.remove)
self.add_empty()
selection_label_listbox.select_row(selection_label_listbox.get_children()[0])
def _setup_target_selector(self):
"""Prepare the target selector combobox"""
target_store = Gtk.ListStore(str)
for uinput in global_uinputs.devices:
target_store.append([uinput])
target_input = self.get_target_selector()
target_input.set_model(target_store)
renderer_text = Gtk.CellRendererText()
target_input.pack_start(renderer_text, False)
target_input.add_attribute(renderer_text, "text", 0)
target_input.set_id_column(0)
def _setup_recording_toggle(self):
"""Prepare the toggle button for recording key inputs."""
toggle = self.get_recording_toggle()
toggle.connect("focus-out-event", self._show_change_key)
toggle.connect("focus-in-event", self._show_press_key)
toggle.connect("clicked", self._on_toggle_clicked)
toggle.connect("focus-out-event", self._reset_keycode_consumption)
toggle.connect("focus-out-event", self._on_toggle_unfocus)
toggle.connect("toggled", self._on_recording_toggle_toggle)
# Don't leave the input when using arrow keys or tab. wait for the
# window to consume the keycode from the reader. I.e. a tab input should
# be recorded, instead of causing the recording to stop.
toggle.connect("key-press-event", lambda *args: Gdk.EVENT_STOP)
def _show_press_key(self, *args):
"""Show user friendly instructions."""
self.get_recording_toggle().set_label(_("Press Key"))
def _show_change_key(self, *args):
"""Show user friendly instructions."""
self.get_recording_toggle().set_label(_("Change Key"))
def _setup_source_view(self):
"""Prepare the code editor."""
source_view = self.get("code_editor")
# without this the wrapping ScrolledWindow acts weird when new lines are added,
# not offering enough space to the text editor so the whole thing is suddenly
# scrollable by a few pixels.
# Found this after making blind guesses with settings in glade, and then
# actually looking at the snaphot preview! In glades editor this didn have an
# effect.
source_view.set_resize_mode(Gtk.ResizeMode.IMMEDIATE)
source_view.get_buffer().connect("changed", self.show_line_numbers_if_multiline)
# Syntax Highlighting
# Thanks to https://github.com/wolfthefallen/py-GtkSourceCompletion-example
# language_manager = GtkSource.LanguageManager()
# fun fact: without saving LanguageManager into its own variable it doesn't work
# python = language_manager.get_language("python")
# source_view.get_buffer().set_language(python)
# TODO there are some similarities with python, but overall it's quite useless.
# commented out until there is proper highlighting for input-remappers syntax.
autocompletion = Autocompletion(source_view, self.get_target_selector())
autocompletion.set_relative_to(self.get("code_editor_container"))
autocompletion.connect("suggestion-inserted", self.gather_changes_and_save)
self.autocompletion = autocompletion
def show_line_numbers_if_multiline(self, *_):
"""Show line numbers if a macro is being edited."""
code_editor = self.get("code_editor")
symbol = self.get_symbol_input_text() or ""
if "\n" in symbol:
code_editor.set_show_line_numbers(True)
code_editor.set_monospace(True)
code_editor.get_style_context().add_class("multiline")
else:
code_editor.set_show_line_numbers(False)
code_editor.set_monospace(False)
code_editor.get_style_context().remove_class("multiline")
def get_delete_button(self):
return self.get("delete-mapping")
def check_add_new_key(self):
"""If needed, add a new empty mapping to the list for the user to configure."""
selection_label_listbox = self.get("selection_label_listbox")
selection_label_listbox = selection_label_listbox.get_children()
for selection_label in selection_label_listbox:
if selection_label.get_combination() is None:
# unfinished row found
break
else:
self.add_empty()
return True
def disable_symbol_input(self, clear=False):
"""Display help information and dont allow entering a symbol.
Without this, maybe a user enters a symbol or writes a macro, switches
presets accidentally before configuring the key and then it's gone. It can
only be saved to the preset if a key is configured. This avoids that pitfall.
"""
logger.debug("Disabling the text input")
text_input = self.get_text_input()
# beware that this also appeared to disable event listeners like
# focus-out-event:
text_input.set_sensitive(False)
text_input.set_opacity(0.5)
if clear or self.get_symbol_input_text() == "":
# don't overwrite user input
self.set_symbol_input_text(SET_KEY_FIRST)
def enable_symbol_input(self):
"""Don't display help information anymore and allow changing the symbol."""
logger.debug("Enabling the text input")
text_input = self.get_text_input()
text_input.set_sensitive(True)
text_input.set_opacity(1)
buffer = text_input.get_buffer()
symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True)
if symbol == SET_KEY_FIRST:
# don't overwrite user input
self.set_symbol_input_text("")
def disable_target_selector(self):
"""don't allow any selection"""
selector = self.get_target_selector()
selector.set_sensitive(False)
selector.set_opacity(0.5)
def enable_target_selector(self):
selector = self.get_target_selector()
selector.set_sensitive(True)
selector.set_opacity(1)
@ensure_everything_saved
def on_mapping_selected(self, _=None, selection_label=None):
"""One of the buttons in the left "combination" column was clicked.
Load the information from that mapping entry into the editor.
"""
self.active_selection_label = selection_label
if selection_label is None:
return
combination = selection_label.combination
self.set_combination(combination)
if combination is None:
self.disable_symbol_input(clear=True)
# default target should fit in most cases
self.set_target_selection("keyboard")
# symbol input disabled until a combination is configured
self.disable_target_selector()
# symbol input disabled until a combination is configured
else:
if active_preset.get_mapping(combination):
self.set_symbol_input_text(active_preset.get_mapping(combination)[0])
self.set_target_selection(active_preset.get_mapping(combination)[1])
self.enable_symbol_input()
self.enable_target_selector()
self.get("window").set_focus(self.get_text_input())
def add_empty(self):
"""Add one empty row for a single mapped key."""
selection_label_listbox = self.get("selection_label_listbox")
mapping_selection = SelectionLabel()
mapping_selection.set_label(_("new entry"))
mapping_selection.show_all()
selection_label_listbox.insert(mapping_selection, -1)
@ensure_everything_saved
def load_custom_mapping(self):
"""Display the entries in active_preset."""
selection_label_listbox = self.get("selection_label_listbox")
selection_label_listbox.forall(selection_label_listbox.remove)
for key, _ in active_preset:
selection_label = SelectionLabel()
selection_label.set_combination(key)
selection_label_listbox.insert(selection_label, -1)
self.check_add_new_key()
# select the first entry
selection_labels = selection_label_listbox.get_children()
if len(selection_labels) == 0:
self.add_empty()
selection_labels = selection_label_listbox.get_children()
selection_label_listbox.select_row(selection_labels[0])
def get_recording_toggle(self) -> Gtk.ToggleButton:
return self.get("key_recording_toggle")
def get_text_input(self):
return self.get("code_editor")
def get_target_selector(self):
return self.get("target-selector")
def set_combination(self, combination):
"""Show what the user is currently pressing in the user interface."""
self.active_selection_label.set_combination(combination)
if combination and len(combination) > 0:
self.enable_symbol_input()
else:
self.disable_symbol_input()
def get_combination(self):
"""Get the EventCombination object from the left column.
Or None if no code is mapped on this row.
"""
if self.active_selection_label is None:
return None
return self.active_selection_label.combination
def set_symbol_input_text(self, symbol):
self.get("code_editor").get_buffer().set_text(symbol or "")
# move cursor location to the beginning, like any code editor does
Gtk.TextView.do_move_cursor(
self.get("code_editor"),
Gtk.MovementStep.BUFFER_ENDS,
-1,
False,
)
def get_symbol_input_text(self):
"""Get the assigned symbol from the text input.
This might not be stored in active_preset yet, and might therefore also not
be part of the preset json file yet.
If there is no symbol, this returns None. This is important for some other
logic down the road in active_preset or something.
"""
buffer = self.get("code_editor").get_buffer()
symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True)
if symbol == SET_KEY_FIRST:
# not configured yet
return ""
return symbol.strip()
def set_target_selection(self, target):
selector = self.get_target_selector()
selector.set_active_id(target)
def get_target_selection(self):
return self.get_target_selector().get_active_id()
def get(self, name):
"""Get a widget from the window"""
return self.user_interface.builder.get_object(name)
def update_toggle_opacity(self):
"""If the key can't be mapped, grey it out.
During injection, when the device is grabbed and weird things are being
done, it is not possible.
"""
toggle = self.get_recording_toggle()
if not self.user_interface.can_modify_preset():
toggle.set_opacity(0.4)
else:
toggle.set_opacity(1)
return True
def _on_recording_toggle_toggle(self, toggle):
"""Refresh useful usage information."""
if not toggle.get_active():
# if more events arrive from the time when the toggle was still on,
# use them.
self.record_events_until = time.time()
return
self.record_events_until = RECORD_ALL
self._reset_keycode_consumption()
reader.clear()
if not self.user_interface.can_modify_preset():
# because the device is in grab mode by the daemon and
# therefore the original keycode inaccessible
logger.info("Cannot change keycodes while injecting")
self.user_interface.show_status(
CTX_ERROR, _('Use "Stop Injection" to stop before editing')
)
toggle.set_active(False)
def _on_delete_button_clicked(self, *_):
"""Destroy the row and remove it from the config."""
accept = Gtk.ResponseType.ACCEPT
if (
len(self.get_symbol_input_text()) > 0
and self._show_confirm_delete() != accept
):
return
key = self.get_combination()
if key is not None:
active_preset.clear(key)
# make sure there is no outdated information lying around in memory
self.set_combination(None)
self.load_custom_mapping()
def _show_confirm_delete(self):
"""Blocks until the user decided about an action."""
confirm_delete = self.get("confirm-delete")
text = _("Are you sure to delete this mapping?")
self.get("confirm-delete-label").set_text(text)
confirm_delete.show()
response = confirm_delete.run()
confirm_delete.hide()
return response
def gather_changes_and_save(self, *_):
"""Look into the ui if new changes should be written, and save the preset."""
# correct case
symbol = self.get_symbol_input_text()
target = self.get_target_selection()
if not symbol or not target:
return
correct_case = system_mapping.correct_case(symbol)
if symbol != correct_case:
self.get_text_input().get_buffer().set_text(correct_case)
# make sure the active_preset is up to date
key = self.get_combination()
if correct_case and key and target:
active_preset.change(key, target, correct_case)
# save to disk if required
if active_preset.has_unsaved_changes():
self.user_interface.save_preset()
def is_waiting_for_input(self):
"""Check if the user is trying to record buttons."""
return self.get_recording_toggle().get_active()
def should_record_combination(self, combination):
"""Check if the combination was written when the toggle was active."""
# At this point the toggle might already be off, because some keys that are
# used while the toggle was still on might cause the focus of the toggle to
# be lost, like multimedia keys. This causes the toggle to be disabled.
# Yet, this event should be mapped.
timestamp = max([event.timestamp() for event in combination])
return timestamp < self.record_events_until
def consume_newest_keycode(self, combination: EventCombination):
"""To capture events from keyboards, mice and gamepads."""
self._switch_focus_if_complete()
if combination is None:
return
if not self.should_record_combination(combination):
# the event arrived after the toggle has been deactivated
logger.debug("Recording toggle is not on")
return
if not isinstance(combination, EventCombination):
raise TypeError("Expected new_key to be a EventCombination object")
# keycode is already set by some other row
existing = active_preset.get_mapping(combination)
if existing is not None:
existing = list(existing)
existing[0] = re.sub(r"\s", "", existing[0])
msg = _('"%s" already mapped to "%s"') % (
combination.beautify(),
tuple(existing),
)
logger.info("%s %s", combination, msg)
self.user_interface.show_status(CTX_KEYCODE, msg)
return
if combination.is_problematic():
self.user_interface.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."),
)
# the newest_keycode is populated since the ui regularly polls it
# in order to display it in the status bar.
previous_key = self.get_combination()
# it might end up being a key combination, wait for more
self._input_has_arrived = True
# keycode didn't change, do nothing
if combination == previous_key:
logger.debug("%s didn't change", previous_key)
return
self.set_combination(combination)
symbol = self.get_symbol_input_text()
target = self.get_target_selection()
if not symbol:
# has not been entered yet
logger.debug("Symbol missing")
return
if not target:
logger.debug("Target missing")
return
# else, the keycode has changed, the symbol is set, all good
active_preset.change(
new_combination=combination,
target=target,
symbol=symbol,
previous_combination=previous_key,
)
def _switch_focus_if_complete(self):
"""If keys are released, it will switch to the text_input.
States:
1. not doing anything, waiting for the user to start using it
2. user focuses it, no keys pressed
3. user presses keys
4. user releases keys. no keys are pressed, just like in step 2, but this time
the focus needs to switch.
"""
if not self.is_waiting_for_input():
self._reset_keycode_consumption()
return
all_keys_released = reader.get_unreleased_keys() is None
if all_keys_released and self._input_has_arrived and self.get_combination():
logger.debug("Recording complete")
# A key was pressed and then released.
# Switch to the symbol. idle_add this so that the
# keycode event won't write into the symbol input as well.
window = self.user_interface.window
self.enable_symbol_input()
self.enable_target_selector()
GLib.idle_add(lambda: window.set_focus(self.get_text_input()))
if not all_keys_released:
# currently the user is using the widget, and certain keys have already
# reached it.
self._input_has_arrived = True
return
self._reset_keycode_consumption()
def _reset_keycode_consumption(self, *_):
self._input_has_arrived = False

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -17,11 +17,11 @@
# 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 gettext
import locale
import os.path
from inputremapper.configs.data import get_data_path
from argparse import ArgumentParser
APP_NAME = "input-remapper"
LOCALE_DIR = os.path.join(get_data_path(), "lang")

@ -1,234 +0,0 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 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 provide a key-logger that can be accessed by any user at all times,
whereas for the helper to start a password is needed and it stops when the ui
closes.
"""
import sys
import select
import multiprocessing
import subprocess
import time
import evdev
from evdev.ecodes import EV_KEY, EV_ABS
from inputremapper.ipc.pipe import Pipe
from inputremapper.logger import logger
from inputremapper.groups import groups
from inputremapper import utils
from inputremapper.user import USER
# received by the helper
CMD_TERMINATE = "terminate"
CMD_REFRESH_GROUPS = "refresh_groups"
# sent by the helper to the reader
MSG_GROUPS = "groups"
MSG_EVENT = "event"
def is_helper_running():
"""Check if the helper is running."""
try:
subprocess.check_output(["pgrep", "-f", "input-remapper-helper"])
except subprocess.CalledProcessError:
return False
return True
class RootHelper:
"""Client that runs as root and works for the GUI.
Sends device information and keycodes to the GUIs socket.
Commands are either numbers for generic commands,
or strings to start listening on a specific device.
"""
def __init__(self):
"""Construct the helper and initialize its sockets."""
self._results = Pipe(f"/tmp/input-remapper-{USER}/results")
self._commands = Pipe(f"/tmp/input-remapper-{USER}/commands")
self._send_groups()
self.group = None
self._pipe = multiprocessing.Pipe()
def run(self):
"""Start doing stuff. Blocks."""
logger.debug("Waiting for the first command")
# the reader will check for new commands later, once it is running
# it keeps running for one device or another.
select.select([self._commands], [], [])
# possibly an alternative to select:
"""while True:
if self._commands.poll():
break
time.sleep(0.1)"""
logger.debug("Starting mainloop")
while True:
self._read_commands()
self._start_reading()
def _send_groups(self):
"""Send the groups to the gui."""
logger.debug("Sending groups")
self._results.send({"type": MSG_GROUPS, "message": groups.dumps()})
def _read_commands(self):
"""Handle all unread commands."""
while self._commands.poll():
cmd = self._commands.recv()
logger.debug('Received command "%s"', cmd)
if cmd == CMD_TERMINATE:
logger.debug("Helper terminates")
sys.exit(0)
if cmd == CMD_REFRESH_GROUPS:
groups.refresh()
self._send_groups()
continue
group = groups.find(key=cmd)
if group is None:
groups.refresh()
group = groups.find(key=cmd)
if group is not None:
self.group = group
continue
logger.error('Received unknown command "%s"', cmd)
logger.debug("No more commands in pipe")
def _start_reading(self):
"""Tell the evdev lib to start looking for keycodes.
If read is called without prior start_reading, no keycodes
will be available.
This blocks forever until it discovers a new command on the socket.
"""
rlist = {}
if self.group is None:
logger.error("group is None")
return
virtual_devices = []
# Watch over each one of the potentially multiple devices per
# hardware
for path in self.group.paths:
try:
device = evdev.InputDevice(path)
except FileNotFoundError:
continue
if evdev.ecodes.EV_KEY in device.capabilities():
virtual_devices.append(device)
if len(virtual_devices) == 0:
logger.debug('No interesting device for "%s"', self.group.key)
return
for device in virtual_devices:
rlist[device.fd] = device
logger.debug(
'Starting reading keycodes from "%s"',
'", "'.join([device.name for device in virtual_devices]),
)
rlist[self._commands] = self._commands
while True:
ready_fds = select.select(rlist, [], [])
if len(ready_fds[0]) == 0:
# happens with sockets sometimes. Sockets are not stable and
# not used, so nothing to worry about now.
continue
for fd in ready_fds[0]:
if rlist[fd] == self._commands:
# all commands will cause the reader to start over
# (possibly for a different device).
# _read_commands will check what is going on
logger.debug("Stops reading due to new command")
return
device = rlist[fd]
try:
event = device.read_one()
if event:
self._send_event(event, device)
except OSError:
logger.debug('Device "%s" disappeared', device.path)
return
def _send_event(self, event, device):
"""Write the event into the pipe to the main process.
Parameters
----------
event : evdev.InputEvent
device : evdev.InputDevice
"""
# value: 1 for down, 0 for up, 2 for hold.
if event.type == EV_KEY and event.value == 2:
# ignore hold-down events
return
blacklisted_keys = [evdev.ecodes.BTN_TOOL_DOUBLETAP]
if event.type == EV_KEY and event.code in blacklisted_keys:
return
if event.type == EV_ABS:
abs_range = utils.get_abs_range(device, event.code)
event.value = utils.classify_action(event, abs_range)
else:
event.value = utils.classify_action(event)
self._results.send(
{
"type": MSG_EVENT,
"message": (event.sec, event.usec, event.type, event.code, event.value),
}
)

@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
import os.path
import re
import traceback
from collections import defaultdict, deque
from typing import (
Callable,
Dict,
Set,
Protocol,
Tuple,
Deque,
Any,
TYPE_CHECKING,
)
from inputremapper.gui.messages.message_types import MessageType
from inputremapper.logger import logger
if TYPE_CHECKING:
pass
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("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) == type(other) and self.message_type == other.message_type

@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
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("(\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

@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
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"

@ -1,251 +0,0 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.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/>.
"""Talking to the GUI helper that has root permissions.
see gui.helper.helper
"""
from typing import Optional
from evdev.ecodes import EV_REL
from inputremapper.input_event import InputEvent
from inputremapper.logger import logger
from inputremapper.event_combination import EventCombination
from inputremapper.groups import groups, GAMEPAD
from inputremapper.ipc.pipe import Pipe
from inputremapper.gui.helper import (
MSG_EVENT,
MSG_GROUPS,
CMD_TERMINATE,
CMD_REFRESH_GROUPS,
)
from inputremapper import utils
from inputremapper.gui.active_preset import active_preset
from inputremapper.user import USER
DEBOUNCE_TICKS = 3
def will_report_up(ev_type):
"""Check if this event will ever report a key up (wheels)."""
return ev_type != EV_REL
class Reader:
"""Processes events from the helper 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.
"""
def __init__(self):
self.previous_event = None
self.previous_result = None
self._unreleased = {}
self._debounce_remove = {}
self._groups_updated = False
self._cleared_at = 0
self.group = None
self._results = None
self._commands = None
self.connect()
def connect(self):
"""Connect to the helper."""
self._results = Pipe(f"/tmp/input-remapper-{USER}/results")
self._commands = Pipe(f"/tmp/input-remapper-{USER}/commands")
def are_new_groups_available(self):
"""Check if groups contains new devices.
The ui should then update its list.
"""
outdated = self._groups_updated
self._groups_updated = False # assume the ui will react accordingly
return outdated
def _get_event(self, message) -> Optional[InputEvent]:
"""Return an InputEvent if the message contains one. None otherwise."""
message_type = message["type"]
message_body = message["message"]
if message_type == MSG_GROUPS:
if message_body != groups.dumps():
groups.loads(message_body)
logger.debug("Received %d devices", len(groups))
self._groups_updated = True
return None
if message_type == MSG_EVENT:
return InputEvent(*message_body)
logger.error('Received unknown message "%s"', message)
return None
def read(self):
"""Get the newest key/combination as EventCombination object.
Only reports keys from down-events.
On key-down events the pipe returns changed combinations. Release
events won't cause that and the reader will return None as in
"nothing new to report". So In order to change a combination, one
of its keys has to be released and then a different one pressed.
Otherwise making combinations wouldn't be possible. Because at
some point the keys have to be released, and that shouldn't cause
the combination to get trimmed.
"""
# this is in some ways similar to the keycode_mapper and
# joystick_to_mouse, but its much simpler because it doesn't
# have to trigger anything, manage any macros and only
# reports key-down events. This function is called periodically
# by the window.
# remember the previous down-event from the pipe in order to
# be able to tell if the reader should return the updated combination
previous_event = self.previous_event
key_down_received = False
self._debounce_tick()
while self._results.poll():
message = self._results.recv()
event = self._get_event(message)
if event is None:
continue
gamepad = GAMEPAD in self.group.types
if not utils.should_map_as_btn(event, active_preset, gamepad):
continue
if event.value == 0:
logger.debug_key(event, "release")
self._release(event.type_and_code)
continue
if self._unreleased.get(event.type_and_code) == event:
logger.debug_key(event, "duplicate key down")
self._debounce_start(event.event_tuple)
continue
# to keep track of combinations.
# "I have got this release event, what was this for?" A release
# event for a D-Pad axis might be any direction, hence this maps
# from release to input in order to remember it. Since all release
# events have value 0, the value is not used in the combination.
key_down_received = True
logger.debug_key(event, "down")
self._unreleased[event.type_and_code] = event
self._debounce_start(event.event_tuple)
previous_event = event
if not key_down_received:
# This prevents writing a subset of the combination into
# result after keys were released. In order to control the gui,
# they have to be released.
return None
self.previous_event = previous_event
if len(self._unreleased) > 0:
result = EventCombination.from_events(self._unreleased.values())
if result == self.previous_result:
# don't return the same stuff twice
return None
self.previous_result = result
logger.debug_key(result, "read result")
return result
return None
def start_reading(self, group):
"""Start reading keycodes for a device."""
logger.debug('Sending start msg to helper for "%s"', group.key)
self._commands.send(group.key)
self.group = group
self.clear()
def terminate(self):
"""Stop reading keycodes for good."""
logger.debug("Sending close msg to helper")
self._commands.send(CMD_TERMINATE)
def refresh_groups(self):
"""Ask the helper for new device groups."""
self._commands.send(CMD_REFRESH_GROUPS)
def clear(self):
"""Next time when reading don't return the previous keycode."""
logger.debug("Clearing reader")
while self._results.poll():
# clear the results pipe and handle any non-event messages,
# otherwise a 'groups' message might get lost
message = self._results.recv()
self._get_event(message)
self._unreleased = {}
self.previous_event = None
self.previous_result = None
def get_unreleased_keys(self):
"""Get a EventCombination object of the current keyboard state."""
unreleased = list(self._unreleased.values())
if len(unreleased) == 0:
return None
return EventCombination.from_events(unreleased)
def _release(self, type_code):
"""Modify the state to recognize the releasing of the key."""
if type_code in self._unreleased:
del self._unreleased[type_code]
if type_code in self._debounce_remove:
del self._debounce_remove[type_code]
def _debounce_start(self, event_tuple):
"""Act like the key was released if no new event arrives in time."""
if not will_report_up(event_tuple[0]):
self._debounce_remove[event_tuple[:2]] = DEBOUNCE_TICKS
def _debounce_tick(self):
"""If the counter reaches 0, the key is not considered held down."""
for type_code in list(self._debounce_remove.keys()):
if type_code not in self._unreleased:
continue
# clear wheel events from unreleased after some time
if self._debounce_remove[type_code] == 0:
logger.debug_key(self._unreleased[type_code], "Considered as released")
self._release(type_code)
else:
self._debounce_remove[type_code] -= 1
reader = Reader()

@ -0,0 +1,292 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
"""Talking to the ReaderService that has root permissions.
see gui.reader_service.ReaderService
"""
import time
from typing import Optional, List, Generator, Dict, Tuple, Set
import evdev
import gi
from gi.repository import GLib
from inputremapper.configs.input_config import InputCombination
from inputremapper.groups import _Groups, _Group
from inputremapper.gui.reader_service import (
MSG_EVENT,
MSG_GROUPS,
CMD_TERMINATE,
CMD_REFRESH_GROUPS,
CMD_STOP_READING,
get_pipe_paths,
ReaderService,
)
from inputremapper.gui.messages.message_types import MessageType
from inputremapper.gui.messages.message_broker import MessageBroker
from inputremapper.gui.messages.message_data import (
GroupsData,
CombinationRecorded,
StatusData,
)
from inputremapper.gui.utils import CTX_ERROR
from inputremapper.gui.gettext import _
from inputremapper.input_event import InputEvent
from inputremapper.ipc.pipe import Pipe
from inputremapper.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(get_pipe_paths()[0])
commands_pipe = Pipe(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):
return {
"type": event.type,
"code": event.code,
"analog_threshold": event.value,
"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 event.value == 0:
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.filter(include_inputremapper=False)
}
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))
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()

@ -0,0 +1,411 @@
# -*- 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
import evdev
from evdev.ecodes import EV_KEY, EV_ABS, EV_REL, REL_HWHEEL, REL_WHEEL
from inputremapper.utils import get_device_hash
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping
from inputremapper.groups import _Groups, _Group
from inputremapper.injection.event_reader import EventReader
from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
from inputremapper.injection.mapping_handlers.mapping_handler import (
NotifyCallback,
InputEventHandler,
MappingHandler,
)
from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.ipc.pipe import Pipe
from inputremapper.logger import logger
from inputremapper.user import USER
# 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"
def get_pipe_paths():
"""Get the path where the pipe can be found."""
return (
f"/tmp/input-remapper-{USER}/reader-results",
f"/tmp/input-remapper-{USER}/reader-commands",
)
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):
"""Construct the reader-service and initialize its communication pipes."""
self._start_time = time.time()
self.groups = groups
self._results_pipe = Pipe(get_pipe_paths()[0])
self._commands_pipe = Pipe(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 is_running():
"""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():
"""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):
"""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())
def _send_groups(self):
"""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):
"""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):
"""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)
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):
"""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):
"""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="keyboard",
output_symbol="KEY_A",
)
handler: MappingHandler = AbsToBtnHandler(
InputCombination([input_config]), mapping
)
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="keyboard",
output_symbol="KEY_A",
)
handler = AbsToBtnHandler(InputCombination([input_config]), mapping)
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="keyboard",
output_symbol="KEY_A",
release_timeout=0.3,
force_release_timeout=True,
)
handler = RelToBtnHandler(InputCombination([input_config]), mapping)
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="keyboard",
output_symbol="KEY_A",
release_timeout=0.3,
force_release_timeout=True,
)
handler = RelToBtnHandler(InputCombination([input_config]), mapping)
handler.set_sub_handler(ForwardToUIHandler(self._results_pipe))
context_dummy.add_handler(input_config, handler)
return context_dummy
class ForwardDummy:
@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: InputEventHandler):
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
class ForwardToUIHandler:
"""Implements the InputEventHandler 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:
self._last_event = event
if EventActions.negative_trigger in event.actions:
event = event.modify(value=-1)
logger.debug("Sending to %s frontend", event)
self.pipe.send(
{
"type": MSG_EVENT,
"message": {
"sec": event.sec,
"usec": event.usec,
"type": event.type,
"code": event.code,
"value": event.value,
"origin_hash": event.origin_hash,
},
}
)
return True
def reset(self):
pass

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -17,11 +17,21 @@
# 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 gi.repository import Gtk, GLib
import time
from dataclasses import dataclass
from typing import List, Callable, Dict, Optional
import gi
from gi.repository import Gtk, GLib, Gdk
from inputremapper.logger import logger
# status ctx ids
CTX_SAVE = 0
CTX_APPLY = 1
CTX_KEYCODE = 2
@ -29,31 +39,141 @@ CTX_ERROR = 3
CTX_WARNING = 4
CTX_MAPPING = 5
debounces = {}
@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)
debounce_manager = DebounceManager()
def debounce(timeout):
"""Debounce a function call to improve performance.
"""Debounce a method call to improve performance.
Calling this creates the decorator, so use something like
Calling this with a millisecond value creates the decorator, so use something like
@debounce(50)
def foo():
def function(self):
...
"""
def decorator(func):
def clear_debounce(self, *args):
debounces[func.__name__] = None
return func(self, *args)
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 wrapped(self, *args):
if debounces.get(func.__name__) is not None:
GLib.source_remove(debounces[func.__name__])
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)
debounces[func.__name__] = GLib.timeout_add(
timeout, lambda: clear_debounce(self, *args)
)
wrapped.__name__ = function.__name__
return wrapped
@ -63,21 +183,93 @@ def debounce(timeout):
class HandlerDisabled:
"""Safely modify a widget without causing handlers to be called.
Use in a with statement.
Use in a `with` statement.
"""
def __init__(self, widget, handler):
def __init__(self, widget: Gtk.Widget, handler: Callable):
self.widget = widget
self.handler = handler
def __enter__(self):
self.widget.handler_block_by_func(self.handler)
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, *_):
self.widget.handler_unblock_by_func(self.handler)
try:
self.widget.handler_unblock_by_func(self.handler)
except TypeError as error:
logger.warning('HandlerDisabled exit failed: "%s"', error)
def gtk_iteration():
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,
)

@ -1,26 +0,0 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.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/>.
"""The injection process.
This folder contains all classes that are only relevant for the injection
process. There is one process for each hardware device that is being injected
for, and one context object per process that is being passed around for all
classes to use.
"""

@ -1,120 +0,0 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.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/>.
"""Because multiple calls to async_read_loop won't work."""
import asyncio
import evdev
from inputremapper.injection.consumers.joystick_to_mouse import JoystickToMouse
from inputremapper.injection.consumers.keycode_mapper import KeycodeMapper
from inputremapper.logger import logger
from inputremapper.injection.context import Context
consumer_classes = [
KeycodeMapper,
JoystickToMouse,
]
class ConsumerControl:
"""Reads input events from a single device and distributes them.
There is one ConsumerControl object for each source, which tells multiple consumers
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,
forward_to: evdev.UInput,
) -> None:
"""Initialize all consumers
Parameters
----------
source : evdev.InputDevice
where to read keycodes from
forward_to : evdev.UInput
where to write keycodes to that were not mapped to anything.
Should be an UInput with capabilities that work for all forwarded
events, so ideally they should be copied from source.
"""
self._source = source
self._forward_to = forward_to
# add all consumers that are enabled for this particular configuration
self._consumers = []
for Consumer in consumer_classes:
consumer = Consumer(context, source, forward_to)
if consumer.is_enabled():
self._consumers.append(consumer)
async def run(self):
"""Start doing things.
Can be stopped by stopping the asyncio loop. This loop
reads events from a single device only.
"""
for consumer in self._consumers:
# run all of them in parallel
asyncio.ensure_future(consumer.run())
logger.debug(
"Starting to listen for events from %s, fd %s",
self._source.path,
self._source.fd,
)
async for event in self._source.async_read_loop():
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.
continue
handled = False
for consumer in self._consumers:
# copy so that the consumer doesn't screw this up for
# all other future consumers
event_copy = evdev.InputEvent(
sec=event.sec,
usec=event.usec,
type=event.type,
code=event.code,
value=event.value,
)
if consumer.is_handled(event_copy):
await consumer.notify(event_copy)
handled = True
if not handled:
# forward the rest
self._forward_to.write(event.type, event.code, event.value)
# this already includes SYN events, so need to syn here again
# This happens all the time in tests because the async_read_loop stops when
# there is nothing to read anymore. Otherwise tests would block.
logger.error('The async_read_loop for "%s" stopped early', self._source.path)

@ -1,82 +0,0 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.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/>.
"""Consumer base class.
Can be notified of new events so that inheriting classes can map them and
inject new events based on them.
"""
class Consumer:
"""Can be notified of new events to inject them. Base class."""
def __init__(self, context, source, forward_to=None):
"""Initialize event consuming functionality.
Parameters
----------
context : Context
The configuration of the Injector process
source : InputDevice
Where events used in handle_keycode come from
forward_to : evdev.UInput
Where to write keycodes to that were not mapped to anything.
Should be an UInput with capabilities that work for all forwarded
events, so ideally they should be copied from source.
"""
self.context = context
self.forward_to = forward_to
self.source = source
self.context.update_purposes()
def is_enabled(self):
"""Check if the consumer will have work to do."""
raise NotImplementedError
def forward(self, key):
"""Shorthand to forward an event."""
self.forward_to.write(*key)
async def notify(self, event):
"""A new event is ready.
Overwrite this function if the consumer should do something each time
a new event arrives. E.g. mapping a single button once clicked.
"""
raise NotImplementedError
def is_handled(self, event):
"""Check if the consumer will take care of this event.
If this returns true, the event will not be forwarded anymore
automatically. If you want to forward the event after all you can
inject it into `self.forward_to`.
"""
raise NotImplementedError
async def run(self):
"""Start doing things.
Overwrite this function if the consumer should do something
continuously even if no new event arrives. e.g. continuously injecting
mouse movement events.
"""
raise NotImplementedError

@ -1,272 +0,0 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.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/>.
"""Keeps mapping joystick to mouse movements."""
import asyncio
import time
from evdev.ecodes import (
EV_REL,
REL_X,
REL_Y,
REL_WHEEL,
REL_HWHEEL,
EV_ABS,
ABS_X,
ABS_Y,
ABS_RX,
ABS_RY,
)
from inputremapper.logger import logger
from inputremapper.configs.global_config import MOUSE, WHEEL
from inputremapper import utils
from inputremapper.injection.consumers.consumer import Consumer
from inputremapper.groups import classify, GAMEPAD
from inputremapper.injection.global_uinputs import global_uinputs
# miniscule movements on the joystick should not trigger a mouse wheel event
WHEEL_THRESHOLD = 0.15
def abs_max(value_1, value_2):
"""Get the value with the higher abs value."""
if abs(value_1) > abs(value_2):
return value_1
return value_2
class JoystickToMouse(Consumer):
"""Keeps producing events at 60hz if needed.
Maps joysticks to mouse movements.
This class does not handle injecting macro stuff over time, that is done
by the keycode_mapper.
"""
def __init__(self, *args, **kwargs):
"""Construct the event producer without it doing anything yet."""
super().__init__(*args, **kwargs)
self._abs_range = None
self._set_abs_range_from(self.source)
# events only take ints, so a movement of 0.3 needs to add
# up to 1.2 to affect the cursor, with 0.2 remaining
self.pending_rel = {REL_X: 0, REL_Y: 0, REL_WHEEL: 0, REL_HWHEEL: 0}
# the last known position of the joystick
self.abs_state = {ABS_X: 0, ABS_Y: 0, ABS_RX: 0, ABS_RY: 0}
def is_enabled(self):
gamepad = classify(self.source) == GAMEPAD
return gamepad and self.context.joystick_as_mouse()
def _write(self, ev_type, keycode, value):
"""Inject."""
# if the mouse won't move even though correct stuff is written here,
# the capabilities are probably wrong
try:
global_uinputs.write((ev_type, keycode, value), "mouse")
except OverflowError:
# screwed up the calculation of mouse movements
logger.error("OverflowError (%s, %s, %s)", ev_type, keycode, value)
def accumulate(self, code, input_value):
"""Since devices can't do float values, stuff has to be accumulated.
If pending is 0.6 and input_value is 0.5, return 0.1 and 1.
Because it should move 1px, and 0.1px is rememberd for the next value
in pending.
"""
self.pending_rel[code] += input_value
output_value = int(self.pending_rel[code])
self.pending_rel[code] -= output_value
return output_value
def _set_abs_range_from(self, device):
"""Update the min and max values joysticks will report.
This information is needed for abs -> rel mapping.
"""
if device is None:
# I don't think this ever happened
logger.error("Expected device to not be None")
return
abs_range = utils.get_abs_range(device)
if abs_range is None:
return
if abs_range[1] in [0, 1, None]:
# max abs_range of joysticks is usually a much higher number
return
self.set_abs_range(*abs_range)
logger.debug('ABS range of "%s": %s', device.name, abs_range)
def set_abs_range(self, min_abs, max_abs):
"""Update the min and max values joysticks will report.
This information is needed for abs -> rel mapping.
"""
self._abs_range = (min_abs, max_abs)
# all joysticks in resting position by default
center = (self._abs_range[1] + self._abs_range[0]) / 2
self.abs_state = {ABS_X: center, ABS_Y: center, ABS_RX: center, ABS_RY: center}
def get_abs_values(self):
"""Get the raw values for wheel and mouse movement.
Returned values center around 0 and are normalized into -1 and 1.
If two joysticks have the same purpose, the one that reports higher
absolute values takes over the control.
"""
# center is the value of the resting position
center = (self._abs_range[1] + self._abs_range[0]) / 2
# normalizer is the maximum possible value after centering
normalizer = (self._abs_range[1] - self._abs_range[0]) / 2
mouse_x = 0
mouse_y = 0
wheel_x = 0
wheel_y = 0
def standardize(value):
return (value - center) / normalizer
if self.context.left_purpose == MOUSE:
mouse_x = abs_max(mouse_x, standardize(self.abs_state[ABS_X]))
mouse_y = abs_max(mouse_y, standardize(self.abs_state[ABS_Y]))
if self.context.left_purpose == WHEEL:
wheel_x = abs_max(wheel_x, standardize(self.abs_state[ABS_X]))
wheel_y = abs_max(wheel_y, standardize(self.abs_state[ABS_Y]))
if self.context.right_purpose == MOUSE:
mouse_x = abs_max(mouse_x, standardize(self.abs_state[ABS_RX]))
mouse_y = abs_max(mouse_y, standardize(self.abs_state[ABS_RY]))
if self.context.right_purpose == WHEEL:
wheel_x = abs_max(wheel_x, standardize(self.abs_state[ABS_RX]))
wheel_y = abs_max(wheel_y, standardize(self.abs_state[ABS_RY]))
# Some joysticks report from 0 to 255 (EMV101),
# others from -32768 to 32767 (X-Box 360 Pad)
return mouse_x, mouse_y, wheel_x, wheel_y
def is_handled(self, event):
"""Check if the event is something this will take care of."""
if event.type != EV_ABS or event.code not in utils.JOYSTICK:
return False
if self._abs_range is None:
return False
purposes = [MOUSE, WHEEL]
left_purpose = self.context.left_purpose
right_purpose = self.context.right_purpose
if event.code in (ABS_X, ABS_Y) and left_purpose in purposes:
return True
if event.code in (ABS_RX, ABS_RY) and right_purpose in purposes:
return True
return False
async def notify(self, event):
if event.type == EV_ABS and event.code in self.abs_state:
self.abs_state[event.code] = event.value
async def run(self):
"""Keep writing mouse movements based on the gamepad stick position.
Even if no new input event arrived because the joystick remained at
its position, this will keep injecting the mouse movement events.
"""
abs_range = self._abs_range
preset = self.context.preset
pointer_speed = preset.get("gamepad.joystick.pointer_speed")
non_linearity = preset.get("gamepad.joystick.non_linearity")
x_scroll_speed = preset.get("gamepad.joystick.x_scroll_speed")
y_scroll_speed = preset.get("gamepad.joystick.y_scroll_speed")
max_speed = 2**0.5 # for normalized abs event values
if abs_range is not None:
logger.info(
"Left joystick as %s, right joystick as %s",
self.context.left_purpose,
self.context.right_purpose,
)
start = time.time()
while True:
# try to do this as close to 60hz as possible
time_taken = time.time() - start
await asyncio.sleep(max(0.0, (1 / 60) - time_taken))
start = time.time()
if abs_range is None:
# no ev_abs events will be mapped to ev_rel
continue
abs_values = self.get_abs_values()
if len([val for val in abs_values if not -1 <= val <= 1]) > 0:
logger.error("Inconsistent values: %s", abs_values)
continue
mouse_x, mouse_y, wheel_x, wheel_y = abs_values
# mouse movements
if abs(mouse_x) > 0 or abs(mouse_y) > 0:
if non_linearity != 1:
# to make small movements smaller for more precision
speed = (mouse_x**2 + mouse_y**2) ** 0.5 # pythagoras
factor = (speed / max_speed) ** non_linearity
else:
factor = 1
rel_x = mouse_x * factor * pointer_speed
rel_y = mouse_y * factor * pointer_speed
rel_x = self.accumulate(REL_X, rel_x)
rel_y = self.accumulate(REL_Y, rel_y)
if rel_x != 0:
self._write(EV_REL, REL_X, rel_x)
if rel_y != 0:
self._write(EV_REL, REL_Y, rel_y)
# wheel movements
if abs(wheel_x) > 0:
change = wheel_x * x_scroll_speed
value = self.accumulate(REL_WHEEL, change)
if abs(change) > WHEEL_THRESHOLD * x_scroll_speed:
self._write(EV_REL, REL_HWHEEL, value)
if abs(wheel_y) > 0:
change = wheel_y * y_scroll_speed
value = self.accumulate(REL_HWHEEL, change)
if abs(change) > WHEEL_THRESHOLD * y_scroll_speed:
self._write(EV_REL, REL_WHEEL, -value)

@ -1,560 +0,0 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.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/>.
"""Inject a keycode based on the mapping."""
import itertools
import asyncio
import time
import evdev
from evdev.ecodes import EV_KEY, EV_ABS
import inputremapper.exceptions
from inputremapper.logger import logger
from inputremapper.configs.system_mapping import DISABLE_CODE
from inputremapper import utils
from inputremapper.injection.consumers.consumer import Consumer
from inputremapper.utils import RELEASE
from inputremapper.groups import classify, GAMEPAD
from inputremapper.injection.global_uinputs import global_uinputs
# this state is shared by all KeycodeMappers of this process
# maps mouse buttons to macro instances that have been executed.
# They may still be running or already be done. Just like unreleased,
# this is a mapping of (type, code). The value is not included in the
# key, because a key release event with a value of 0 needs to be able
# to find the running macro. The downside is that a d-pad cannot
# execute two macros at once, one for each direction.
# Only sequentially.
active_macros = {}
# mapping of future release event (type, code) to an Unreleased object,
# All key-up events have a value of 0, so it is not added to
# the tuple. This is needed in order to release the correct event
# mapped on a D-Pad. Each direction on one D-Pad axis reports the
# same type and code, but different values. There cannot be both at
# the same time, as pressing one side of a D-Pad forces the other
# side to go up. If both sides of a D-Pad are mapped to different
# event-codes, this data structure helps to figure out which of those
# two to release on an event of value 0. Same goes for the Wheel.
# The input event is remembered to make sure no duplicate down-events
# are written. Since wheels report a lot of "down" events that don't
# serve any purpose when mapped to a key, those duplicate down events
# should be removed. If the same type and code arrives but with a
# different value (direction), there must be a way to check if the
# event is actually a duplicate and not a different event.
unreleased = {}
COMBINATION_INCOMPLETE = 1 # not all keys of the combination are pressed
NOT_COMBINED = 2 # this key is not part of a combination
def subsets(combination):
"""Return a list of subsets of the combination.
If combination is only one element long it returns an empty list,
because it's not a combination and there is no reason to iterate.
Includes the complete input as well.
Parameters
-----------
combination : tuple
tuple of 3-tuples, each being int, int, int (type, code, action)
"""
combination = list(combination)
lengths = list(range(2, len(combination) + 1))
lengths.reverse()
return list(
itertools.chain.from_iterable(
itertools.combinations(combination, length) for length in lengths
)
)
class Unreleased:
"""This represents a key that has been pressed but not released yet."""
__slots__ = (
"target",
"input_event_tuple",
"triggered_key",
)
def __init__(self, target, input_event_tuple, triggered_key):
"""
Parameters
----------
target : 3-tuple
int type, int code of what was injected or forwarded
and string target_uinput for injected events,
None for forwarded events
input_event_tuple : 3-tuple
int, int, int / type, code, action
triggered_key : tuple of 3-tuples
What was used to index key_to_code or macros when stuff
was triggered.
If nothing was triggered and input_event_tuple forwarded,
insert None.
"""
self.target = target
self.input_event_tuple = input_event_tuple
self.triggered_key = triggered_key
if not isinstance(input_event_tuple[0], int) or len(input_event_tuple) != 3:
raise ValueError(
"Expected input_event_tuple to be a 3-tuple of ints, but "
f"got {input_event_tuple}"
)
unreleased[input_event_tuple[:2]] = self
def is_mapped(self):
"""If true, the key-down event was written to context.uinput.
That means the release event should also be injected into that one.
If this returns false, just forward the release event instead.
"""
# This should end up being equal to context.is_mapped(key)
return self.triggered_key is not None
def __str__(self):
return (
"Unreleased("
f"target{self.target},"
f"input{self.input_event_tuple},"
f'key{self.triggered_key or "(None)"}'
")"
)
def __repr__(self):
return self.__str__()
def find_by_event(key):
"""Find an unreleased entry by an event.
If such an entry exists, it was created by an event that is exactly
like the input parameter (except for the timestamp).
That doesn't mean it triggered something, only that it was seen before.
"""
unreleased_entry = unreleased.get(key[:2])
if unreleased_entry and unreleased_entry.input_event_tuple == key:
return unreleased_entry
return None
def find_by_key(key):
"""Find an unreleased entry by a combination of keys.
If such an entry exist, it was created when a combination of keys
(which matches the parameter, can also be of len 1 = single key)
ended up triggering something.
Parameters
----------
key : tuple of int
type, code, action
"""
unreleased_entry = unreleased.get(key[-1][:2])
if unreleased_entry and unreleased_entry.triggered_key == key:
return unreleased_entry
return None
class KeycodeMapper(Consumer):
"""Injects keycodes and starts macros.
This really is somewhat complicated because it needs to be able to handle
combinations (which is actually not that trivial because the order of keys
matters). The nature of some events (D-Pads and Wheels) adds to the
complexity. Since macros are mapped the same way keys are, this class
takes care of both.
"""
def __init__(self, *args, **kwargs):
"""Create a keycode mapper for one virtual device.
There may be multiple KeycodeMappers for one hardware device. They
share some state (unreleased and active_macros) with each other.
"""
super().__init__(*args, **kwargs)
self._abs_range = None
if self.context.maps_joystick():
self._abs_range = utils.get_abs_range(self.source)
self._gamepad = classify(self.source) == GAMEPAD
self.debounces = {}
# some type checking, prevents me from forgetting what that stuff
# is supposed to be when writing tests.
for combination in self.context.key_to_code:
for event in combination:
if abs(event.value) > 1:
raise ValueError(
f"Expected values to be one of -1, 0 or 1, "
f"but got {combination}"
)
def is_enabled(self):
# even if the source does not provide a capability that is used here, it might
# be important for notifying macros of new events that run on other sources.
return len(self.context.key_to_code) > 0 or len(self.context.macros) > 0
def is_handled(self, event):
return utils.should_map_as_btn(event, self.context.preset, self._gamepad)
async def run(self):
"""Provide a debouncer to inject wheel releases."""
start = time.time()
while True:
# try to do this as close to 60hz as possible
time_taken = time.time() - start
await asyncio.sleep(max(0.0, (1 / 60) - time_taken))
start = time.time()
for debounce in self.debounces.values():
if debounce[2] == -1:
# has already been triggered
continue
if debounce[2] == 0:
debounce[0](*debounce[1])
debounce[2] = -1
else:
debounce[2] -= 1
def debounce(self, debounce_id, func, args, ticks):
"""Debounce a function call.
Parameters
----------
debounce_id : hashable
If this function is called with the same debounce_id again,
the previous debouncing is overwritten, and therefore restarted.
func : function
args : tuple
ticks : int
After ticks * 1 / 60 seconds the function will be executed,
unless debounce is called again with the same debounce_id
"""
self.debounces[debounce_id] = [func, args, ticks]
async def notify(self, event):
"""Receive the newest event that should be mapped."""
action = utils.classify_action(event, self._abs_range)
for macro, _ in self.context.macros.values():
macro.notify(event, action)
will_report_key_up = utils.will_report_key_up(event)
if not will_report_key_up:
# simulate a key-up event if no down event arrives anymore.
# this may release macros, combinations or keycodes.
release = evdev.InputEvent(0, 0, event.type, event.code, 0)
self.debounce(
debounce_id=(event.type, event.code, action),
func=self.handle_keycode,
args=(release, RELEASE, False),
ticks=3,
)
async def delayed_handle_keycode():
# give macros a priority of working on their asyncio iterations
# first before handle_keycode. This is important for if_single.
# If if_single injects a modifier to modify the key that canceled
# its sleep, it needs to inject it before handle_keycode injects
# anything. This is important for the space cadet shift.
# 1. key arrives
# 2. stop if_single
# 3. make if_single inject `then`
# 4. inject key
# But I can't just wait for if_single to do its thing because it might
# be a macro that sleeps for a few seconds.
# This appears to me to be incredibly race-conditiony. For that
# reason wait a few more asyncio ticks before continuing.
# But a single one also worked. I can't wait for the specific
# macro task here because it might block forever. I'll just give
# it a few asyncio iterations advance before continuing here.
for _ in range(10):
# Noticable delays caused by this start at 10000 iterations
# Also see the python docs on asyncio.sleep. Sleeping for 0
# seconds just iterates the loop once.
await asyncio.sleep(0)
self.handle_keycode(event, action)
await delayed_handle_keycode()
def macro_write(self, target_uinput):
def f(ev_type, code, value):
"""Handler for macros."""
logger.debug(
f"Macro sending %s to %s", (ev_type, code, value), target_uinput
)
global_uinputs.write((ev_type, code, value), target_uinput)
return f
def _get_key(self, key):
"""If the event triggers stuff, get the key for that.
This key can be used to index `key_to_code` and `macros` and it might
be a combination of keys.
Otherwise, for unmapped events, returns the input.
The return format is always a tuple of 3-tuples, each 3-tuple being
type, code, action (int, int, int)
Parameters
----------
key : tuple of int
3-tuple of type, code, action
Action should be one of -1, 0 or 1
"""
unreleased_entry = find_by_event(key)
# The key used to index the mappings `key_to_code` and `macros`.
# If the key triggers a combination, the returned key will be that one
# instead
action = key[2]
key = (key,)
if unreleased_entry and unreleased_entry.triggered_key is not None:
# seen before. If this key triggered a combination,
# use the combination that was triggered by this as key.
return unreleased_entry.triggered_key
if utils.is_key_down(action):
# get the key/combination that the key-down would trigger
# the triggering key-down has to be the last element in
# combination, all others can have any arbitrary order. By
# checking all unreleased keys, a + b + c takes priority over
# b + c, if both mappings exist.
# WARNING! the combination-down triggers, but a single key-up
# releases. Do not check if key in macros and such, if it is an
# up event. It's going to be False.
combination = tuple(
value.input_event_tuple for value in unreleased.values()
)
if key[0] not in combination: # might be a duplicate-down event
combination += key
# find any triggered combination. macros and key_to_code contain
# every possible equivalent permutation of possible macros. The
# last key in the combination needs to remain the newest key
# though.
for subset in subsets(combination):
if subset[-1] != key[0]:
# only combinations that are completed and triggered by
# the newest input are of interest
continue
if self.context.is_mapped(subset):
key = subset
break
else:
# no subset found, just use the key. all indices are tuples of
# tuples, both for combinations and single keys.
if len(combination) > 1:
logger.debug_key(combination, "unknown combination")
return key
async def run_macro(self, macro, target_uinput):
"""Run the provided macro."""
try:
await macro.run(self.macro_write(target_uinput))
except Exception as e:
logger.error(f'Macro "%s" failed: %s', macro.code, e)
def handle_keycode(self, event, action, forward=True):
"""Write mapped keycodes, forward unmapped ones and manage macros.
As long as the provided event is mapped it will handle it, it won't
check any type, code or capability anymore. Otherwise it forwards
it as it is.
Parameters
----------
action : int
One of PRESS, PRESS_NEGATIVE or RELEASE
Just looking at the events value is not enough, because then mapping
trigger-values that are between 1 and 255 is not possible. They might skip
the 1 when pressed fast enough.
event : evdev.InputEvent
forward : bool
if False, will not forward the event if it didn't trigger any
mapping
"""
assert isinstance(action, int)
type_and_code = (event.type, event.code)
active_macro = active_macros.get(type_and_code)
original_tuple = (event.type, event.code, event.value)
key = self._get_key((*type_and_code, action))
is_mapped = self.context.is_mapped(key)
"""Releasing keys and macros"""
if utils.is_key_up(action):
if active_macro is not None and active_macro.is_holding():
# Tell the macro for that keycode that the key is released and
# let it decide what to do with that information.
active_macro.release_trigger()
logger.debug_key(key, "releasing macro")
if type_and_code in unreleased:
# figure out what this release event was for
unreleased_entry = unreleased[type_and_code]
target_type, target_code, target_uinput = unreleased_entry.target
del unreleased[type_and_code]
if target_code == DISABLE_CODE:
logger.debug_key(key, "releasing disabled key")
return
if target_code is None:
logger.debug_key(key, "releasing key")
return
if unreleased_entry.is_mapped():
# release what the input is mapped to
try:
logger.debug_key(
key, "releasing (%s, %s)", target_code, target_uinput
)
global_uinputs.write(
(target_type, target_code, 0), target_uinput
)
return
except inputremapper.exceptions.Error:
logger.debug_key(key, "could not map")
pass
if forward:
# forward the release event
logger.debug_key((original_tuple,), "forwarding release")
self.forward(original_tuple)
else:
logger.debug_key(key, "not forwarding release")
return
if event.type != EV_ABS:
# ABS events might be spammed like crazy every time the
# position slightly changes
logger.debug_key(key, "unexpected key up")
# everything that can be released is released now
return
"""Filtering duplicate key downs"""
if is_mapped and utils.is_key_down(action):
# unmapped keys should not be filtered here, they should just
# be forwarded to populate unreleased and then be written.
if find_by_key(key) is not None:
# this key/combination triggered stuff before.
# duplicate key-down. skip this event. Avoid writing millions
# of key-down events when a continuous value is reported, for
# example for gamepad triggers or mouse-wheel-side buttons
logger.debug_key(key, "duplicate key down")
return
# it would start a macro usually
in_macros = key in self.context.macros
running = active_macro and active_macro.running
if in_macros and running:
# for key-down events and running macros, don't do anything.
# This avoids spawning a second macro while the first one is
# not finished, especially since gamepad-triggers report a ton
# of events with a positive value.
logger.debug_key(key, "macro already running")
self.context.macros[key][0].press_trigger()
return
"""starting new macros or injecting new keys"""
if utils.is_key_down(action):
# also enter this for unmapped keys, as they might end up
# triggering a combination, so they should be remembered in
# unreleased
if key in self.context.macros:
macro, target_uinput = self.context.macros[key]
active_macros[type_and_code] = macro
Unreleased((None, None, None), (*type_and_code, action), key)
macro.press_trigger()
logger.debug_key(
key, "maps to macro (%s, %s)", macro.code, target_uinput
)
asyncio.ensure_future(self.run_macro(macro, target_uinput))
return
if key in self.context.key_to_code:
target_code, target_uinput = self.context.key_to_code[key]
# remember the key that triggered this
# (this combination or this single key)
Unreleased(
(EV_KEY, target_code, target_uinput), (*type_and_code, action), key
)
if target_code == DISABLE_CODE:
logger.debug_key(key, "disabled")
return
try:
logger.debug_key(
key, "maps to (%s, %s)", target_code, target_uinput
)
global_uinputs.write((EV_KEY, target_code, 1), target_uinput)
return
except inputremapper.exceptions.Error:
logger.debug_key(key, "could not map")
pass
if forward:
logger.debug_key((original_tuple,), "forwarding")
self.forward(original_tuple)
else:
logger.debug_key(((*type_and_code, action),), "not forwarding")
# unhandled events may still be important for triggering
# combinations later, so remember them as well.
Unreleased((*type_and_code, None), (*type_and_code, action), None)
return
logger.error("%s unhandled", key)

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -19,13 +19,26 @@
"""Stores injection-process wide information."""
from typing import Awaitable, List, Dict, Tuple, Protocol, Set
from __future__ import annotations
from collections import defaultdict
from typing import List, Dict, Set, Hashable
import evdev
from inputremapper.configs.input_config import DeviceHash
from inputremapper.input_event import InputEvent
from inputremapper.configs.preset import Preset
from inputremapper.injection.mapping_handlers.mapping_handler import (
EventListener,
NotifyCallback,
)
from inputremapper.injection.mapping_handlers.mapping_parser import (
parse_mappings,
EventPipelines,
)
from inputremapper.logger import logger
from inputremapper.injection.macros.parse import parse, is_this_a_macro
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.event_combination import EventCombination
from inputremapper.configs.global_config import NONE, MOUSE, WHEEL, BUTTONS
class Context:
@ -46,116 +59,66 @@ class Context:
- 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 that is the source of key_to_code and macros,
only used to query config values.
key_to_code : dict
Preset of ((type, code, value),) to linux-keycode
or multiple of those like ((...), (...), ...) for combinations.
Combinations need to be present in every possible valid ordering.
e.g. shift + alt + a and alt + shift + a.
This is needed to query keycodes more efficiently without having
to search preset each time.
macros : dict
Preset of ((type, code, value),) to Macro objects.
Combinations work similar as in key_to_code
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
"""
def __init__(self, preset):
self.preset = preset
# avoid searching through the mapping at runtime,
# might be a bit expensive
self.key_to_code = self._map_keys_to_codes()
self.macros = self._parse_macros()
self.left_purpose = None
self.right_purpose = None
self.update_purposes()
def update_purposes(self):
"""Read joystick purposes from the configuration.
For efficiency, so that the config doesn't have to be read during
runtime repeatedly.
"""
self.left_purpose = self.preset.get("gamepad.joystick.left_purpose")
self.right_purpose = self.preset.get("gamepad.joystick.right_purpose")
def _parse_macros(self):
"""To quickly get the target macro during operation."""
logger.debug("Parsing macros")
macros = {}
for combination, output in self.preset:
if is_this_a_macro(output[0]):
macro = parse(output[0], self)
if macro is None:
continue
for permutation in combination.get_permutations():
macros[permutation] = (macro, output[1])
if len(macros) == 0:
logger.debug("No macros configured")
return macros
def _map_keys_to_codes(self):
"""To quickly get target keycodes during operation.
Returns a mapping of one or more 3-tuples to 2-tuples of (int, target_uinput).
Examples:
((1, 2, 1),): (3, "keyboard")
((1, 5, 1), (1, 4, 1)): (4, "gamepad")
"""
key_to_code = {}
for combination, output in self.preset:
if is_this_a_macro(output[0]):
continue
target_code = system_mapping.get(output[0])
if target_code is None:
logger.error('Don\'t know what "%s" is', output[0])
continue
for permutation in combination.get_permutations():
if permutation[-1].value not in [-1, 1]:
logger.error(
"Expected values to be -1 or 1 at this point: %s",
permutation,
)
key_to_code[permutation] = (target_code, output[1])
return key_to_code
def is_mapped(self, combination):
"""Check if this combination is used for macros or mappings.
Parameters
----------
combination : tuple of tuple of int
One or more 3-tuples of type, code, action,
for example ((EV_KEY, KEY_A, 1), (EV_ABS, ABS_X, -1))
or ((EV_KEY, KEY_B, 1),)
"""
return combination in self.macros or combination in self.key_to_code
def maps_joystick(self):
"""If at least one of the joysticks will serve a special purpose."""
return (self.left_purpose, self.right_purpose) != (NONE, NONE)
def joystick_as_mouse(self):
"""If at least one joystick maps to an EV_REL capability."""
purposes = (self.left_purpose, self.right_purpose)
return MOUSE in purposes or WHEEL in purposes
def joystick_as_dpad(self):
"""If at least one joystick may be mapped to keys."""
purposes = (self.left_purpose, self.right_purpose)
return BUTTONS in purposes
def writes_keys(self):
"""Check if anything is being mapped to keys."""
return len(self.macros) > 0 and len(self.key_to_code) > 0
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],
):
if len(forward_devices) == 0:
logger.warning("Not forward_devices set")
if len(source_devices) == 0:
logger.warning("Not source_devices set")
self.listeners = set()
self._source_devices = source_devices
self._forward_devices = forward_devices
self._notify_callbacks = defaultdict(list)
self._handlers = 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.info("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]

@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
"""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.utils import get_device_hash, DeviceHash
from inputremapper.injection.mapping_handlers.mapping_handler import (
EventListener,
NotifyCallback,
)
from inputremapper.input_event import InputEvent
from inputremapper.logger import logger
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 form the set
# fire and forget, run them in parallel and don't wait for them, since
# a listener might be blocking forever while waiting for more events.
asyncio.ensure_future(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.
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)
if event.type == evdev.ecodes.EV_KEY:
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)
if not self.send_to_handlers(event):
# 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:
await self.handle(
InputEvent.from_event(event, origin_hash=self._device_hash)
)
except Exception as e:
logger.error("Handling event %s failed: %s", event, e)
traceback.print_exception(e)
self.context.reset()
logger.info("read loop for %s stopped", self._source.path)

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -17,14 +17,16 @@
# 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
import evdev
import inputremapper.utils
import inputremapper.exceptions
import inputremapper.utils
from inputremapper.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
@ -34,13 +36,16 @@ DEFAULT_UINPUTS = {
"gamepad": {
evdev.ecodes.EV_KEY: [*range(0x130, 0x13F)], # BTN_SOUTH - BTN_THUMBR
evdev.ecodes.EV_ABS: [
*range(0x00, 0x06),
*range(0x10, 0x12),
*(
(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, 0x0A)], # all REL axis
evdev.ecodes.EV_REL: [*range(0x00, 0x0D)], # all REL axis
},
}
DEFAULT_UINPUTS["keyboard + mouse"] = {
@ -54,56 +59,84 @@ DEFAULT_UINPUTS["keyboard + mouse"] = {
}
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
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_, [])
]
class UInput(evdev.UInput):
def __init__(self, *args, **kwargs):
logger.debug(f"creating UInput device: '{kwargs['name']}'")
name = kwargs["name"]
logger.debug('creating UInput device: "%s"', name)
super().__init__(*args, **kwargs)
def can_emit(self, event):
"""check if an event can be emitted by the uinput
# this will never change, so we cache it since evdev runs an expensive loop to
# gather the capabilities. (can_emit is called regularly)
self._capabilities_cache = self.capabilities(absinfo=False)
Wrong events might be injected if the group mappings are wrong
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,
"""
# TODO check for event value especially for EV_ABS
return event[1] in self.capabilities().get(event[0], [])
return event[1] in self._capabilities_cache.get(event[0], [])
class FrontendUInput:
"""Uinput which can not actually send events, for use in the frontend"""
"""Uinput which can not actually send events, for use in the frontend."""
def __init__(self, *args, events=None, name="py-evdev-uinput", **kwargs):
# see https://python-evdev.readthedocs.io/en/latest/apidoc.html#module-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(f"creating fake UInput device: '{self.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."""
"""Manages all UInputs that are shared between all injection processes."""
def __init__(self):
self.devices = {}
self.devices: Dict[str, Union[UInput, FrontendUInput]] = {}
self._uinput_factory = None
self.is_service = inputremapper.utils.is_service()
def __iter__(self):
return iter(uinput for _, uinput in self.devices.items())
def reset(self):
self.is_service = inputremapper.utils.is_service()
self._uinput_factory = None
self.devices = {}
self.prepare_all()
def ensure_uinput_factory_set(self):
if self._uinput_factory is not None:
return
# overwrite global_uinputs.is_service in tests to control this
if self.is_service:
logger.debug("Creating regular UInputs")
self._uinput_factory = UInput
else:
logger.debug("Creating FrontendUInputs")
self._uinput_factory = FrontendUInput
def prepare_all(self):
"""Generate uinputs."""
"""Generate UInputs."""
self.ensure_uinput_factory_set()
for name, events in DEFAULT_UINPUTS.items():
@ -136,8 +169,8 @@ class GlobalUInputs:
events=DEFAULT_UINPUTS[name],
)
def write(self, event, target_uinput):
"""write event to target uinput"""
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)
@ -145,22 +178,28 @@ class GlobalUInputs:
if not uinput.can_emit(event):
raise inputremapper.exceptions.EventNotHandled(event)
logger.write(event, uinput)
uinput.write(*event)
uinput.syn()
def get_uinput(self, name: str):
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
name
uniqe name of the uinput device
"""
if name in self.devices.keys():
return self.devices[name]
if name not in self.devices:
logger.error(
f'UInput "{name}" is unknown. '
+ f"Available: {list(self.devices.keys())}"
)
return None
return None
return self.devices.get(name)
global_uinputs = GlobalUInputs()

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -19,49 +19,57 @@
"""Keeps injecting keycodes in the background based on the preset."""
from __future__ import annotations
import os
import sys
import asyncio
import time
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 typing import Dict, List, Optional
from inputremapper.configs.input_config import InputCombination, InputConfig, DeviceHash
from inputremapper.configs.preset import Preset
from inputremapper.logger import logger
from inputremapper.groups import classify, GAMEPAD, _Group
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.numlock import set_numlock, is_numlock_on, ensure_numlock
from inputremapper.injection.consumer_control import ConsumerControl
from inputremapper.event_combination import EventCombination
from inputremapper.logger import logger
from inputremapper.utils import get_device_hash
CapabilitiesDict = Dict[int, List[int]]
GroupSources = List[evdev.InputDevice]
DEV_NAME = "input-remapper"
# messages
CLOSE = 0
UPGRADE_EVDEV = 7
# states
UNKNOWN = -1
STARTING = 2
FAILED = 3
RUNNING = 4
STOPPED = 5
# messages sent to the injector process
class InjectorCommand(str, enum.Enum):
CLOSE = "CLOSE"
# for both states and messages
NO_GRAB = 6
# messages the injector process reports back to the service
class InjectorState(str, enum.Enum):
UNKNOWN = "UNKNOWN"
STARTING = "STARTING"
FAILED = "FAILED"
RUNNING = "RUNNING"
STOPPED = "STOPPED"
NO_GRAB = "NO_GRAB"
UPGRADE_EVDEV = "UPGRADE_EVDEV"
def is_in_capabilities(
combination: EventCombination, capabilities: CapabilitiesDict
combination: InputCombination, capabilities: CapabilitiesDict
) -> bool:
"""Are this combination or one of its sub keys in the capabilities?"""
for event in combination:
@ -80,6 +88,18 @@ def get_udev_name(name: str, suffix: str) -> str:
return 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.
@ -91,9 +111,11 @@ class Injector(multiprocessing.Process):
group: _Group
preset: Preset
context: Optional[Context]
_state: int
_msg_pipe: multiprocessing.Pipe
_consumer_controls: List[ConsumerControl]
_devices: List[evdev.InputDevice]
_state: InjectorState
_msg_pipe: Tuple[Connection, Connection]
_event_readers: List[EventReader]
_stop_event: asyncio.Event
regrab_timeout = 0.2
@ -102,12 +124,11 @@ class Injector(multiprocessing.Process):
Parameters
----------
group : _Group
group
the device group
preset : Preset
"""
self.group = group
self._state = UNKNOWN
self._state = InjectorState.UNKNOWN
# used to interact with the parts of this class that are running within
# the new process
@ -116,40 +137,46 @@ class Injector(multiprocessing.Process):
self.preset = preset
self.context = None # only needed inside the injection process
self._consumer_controls = []
self._event_readers = []
super().__init__(name=group)
super().__init__(name=group.key)
"""Functions to interact with the running process"""
"""Functions to interact with the running process."""
def get_state(self) -> int:
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._state == UNKNOWN and not alive:
# `self.start()` has not been called yet
return self._state
if self._state == UNKNOWN and alive:
# if it is alive, it is definitely at least starting up.
self._state = STARTING
if self._state == STARTING and self._msg_pipe[1].poll():
# if there is a message available, it might have finished starting up
# and the injector has the real status for us
msg = self._msg_pipe[1].recv()
self._state = msg
if self._state in [STARTING, 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
self._state = FAILED
logger.error("Injector was unexpectedly found stopped")
# 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.FAILED
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
@ -159,80 +186,130 @@ class Injector(multiprocessing.Process):
Can be safely called from the main procss.
"""
logger.info('Stopping injecting keycodes for group "%s"', self.group.key)
self._msg_pipe[1].send(CLOSE)
self._state = STOPPED
"""Process internal stuff"""
def _grab_devices(self) -> GroupSources:
"""Grab all devices that are needed for the injection."""
sources = []
for path in self.group.paths:
source = self._grab_device(path)
if source is None:
# this path doesn't need to be grabbed for injection, because
# it doesn't provide the events needed to execute the preset
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
sources.append(source)
needed_devices[device.path] = device
return sources
grabbed_devices = {}
for device in needed_devices.values():
if device := self._grab_device(device):
grabbed_devices[get_device_hash(device)] = device
def _grab_device(self, path: os.PathLike) -> Optional[evdev.InputDevice]:
"""Try to grab the device, return None if not needed/possible.
return grabbed_devices
Without grab, original events from it would reach the display server
even though they are mapped.
"""
try:
device = evdev.InputDevice(path)
except (FileNotFoundError, OSError):
logger.error('Could not find "%s"', path)
return None
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)
capabilities = device.capabilities(absinfo=False)
needed = False
for key, _ in self.context.preset:
if is_in_capabilities(key, capabilities):
logger.debug('Grabbing "%s" because of "%s"', path, key)
needed = True
break
for input_config in mappings_by_input:
if self._find_input_device(input_config):
continue
gamepad = classify(device) == GAMEPAD
if not (device := self._find_input_device_fallback(input_config)):
# fallback failed, this mapping will be ignored
continue
if gamepad and self.context.maps_joystick():
logger.debug('Grabbing "%s" because of maps_joystick', path)
needed = True
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
if not needed:
# skipping reading and checking on events from those devices
# may be beneficial for performance.
logger.debug("No need to grab %s", path)
return None
def _grab_device(self, device: evdev.InputDevice) -> Optional[evdev.InputDevice]:
"""Try to grab the device, return None if not possible.
attempts = 0
while True:
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", path)
break
except IOError as error:
attempts += 1
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.
logger.debug("Failed attempts to grab %s: %d", path, attempts)
if attempts >= 10:
logger.error("Cannot grab %s, it is possibly in use", path)
logger.error(str(error))
return None
error = err
logger.debug("Failed attempts to grab %s: %d", device.path, attempt + 1)
time.sleep(self.regrab_timeout)
time.sleep(self.regrab_timeout)
logger.error("Cannot grab %s, it is possibly in use", device.path)
logger.error(str(error))
return None
return device
def _copy_capabilities(self, input_device: evdev.InputDevice) -> CapabilitiesDict:
@staticmethod
def _copy_capabilities(input_device: evdev.InputDevice) -> CapabilitiesDict:
"""Copy capabilities for a new device."""
ecodes = evdev.ecodes
@ -263,13 +340,48 @@ class Injector(multiprocessing.Process):
await frame_available.wait()
frame_available.clear()
msg = self._msg_pipe[0].recv()
if msg == CLOSE:
if msg == InjectorCommand.CLOSE:
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.stop()
self._msg_pipe[0].send(InjectorState.STOPPED)
return
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(
name=get_udev_name(source.name, "forwarded"),
events=self._copy_capabilities(source),
# phys=source.phys, # this leads to confusion. the appearance of
# a uinput with this "phys" property causes the udev rule to
# autoload for the original device, overwriting our previous
# attempts at starting an injection.
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.
@ -279,24 +391,6 @@ class Injector(multiprocessing.Process):
Use this function as starting point in a process. It creates
the loops needed to read and map events and keeps running them.
"""
# TODO run all injections in a single process via asyncio
# - Make sure that closing asyncio fds won't lag the service
# - SharedDict becomes obsolete
# - quick_cleanup needs to be able to reliably stop the injection
# - I think I want an event listener architecture so that macros,
# joystick_to_mouse, keycode_mapper and possibly other modules can get
# what they filter for whenever they want, without having to wire
# things through multiple other objects all the time
# - _new_event_arrived moves to the place where events are emitted. injector?
# - active macros and unreleased need to be per injection. it probably
# should move into the keycode_mapper class, but that only works if there
# is only one keycode_mapper per injection, and not per source. Problem was
# that I had to excessively pass around to which device to forward to...
# I also need to have information somewhere which source is a gamepad, I
# probably don't want to evaluate that from scratch each time `notify` is
# called.
# - benefit: writing macros that listen for events from other devices
logger.info('Starting injecting the preset for "%s"', self.group.key)
# create a new event loop, because somehow running an infinite loop
@ -306,55 +400,42 @@ class Injector(multiprocessing.Process):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# create this within the process after the event loop creation,
# so that the macros use the correct loop
self.context = Context(self.preset)
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
# 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._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(NO_GRAB)
self._msg_pipe[0].send(InjectorState.NO_GRAB)
return
numlock_state = is_numlock_on()
coroutines = []
for source in sources:
# 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(
name=get_udev_name(source.name, "forwarded"),
events=self._copy_capabilities(source),
# phys=source.phys, # this leads to confusion. the appearance of
# an uinput with this "phys" property causes the udev rule to
# autoload for the original device, overwriting our previous
# attempts at starting an injection.
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(UPGRADE_EVDEV)
sys.exit(12)
raise e
for device_hash in sources:
# actually doing things
consumer_control = ConsumerControl(self.context, source, forward_to)
coroutines.append(consumer_control.run())
self._consumer_controls.append(consumer_control)
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())
@ -362,7 +443,7 @@ class Injector(multiprocessing.Process):
# grabbing devices screws this up
set_numlock(numlock_state)
self._msg_pipe[0].send(RUNNING)
self._msg_pipe[0].send(InjectorState.RUNNING)
try:
loop.run_until_complete(asyncio.gather(*coroutines))
@ -380,7 +461,7 @@ class Injector(multiprocessing.Process):
# reached otherwise.
logger.debug("Injector coroutines ended")
for source in sources:
for source in sources.values():
# ungrab at the end to make the next injection process not fail
# its grabs
try:

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -34,16 +34,37 @@ w(1000).m(Shift_L, r(2, k(a))).w(10).k(b): <1s> A A <10ms> b
"""
from __future__ import annotations
import asyncio
import copy
import math
import re
from typing import List, Callable, Awaitable, Tuple, Optional, Union, Any
from evdev.ecodes import (
ecodes,
EV_KEY,
EV_REL,
REL_X,
REL_Y,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
REL_WHEEL,
REL_HWHEEL,
)
from evdev.ecodes import ecodes, EV_KEY, EV_REL, REL_X, REL_Y, REL_WHEEL, REL_HWHEEL
from inputremapper.logger import logger
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.configs.validation_errors import (
SymbolNotAvailableInTargetError,
MacroParsingError,
)
from inputremapper.injection.global_uinputs import can_default_uinput_emit
from inputremapper.ipc.shared_dict import SharedDict
from inputremapper.utils import PRESS, PRESS_NEGATIVE
from inputremapper.logger import logger
Handler = Callable[[Tuple[int, int, int]], None]
MacroTask = Callable[[Handler], Awaitable]
macro_variables = SharedDict()
@ -57,7 +78,7 @@ class Variable:
during runtime.
"""
def __init__(self, name):
def __init__(self, name: str):
self.name = name
def resolve(self):
@ -65,10 +86,10 @@ class Variable:
return macro_variables.get(self.name)
def __repr__(self):
return f'<Variable "{self.name}">'
return f'<Variable "{self.name}" at {hex(id(self))}>'
def _type_check(value, allowed_types, display_name=None, position=None):
def _type_check(value: Any, allowed_types, display_name=None, position=None) -> Any:
"""Validate a parameter used in a macro.
If the value is a Variable, it will be returned and should be resolved
@ -86,39 +107,29 @@ def _type_check(value, allowed_types, display_name=None, position=None):
continue
# try to parse "1" as 1 if possible
try:
return allowed_type(value)
except (TypeError, ValueError):
pass
if allowed_type != Macro:
# the macro constructor with a single argument always succeeds,
# but will definitely not result in the correct macro
try:
return allowed_type(value)
except (TypeError, ValueError):
pass
if isinstance(value, allowed_type):
return value
if display_name is not None and position is not None:
raise TypeError(
f"Expected parameter {position} for {display_name} to be "
raise MacroParsingError(
msg=f"Expected parameter {position} for {display_name} to be "
f"one of {allowed_types}, but got {value}"
)
raise TypeError(f"Expected parameter to be one of {allowed_types}, but got {value}")
raise MacroParsingError(
msg=f"Expected parameter to be one of {allowed_types}, but got {value}"
)
def _type_check_symbol(keyname):
"""Same as _type_check, but checks if the key-name is valid."""
if isinstance(keyname, Variable):
# it is a variable and will be read at runtime
return keyname
symbol = str(keyname)
code = system_mapping.get(symbol)
if code is None:
raise KeyError(f'Unknown key "{symbol}"')
return code
def _type_check_variablename(name):
def _type_check_variablename(name: str):
"""Check if this is a legit variable name.
Because they could clash with language features. If the macro is able to be
@ -128,7 +139,7 @@ def _type_check_variablename(name):
Not allowed: "1_foo", "foo=blub", "$foo", "foo,1234", "foo()"
"""
if not isinstance(name, str) or not re.match(r"^[A-Za-z_][A-Za-z_0-9]*$", name):
raise SyntaxError(f'"{name}" is not a legit variable name')
raise MacroParsingError(msg=f'"{name}" is not a legit variable name')
def _resolve(argument, allowed_types=None):
@ -175,21 +186,30 @@ class Macro:
4. `Macro.run` will run all tasks in self.tasks
"""
def __init__(self, code, context):
def __init__(
self,
code: Optional[str],
context=None,
mapping=None,
):
"""Create a macro instance that can be populated with tasks.
Parameters
----------
code : string or None
code
The original parsed code, for logging purposes.
context : Context, or None for use in frontend
context : Context
mapping : UIMapping
"""
self.code = code
self.context = context
self.mapping = mapping
# TODO check if mapping is ever none by throwing an error
# List of coroutines that will be called sequentially.
# This is the compiled code
self.tasks = []
self.tasks: List[MacroTask] = []
# can be used to wait for the release of the event
self._trigger_release_event = asyncio.Event()
@ -200,54 +220,33 @@ class Macro:
self.running = False
self.child_macros = []
self.child_macros: List[Macro] = []
self.keystroke_sleep_ms = None
self._new_event_arrived = asyncio.Event()
self._newest_event = None
self._newest_action = None
def notify(self, event, action):
"""Tell the macro about the newest event."""
for macro in self.child_macros:
macro.notify(event, action)
self._newest_event = event
self._newest_action = action
self._new_event_arrived.set()
async def _wait_for_event(self, filter=None):
"""Wait until a specific event arrives.
def is_holding(self):
"""Check if the macro is waiting for a key to be released."""
return not self._trigger_release_event.is_set()
The parameters can be used to provide a filter. It will block
until an event arrives that matches them.
def get_capabilities(self):
"""Get the merged capabilities of the macro and its children."""
capabilities = copy.deepcopy(self.capabilities)
Parameters
----------
filter : function
Receives the event. Stop waiting if it returns true.
"""
while True:
await self._new_event_arrived.wait()
self._new_event_arrived.clear()
if filter is not None:
if not filter(self._newest_event, self._newest_action):
continue
for macro in self.child_macros:
macro_capabilities = macro.get_capabilities()
for type_ in macro_capabilities:
if type_ not in capabilities:
capabilities[type_] = set()
break
capabilities[type_].update(macro_capabilities[type_])
def is_holding(self):
"""Check if the macro is waiting for a key to be released."""
return not self._trigger_release_event.is_set()
return capabilities
async def run(self, handler):
async def run(self, handler: Callable):
"""Run the macro.
Parameters
----------
handler : function
handler
Will receive int type, code and value for an event to write
"""
if not callable(handler):
@ -257,10 +256,7 @@ class Macro:
logger.error('Tried to run already running macro "%s"', self.code)
return
# newly arriving events are only interesting if they arrive after the
# macro started
self._new_event_arrived.clear()
self.keystroke_sleep_ms = self.context.preset.get("macros.keystroke_sleep_ms")
self.keystroke_sleep_ms = self.mapping.macro_key_sleep_ms
self.running = True
@ -269,7 +265,7 @@ class Macro:
coroutine = task(handler)
if asyncio.iscoroutine(coroutine):
await coroutine
except Exception as e:
except Exception:
raise
finally:
# done
@ -304,20 +300,20 @@ class Macro:
await asyncio.sleep(self.keystroke_sleep_ms / 1000)
def __repr__(self):
return f'<Macro "{self.code}">'
return f'<Macro "{self.code}" at {hex(id(self))}>'
"""Functions that prepare the macro"""
"""Functions that prepare the macro."""
def add_key(self, symbol):
def add_key(self, symbol: str):
"""Write the symbol."""
# This is done to figure out if the macro is broken at compile time, because
# if KEY_A was unknown we can show this in the gui before the injection starts.
_type_check_symbol(symbol)
self._type_check_symbol(symbol)
async def task(handler):
async def task(handler: Callable):
# if the code is $foo, figure out the correct code now.
resolved_symbol = _resolve(symbol, [str])
code = _type_check_symbol(resolved_symbol)
code = self._type_check_symbol(resolved_symbol)
resolved_code = _resolve(code, [int])
handler(EV_KEY, resolved_code, 1)
@ -327,26 +323,26 @@ class Macro:
self.tasks.append(task)
def add_key_down(self, symbol):
def add_key_down(self, symbol: str):
"""Press the symbol."""
_type_check_symbol(symbol)
self._type_check_symbol(symbol)
async def task(handler):
async def task(handler: Callable):
resolved_symbol = _resolve(symbol, [str])
code = _type_check_symbol(resolved_symbol)
code = self._type_check_symbol(resolved_symbol)
resolved_code = _resolve(code, [int])
handler(EV_KEY, resolved_code, 1)
self.tasks.append(task)
def add_key_up(self, symbol):
def add_key_up(self, symbol: str):
"""Release the symbol."""
_type_check_symbol(symbol)
self._type_check_symbol(symbol)
async def task(handler):
async def task(handler: Callable):
resolved_symbol = _resolve(symbol, [str])
code = _type_check_symbol(resolved_symbol)
code = self._type_check_symbol(resolved_symbol)
resolved_code = _resolve(code, [int])
handler(EV_KEY, resolved_code, 0)
@ -365,11 +361,11 @@ class Macro:
# if macro is a key name, hold down the key while the
# keyboard key is physically held down
symbol = macro
_type_check_symbol(symbol)
self._type_check_symbol(symbol)
async def task(handler):
async def task(handler: Callable):
resolved_symbol = _resolve(symbol, [str])
code = _type_check_symbol(resolved_symbol)
code = self._type_check_symbol(resolved_symbol)
resolved_code = _resolve(code, [int])
handler(EV_KEY, resolved_code, 1)
@ -380,32 +376,34 @@ class Macro:
if isinstance(macro, Macro):
# repeat the macro forever while the key is held down
async def task(handler):
async def task(handler: Callable):
while self.is_holding():
# run the child macro completely to avoid
# not-releasing any key
await macro.run(handler)
# give some other code a chance to run
await asyncio.sleep(1 / 1000)
self.tasks.append(task)
self.child_macros.append(macro)
def add_modify(self, modifier, macro):
def add_modify(self, modifier: str, macro: Macro):
"""Do stuff while a modifier is activated.
Parameters
----------
modifier : str
macro : Macro
modifier
macro
"""
_type_check(macro, [Macro], "modify", 2)
_type_check_symbol(modifier)
self._type_check_symbol(modifier)
self.child_macros.append(macro)
async def task(handler):
async def task(handler: Callable):
# TODO test var
resolved_modifier = _resolve(modifier, [str])
code = _type_check_symbol(resolved_modifier)
code = self._type_check_symbol(resolved_modifier)
handler(EV_KEY, code, 1)
await self._keycode_pause()
@ -418,11 +416,11 @@ class Macro:
def add_hold_keys(self, *symbols):
"""Hold down multiple keys, equivalent to `a + b + c + ...`."""
for symbol in symbols:
_type_check_symbol(symbol)
self._type_check_symbol(symbol)
async def task(handler):
async def task(handler: Callable):
resolved_symbols = [_resolve(symbol, [str]) for symbol in symbols]
codes = [_type_check_symbol(symbol) for symbol in resolved_symbols]
codes = [self._type_check_symbol(symbol) for symbol in resolved_symbols]
for code in codes:
handler(EV_KEY, code, 1)
@ -436,34 +434,28 @@ class Macro:
self.tasks.append(task)
def add_repeat(self, repeats, macro):
"""Repeat actions.
Parameters
----------
repeats : int or Macro
macro : Macro
"""
def add_repeat(self, repeats: Union[str, int], macro: Macro):
"""Repeat actions."""
repeats = _type_check(repeats, [int], "repeat", 1)
_type_check(macro, [Macro], "repeat", 2)
async def task(handler):
async def task(handler: Callable):
for _ in range(_resolve(repeats, [int])):
await macro.run(handler)
self.tasks.append(task)
self.child_macros.append(macro)
def add_event(self, type_, code, value):
def add_event(self, type_: Union[str, int], code: Union[str, int], value: int):
"""Write any event.
Parameters
----------
type_: str or int
type_
examples: 2, 'EV_KEY'
code : str or int
code
examples: 52, 'KEY_A'
value : int
value
"""
type_ = _type_check(type_, [int, str], "event", 1)
code = _type_check(code, [int, str], "event", 2)
@ -477,7 +469,7 @@ class Macro:
self.tasks.append(lambda handler: handler(type_, code, value))
self.tasks.append(self._keycode_pause)
def add_mouse(self, direction, speed):
def add_mouse(self, direction: str, speed: int):
"""Move the mouse cursor."""
_type_check(direction, [str], "mouse", 1)
speed = _type_check(speed, [int], "mouse", 2)
@ -489,40 +481,40 @@ class Macro:
"right": (REL_X, 1),
}[direction.lower()]
# how long to pause in ms between the injection of mouse events
injection_throttle = 10
async def task(handler):
async def task(handler: Callable):
resolved_speed = value * _resolve(speed, [int])
while self.is_holding():
handler(EV_REL, code, resolved_speed)
await asyncio.sleep(injection_throttle / 1000)
await asyncio.sleep(1 / self.mapping.rel_rate)
self.tasks.append(task)
def add_wheel(self, direction, speed):
def add_wheel(self, direction: str, speed: int):
"""Move the scroll wheel."""
_type_check(direction, [str], "wheel", 1)
speed = _type_check(speed, [int], "wheel", 2)
code, value = {
"up": (REL_WHEEL, 1),
"down": (REL_WHEEL, -1),
"left": (REL_HWHEEL, 1),
"right": (REL_HWHEEL, -1),
"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()]
async def task(handler):
async def task(handler: Callable):
resolved_speed = _resolve(speed, [int])
remainder = [0.0, 0.0]
while self.is_holding():
handler(EV_REL, code, value)
# scrolling moves much faster than mouse, so this
# waits between injections instead to make it slower
await asyncio.sleep(1 / resolved_speed)
for i in range(0, 2):
float_value = value[i] * resolved_speed + remainder[i]
remainder[i] = math.fmod(float_value, 1)
if abs(float_value) >= 1:
handler(EV_REL, code[i], int(float_value))
await asyncio.sleep(1 / self.mapping.rel_rate)
self.tasks.append(task)
def add_wait(self, time):
def add_wait(self, time: Union[int, float]):
"""Wait time in milliseconds."""
time = _type_check(time, [int, float], "wait", 1)
@ -531,7 +523,7 @@ class Macro:
self.tasks.append(task)
def add_set(self, variable, value):
def add_set(self, variable: str, value):
"""Set a variable to a certain value."""
_type_check_variablename(variable)
@ -543,6 +535,36 @@ class Macro:
self.tasks.append(task)
def add_add(self, variable: str, value: Union[int, float]):
"""Add a number to a variable."""
_type_check_variablename(variable)
_type_check(value, [int, float], "value", 1)
async def task(_):
current = macro_variables[variable]
if current is None:
logger.debug('"%s" initialized with 0', variable)
macro_variables[variable] = 0
current = 0
resolved_value = _resolve(value)
if not isinstance(resolved_value, (int, float)):
logger.error('Expected delta "%s" to be a number', resolved_value)
return
if not isinstance(current, (int, float)):
logger.error(
'Expected variable "%s" to contain a number, but got "%s"',
variable,
current,
)
return
logger.debug('"%s" += "%s"', variable, resolved_value)
macro_variables[variable] += value
self.tasks.append(task)
def add_ifeq(self, variable, value, then=None, else_=None):
"""Old version of if_eq, kept for compatibility reasons.
@ -553,7 +575,7 @@ class Macro:
_type_check(then, [Macro, None], "ifeq", 3)
_type_check(else_, [Macro, None], "ifeq", 4)
async def task(handler):
async def task(handler: Callable):
set_value = macro_variables.get(variable)
logger.debug('"%s" is "%s"', variable, set_value)
if set_value == value:
@ -574,7 +596,7 @@ class Macro:
_type_check(then, [Macro, None], "if_eq", 3)
_type_check(else_, [Macro, None], "if_eq", 4)
async def task(handler):
async def task(handler: Callable):
resolved_value_1 = _resolve(value_1)
resolved_value_2 = _resolve(value_2)
if resolved_value_1 == resolved_value_2:
@ -615,7 +637,7 @@ class Macro:
await self._trigger_press_event.wait()
await self._trigger_release_event.wait()
async def task(handler):
async def task(handler: Callable):
resolved_timeout = _resolve(timeout, [int, float]) / 1000
try:
await asyncio.wait_for(wait(), resolved_timeout)
@ -637,41 +659,52 @@ class Macro:
if isinstance(else_, Macro):
self.child_macros.append(else_)
async def task(handler):
triggering_event = (self._newest_event.type, self._newest_event.code)
async def task(handler: Callable):
listener_done = asyncio.Event()
def event_filter(event, action):
"""Which event may wake if_single up."""
# release event of the actual key
if (event.type, event.code) == triggering_event:
return True
async def listener(event):
if event.type != EV_KEY:
# ignore anything that is not a key
return
if event.value == 1:
# another key was pressed, trigger else
listener_done.set()
return
# press event of another key
if action in (PRESS, PRESS_NEGATIVE):
return True
self.context.listeners.add(listener)
coroutine = self._wait_for_event(event_filter)
resolved_timeout = _resolve(timeout, allowed_types=[int, float, None])
try:
if resolved_timeout is not None:
await asyncio.wait_for(coroutine, resolved_timeout / 1000)
else:
await coroutine
await asyncio.wait(
[listener_done.wait(), self._trigger_release_event.wait()],
timeout=resolved_timeout / 1000 if resolved_timeout else None,
return_when=asyncio.FIRST_COMPLETED,
)
newest_event = (self._newest_event.type, self._newest_event.code)
# if newest_event == triggering_event, then no other key was pressed.
# if it is !=, then a new key was pressed in the meantime.
new_key_pressed = triggering_event != newest_event
self.context.listeners.remove(listener)
if not new_key_pressed:
# no timeout and not combined
if then:
await then.run(handler)
return
except asyncio.TimeoutError:
pass
if else_:
if not listener_done.is_set() and self._trigger_release_event.is_set():
await then.run(handler) # was trigger release
else:
await else_.run(handler)
self.tasks.append(task)
def _type_check_symbol(self, keyname: Union[str, Variable]) -> Union[Variable, int]:
"""Same as _type_check, but checks if the key-name is valid."""
if isinstance(keyname, Variable):
# it is a variable and will be read at runtime
return keyname
symbol = str(keyname)
code = system_mapping.get(symbol)
if code is None:
raise MacroParsingError(msg=f'Unknown key "{symbol}"')
if self.mapping is not None:
target = self.mapping.target_uinput
if target is not None and not can_default_uinput_emit(target, EV_KEY, code):
raise SymbolNotAvailableInTargetError(symbol, target)
return code

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -21,15 +21,16 @@
"""Parse macro code"""
import re
import traceback
import inspect
import re
from typing import Optional, Any
from inputremapper.logger import logger
from inputremapper.configs.validation_errors import MacroParsingError
from inputremapper.injection.macros.macro import Macro, Variable
from inputremapper.logger import logger
def is_this_a_macro(output):
def is_this_a_macro(output: Any):
"""Figure out if this is a macro."""
if not isinstance(output, str):
return False
@ -41,7 +42,7 @@ def is_this_a_macro(output):
return "(" in output and ")" in output and len(output) >= 4
FUNCTIONS = {
TASK_FACTORIES = {
"modify": Macro.add_modify,
"repeat": Macro.add_repeat,
"key": Macro.add_key,
@ -57,6 +58,7 @@ FUNCTIONS = {
"set": Macro.add_set,
"if_tap": Macro.add_if_tap,
"if_single": Macro.add_if_single,
"add": Macro.add_add,
# 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": Macro.add_modify,
@ -121,14 +123,14 @@ def get_num_parameters(function):
return min_num_args, max_num_args
def _extract_args(inner):
def _extract_args(inner: str):
"""Extract parameters from the inner contents of a call.
This does not parse them.
Parameters
----------
inner : string
inner
for example '1, r, r(2, k(a))' should result in ['1', 'r', 'r(2, k(a))']
"""
inner = inner.strip()
@ -166,7 +168,9 @@ def _count_brackets(macro):
openings = macro.count("(")
closings = macro.count(")")
if openings != closings:
raise SyntaxError(f"Found {openings} opening and {closings} closing brackets")
raise MacroParsingError(
macro, f"Found {openings} opening and {closings} closing brackets"
)
brackets = 0
position = 0
@ -206,37 +210,54 @@ def _is_number(value):
return False
def _parse_recurse(code, context, macro_instance=None, depth=0):
def _parse_recurse(
code: str,
context,
mapping,
verbose: bool,
macro_instance: Optional[Macro] = None,
depth: int = 0,
):
"""Handle a subset of the macro, e.g. one parameter or function call.
Not using eval for security reasons.
Parameters
----------
code : string
code
Just like parse. A single parameter or the complete macro as string.
Comments and redundant whitespace characters are expected to be removed already.
TODO add some examples.
Are all of "foo(1);bar(2)" "foo(1)" and "1" valid inputs?
context : Context
macro_instance : Macro or None
A macro instance to add tasks to
depth : int
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()
if code == "" or code == "None":
# A function parameter probably
# I think "" is the deprecated alternative to "None"
return None
if code.startswith('"'):
# TODO and endswith check, if endswith fails throw error?
# what is currently the error if only one quote is set?
# a string, don't parse. remove quotes
string = code[1:-1]
logger.debug("%sstring %s", space, string)
debug("%sstring %s", space, string)
return string
if code.startswith("$"):
@ -248,7 +269,7 @@ def _parse_recurse(code, context, macro_instance=None, depth=0):
code = float(code)
else:
code = int(code)
logger.debug("%snumber %s", space, code)
debug("%snumber %s", space, code)
return code
# is it another macro?
@ -257,19 +278,19 @@ def _parse_recurse(code, context, macro_instance=None, depth=0):
if call is not None:
if macro_instance is None:
# start a new chain
macro_instance = Macro(code, context)
macro_instance = Macro(code, context, mapping)
else:
# chain this call to the existing instance
assert isinstance(macro_instance, Macro)
function = FUNCTIONS.get(call)
if function is None:
raise Exception(f"Unknown function {call}")
task_factory = TASK_FACTORIES.get(call)
if task_factory is None:
raise MacroParsingError(code, f"Unknown function {call}")
# get all the stuff inbetween
position = _count_brackets(code)
inner = code[code.index("(") + 1 : position - 1]
logger.debug("%scalls %s with %s", space, call, inner)
closing_bracket_position = _count_brackets(code) - 1
inner = code[code.index("(") + 1 : closing_bracket_position]
debug("%scalls %s with %s", space, call, inner)
# split "3, foo=a(2, k(a).w(10))" into arguments
raw_string_args = _extract_args(inner)
@ -279,18 +300,22 @@ def _parse_recurse(code, context, macro_instance=None, depth=0):
keyword_args = {}
for param in raw_string_args:
key, value = _split_keyword_arg(param)
parsed = _parse_recurse(value.strip(), context, None, depth + 1)
parsed = _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 SyntaxError(msg)
raise MacroParsingError(code, msg)
positional_args.append(parsed)
else:
if key in keyword_args:
raise SyntaxError(f'The "{key}" argument was specified twice')
raise MacroParsingError(
code, f'The "{key}" argument was specified twice'
)
keyword_args[key] = parsed
logger.debug(
debug(
"%sadd call to %s with %s, %s",
space,
call,
@ -298,7 +323,7 @@ def _parse_recurse(code, context, macro_instance=None, depth=0):
keyword_args,
)
min_args, max_args = get_num_parameters(function)
min_args, max_args = get_num_parameters(task_factory)
num_provided_args = len(raw_string_args)
if num_provided_args < min_args or num_provided_args > max_args:
if min_args != max_args:
@ -309,40 +334,57 @@ def _parse_recurse(code, context, macro_instance=None, depth=0):
else:
msg = f"{call} takes {min_args}, not {num_provided_args} parameters"
raise ValueError(msg)
raise MacroParsingError(code, msg)
use_safe_argument_names(keyword_args)
function(macro_instance, *positional_args, **keyword_args)
try:
task_factory(macro_instance, *positional_args, **keyword_args)
except TypeError as exception:
raise MacroParsingError(msg=str(exception)) from exception
# is after this another call? Chain it to the macro_instance
if len(code) > position and code[position] == ".":
chain = code[position + 1 :]
logger.debug("%sfollowed by %s", space, chain)
_parse_recurse(chain, context, macro_instance, depth)
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)
_parse_recurse(chain, context, mapping, verbose, macro_instance, depth)
elif re.match(r"[a-zA-Z_]", next_char):
# something like foo()bar
raise MacroParsingError(
code,
f'Expected a "." to follow after '
f"{code[:closing_bracket_position + 1]}",
)
return macro_instance
# 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.
logger.debug("%sstring %s", space, code)
debug("%sstring %s", space, code)
return code
def handle_plus_syntax(macro):
"""transform a + b + c to hold_keys(a,b,c)"""
"""Transform a + b + c to hold_keys(a,b,c)."""
if "+" not in macro:
return macro
if "(" in macro or ")" in macro:
# TODO: MacroParsingError
raise ValueError(f'Mixing "+" and macros is unsupported: "{ macro}"')
raise MacroParsingError(
macro, f'Mixing "+" and macros is unsupported: "{ macro}"'
)
chunks = [chunk.strip() for chunk in macro.split("+")]
if "" in chunks:
raise ValueError(f'Invalid syntax for "{macro}"')
raise MacroParsingError(f'Invalid syntax for "{macro}"')
output = f"hold_keys({','.join(chunks)})"
@ -395,49 +437,28 @@ def clean(code):
return remove_whitespaces(remove_comments(code), '"')
def parse(macro, context=None, return_errors=False):
"""parse and generate a Macro that can be run as often as you want.
If it could not be parsed, possibly due to syntax errors, will log the
error and return None.
def parse(macro: str, context=None, mapping=None, verbose: bool = True):
"""Parse and generate a Macro that can be run as often as you want.
Parameters
----------
macro : string
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
return_errors : bool
If True, returns errors as a string or None if parsing worked.
If False, returns the parsed macro.
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 = clean(macro)
macro = handle_plus_syntax(macro)
try:
macro = handle_plus_syntax(macro)
except Exception as error:
logger.error('Failed to parse macro "%s": %s', macro, error.__repr__())
# print the traceback in case this is a bug of input-remapper
logger.debug("".join(traceback.format_tb(error.__traceback__)).strip())
return f"{error.__class__.__name__}: {str(error)}" if return_errors else None
if return_errors:
logger.debug("checking the syntax of %s", macro)
else:
logger.debug("preparing macro %s for later execution", macro)
macro_obj = _parse_recurse(macro, context, mapping, verbose)
if not isinstance(macro_obj, Macro):
raise MacroParsingError(macro, "The provided code was not a macro")
try:
macro_object = _parse_recurse(macro, context)
if not isinstance(macro_object, Macro):
# someone put a single parameter like a string into this function, and
# it was most likely returned without modification. Not a macro
raise ValueError("The provided code was not a macro")
return macro_object if not return_errors else None
except Exception as error:
logger.error('Failed to parse macro "%s": %s', macro, error.__repr__())
# print the traceback in case this is a bug of input-remapper
logger.debug("".join(traceback.format_tb(error.__traceback__)).strip())
return f"{error.__class__.__name__}: {str(error)}" if return_errors else None
return macro_obj

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -16,8 +16,3 @@
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Consumers
Each consumer can listen for events and then inject something mapped.
"""

@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
from typing import Tuple, Optional, Dict
import evdev
from evdev.ecodes import EV_ABS
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper import exceptions
from inputremapper.configs.mapping import Mapping
from inputremapper.injection.global_uinputs import global_uinputs
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
HandlerEnums,
InputEventHandler,
)
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.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,
**_,
) -> None:
super().__init__(combination, mapping)
# 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}'
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
@property
def child(self): # used for logging
return (
f"maps to: {self.mapping.get_output_name_constant()} "
f"{self.mapping.get_output_type_code()} at "
f"{self.mapping.target_uinput}"
)
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]
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 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):
"""Inject."""
try:
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: InputEventHandler) -> 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 {}

@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
from typing import Tuple
import evdev
from evdev.ecodes import EV_ABS
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
InputEventHandler,
)
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
_active: bool
_sub_handler: InputEventHandler
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
**_,
):
super().__init__(combination, mapping)
self._active = 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))}>"
@property
def child(self): # used for logging
return self._sub_handler
def _trigger_point(self, abs_min: int, abs_max: int) -> Tuple[float, float]:
"""Calculate the axis mid and trigger point."""
# TODO: potentially cache this function
assert self._input_config.analog_threshold
if abs_min == -1 and abs_max == 1:
# this is a hat switch
# return +-1
return (
self._input_config.analog_threshold
// abs(self._input_config.analog_threshold),
0,
)
half_range = (abs_max - abs_min) / 2
middle = half_range + abs_min
trigger_offset = half_range * self._input_config.analog_threshold / 100
# threshold, middle
return middle + trigger_offset, middle
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
absinfo = {
entry[0]: entry[1] for entry in source.capabilities(absinfo=True)[EV_ABS]
}
threshold, mid_point = self._trigger_point(
absinfo[event.code].min, absinfo[event.code].max
)
value = event.value
if (value < threshold > mid_point) or (value > threshold < mid_point):
if self._active:
event = event.modify(value=0, actions=(EventActions.as_key,))
else:
# consume the event.
# We could return False to forward events
return True
else:
if value >= threshold > mid_point:
direction = EventActions.positive_trigger
else:
direction = EventActions.negative_trigger
event = event.modify(value=1, actions=(EventActions.as_key, direction))
self._active = bool(event.value)
# logger.debug(event.event_tuple, "sending to sub_handler")
return self._sub_handler.notify(
event,
source=source,
suppress=suppress,
)
def reset(self) -> None:
self._active = False
self._sub_handler.reset()

@ -0,0 +1,245 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
import asyncio
import math
import time
from functools import partial
from typing import Dict, Tuple, Optional
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 global_uinputs
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
HandlerEnums,
InputEventHandler,
)
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.logger import logger
from inputremapper.utils import get_evdev_constant_name
def calculate_output(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
# TODO move into class?
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 = 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
# TODO move into class?
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] = 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
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,
**_,
) -> None:
super().__init__(combination, mapping)
# 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(_run_wheel_output, self, codes=codes)
else:
self._run = partial(_run_normal_output, self)
def __str__(self):
name = get_evdev_constant_name(*self._map_axis.type_and_code)
return f'AbsToRelHandler for "{name}" {self._map_axis}'
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
@property
def child(self): # used for logging
return (
f"maps to: {self.mapping.get_output_name_constant()} "
f"{self.mapping.get_output_type_code()} at "
f"{self.mapping.target_uinput}"
)
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]
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:
return # rel 0 does not make sense
try:
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: InputEventHandler) -> 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 {}

@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
from typing import Dict, Tuple, Hashable, TYPE_CHECKING
import evdev
from inputremapper.configs.input_config import InputConfig
from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.mapping import Mapping
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
HandlerEnums,
InputEventHandler,
ContextProtocol,
)
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.logger import logger
from inputremapper.utils import get_device_hash
class AxisSwitchHandler(MappingHandler):
"""Enables or disables an axis.
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: InputEventHandler
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
context: ContextProtocol,
**_,
):
super().__init__(combination, mapping)
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))}>"
@property
def child(self):
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 = bool(event.value)
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:
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}

@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
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")

@ -0,0 +1,197 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
from __future__ import annotations # needed for the TYPE_CHECKING import
from typing import TYPE_CHECKING, Dict, Hashable
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.mapping_handlers.mapping_handler import (
MappingHandler,
InputEventHandler,
HandlerEnums,
)
from inputremapper.input_event import InputEvent
from inputremapper.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]
_output_state: bool # the last update we sent to a sub-handler
_sub_handler: InputEventHandler
_handled_input_hashes: list[Hashable]
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
context: Context,
**_,
) -> None:
logger.debug(str(mapping))
super().__init__(combination, mapping)
self._pressed_keys = {}
self._output_state = False
self._context = context
# 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))}>"
@property
def child(self):
# used for logging
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
was_activated = self.is_activated()
# 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.value == 1
self._pressed_keys[event.input_match_hash] = is_pressed
# maybe this changes the activation status (triggered/not-triggered)
is_activated = self.is_activated()
if is_activated == was_activated or is_activated == self._output_state:
# nothing changed
if self._output_state:
# combination is active, consume the event
return True
else:
# combination inactive, forward the event
return False
if is_activated:
# send key up events to the forwarded uinput
self.forward_release()
event = event.modify(value=1)
else:
if self._output_state or self.mapping.is_axis_mapping():
# we ignore the suppress argument for release events
# otherwise we might end up with stuck keys
# (test_event_pipeline.test_combination)
# we also ignore it if the mapping specifies an output axis
# this will enable us to activate multiple axis with the same button
suppress = False
event = event.modify(value=0)
if suppress:
return False
logger.debug("Sending %s to sub-handler", self.mapping.input_combination)
self._output_state = bool(event.value)
return self._sub_handler.notify(event, source, suppress)
def reset(self) -> None:
self._sub_handler.reset()
for key in self._pressed_keys:
self._pressed_keys[key] = False
self._output_state = 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:
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()
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

@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
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.mapping_handlers.mapping_handler import (
MappingHandler,
InputEventHandler,
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
) -> 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)
def __str__(self):
return f"HierarchyHandler for {self._input_config}"
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
@property
def child(self): # used for logging
return self.handlers
def notify(
self,
event: InputEvent,
source: evdev.InputDevice = None,
suppress: bool = False,
) -> bool:
if event.input_match_hash != self._input_config.input_match_hash:
return False
success = False
for handler in self.handlers:
if not success:
success = handler.notify(event, source)
else:
handler.notify(event, source, suppress=True)
return success
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: InputEventHandler) -> None:
assert False

@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
from typing import Tuple, Dict
from inputremapper.configs.input_config import InputCombination
from inputremapper import exceptions
from inputremapper.configs.mapping import Mapping
from inputremapper.exceptions import MappingParsingError
from inputremapper.injection.global_uinputs import global_uinputs
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
HandlerEnums,
)
from inputremapper.input_event import InputEvent
from inputremapper.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,
**_,
):
super().__init__(combination, mapping)
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):
return f"KeyHandler to {self._maps_to}"
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
@property
def child(self): # used for logging
name = get_evdev_constant_name(*self._maps_to)
return f"maps to: {name} {self._maps_to} on {self.mapping.target_uinput}"
def notify(self, event: InputEvent, *_, **__) -> bool:
"""Inject event.value to the target key."""
event_tuple = (*self._maps_to, event.value)
try:
global_uinputs.write(event_tuple, self.mapping.target_uinput)
self._active = bool(event.value)
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)
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}

@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
import asyncio
from typing import Dict, Callable
from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.mapping import Mapping
from inputremapper.injection.global_uinputs import global_uinputs
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.parse import parse
from inputremapper.injection.mapping_handlers.mapping_handler import (
ContextProtocol,
MappingHandler,
HandlerEnums,
)
from inputremapper.input_event import InputEvent
from inputremapper.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,
*,
context: ContextProtocol,
):
super().__init__(combination, mapping)
self._active = False
assert self.mapping.output_symbol is not None
self._macro = parse(self.mapping.output_symbol, context, mapping)
def __str__(self):
return f"MacroHandler"
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
@property
def child(self): # used for logging
return f"maps to {self._macro} on {self.mapping.target_uinput}"
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: %s', self._macro.code, exception)
def notify(self, event: InputEvent, *_, **__) -> bool:
if event.value == 1:
self._active = True
self._macro.press_trigger()
if self._macro.running:
return True
def handler(type_, code, value) -> None:
"""Handler for macros."""
global_uinputs.write((type_, code, value), self.mapping.target_uinput)
asyncio.ensure_future(self.run_macro(handler))
return True
else:
self._active = False
if self._macro.is_holding():
self._macro.release_trigger()
return True
def reset(self) -> None:
self._active = False
if self._macro.is_holding():
self._macro.release_trigger()
def needs_wrapping(self) -> bool:
return True
def wrap_with(self) -> Dict[InputCombination, HandlerEnums]:
return {InputCombination(self.input_configs): HandlerEnums.combination}

@ -0,0 +1,229 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
"""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 InputEventHandler protocol.
An InputEventHandler 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.input_event import InputEvent
from inputremapper.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 InputEventHandler.notify
return True if the event was actually taken care of
"""
def __call__(
self,
event: InputEvent,
source: evdev.InputDevice,
suppress: bool = False,
) -> bool:
...
class InputEventHandler(Protocol):
"""The protocol any handler, which can be part of an event pipeline, must follow."""
def notify(
self,
event: InputEvent,
source: evdev.InputDevice,
suppress: bool = False,
) -> bool:
...
def reset(self) -> None:
"""Reset the state of the handler e.g. release any buttons."""
...
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:
"""The protocol an InputEventHandler must follow if it should be
dynamically integrated in an event-pipeline by the mapping parser
"""
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[InputEventHandler]
# https://bugs.python.org/issue44807
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
**_,
) -> 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
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: InputEventHandler) -> 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)

@ -0,0 +1,325 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
"""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.mapping import Mapping
from inputremapper.configs.preset import Preset
from inputremapper.configs.system_mapping import DISABLE_CODE, DISABLE_NAME
from inputremapper.exceptions import MappingParsingError
from inputremapper.injection.macros.parse import is_this_a_macro
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,
MappingHandler,
ContextProtocol,
InputEventHandler,
)
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.logger import logger
from inputremapper.utils import get_evdev_constant_name
EventPipelines = Dict[InputConfig, Set[InputEventHandler]]
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,
}
def parse_mappings(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 = _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,
)
# layer other handlers on top until the outer handler needs ranking or can
# directly handle a input event
handlers.extend(_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 = _create_hierarchy_handlers(need_ranking)
for handler in ranked_handlers:
handlers.extend(_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(
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)
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(_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(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 is_this_a_macro(mapping.output_symbol):
return HandlerEnums.macro
return HandlerEnums.key
if mapping.output_type == EV_KEY:
return HandlerEnums.key
input_event = _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(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(
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 = _order_combinations(combinations_with_event, event)
sub_handlers: List[MappingHandler] = []
for combination in sorted_combinations:
sub_handlers.append(*handlers[combination])
sorted_handlers.add(HierarchyHandler(sub_handlers, event))
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(
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 _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(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)

@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
from typing import Dict
import evdev
from inputremapper.configs.input_config import InputCombination
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
HandlerEnums,
)
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)}>"
@property
def child(self):
return "Voids all events"
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

@ -0,0 +1,246 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
import asyncio
from typing import Tuple, Dict, Optional
import evdev
from evdev.ecodes import (
EV_ABS,
EV_REL,
REL_WHEEL,
REL_HWHEEL,
REL_HWHEEL_HI_RES,
REL_WHEEL_HI_RES,
)
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper import exceptions
from inputremapper.configs.mapping import (
Mapping,
WHEEL_SCALING,
WHEEL_HI_RES_SCALING,
REL_XY_SCALING,
DEFAULT_REL_RATE,
)
from inputremapper.injection.global_uinputs import global_uinputs
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
HandlerEnums,
InputEventHandler,
)
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.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,
**_,
) -> None:
super().__init__(combination, mapping)
# 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}"
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
@property
def child(self): # used for logging
return (
f"maps to: {self.mapping.get_output_name_constant()} "
f"{self.mapping.get_output_type_code()} at "
f"{self.mapping.target_uinput}"
)
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:
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(
(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:
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: InputEventHandler) -> 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 {}

@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
import asyncio
import time
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.mapping_handlers.mapping_handler import (
MappingHandler,
InputEventHandler,
)
from inputremapper.input_event import InputEvent, EventActions
from inputremapper.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: InputEventHandler
def __init__(
self,
combination: InputCombination,
mapping: Mapping,
**_,
) -> None:
super().__init__(combination, mapping)
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))}>"
@property
def child(self): # used for logging
return self._sub_handler
async def _stage_release(
self,
source: InputEvent,
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(value=0, 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 = EventActions.positive_trigger
else:
direction = EventActions.negative_trigger
self._last_activation = time.time()
event = event.modify(value=1, actions=(EventActions.as_key, direction))
self._active = bool(event.value)
# 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()

@ -0,0 +1,273 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.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/>.
import math
from typing import Dict
import evdev
from evdev.ecodes import (
EV_REL,
REL_WHEEL,
REL_HWHEEL,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
)
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper import exceptions
from inputremapper.configs.mapping import (
Mapping,
REL_XY_SCALING,
WHEEL_SCALING,
WHEEL_HI_RES_SCALING,
)
from inputremapper.injection.global_uinputs import global_uinputs
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
HandlerEnums,
InputEventHandler,
)
from inputremapper.input_event import InputEvent
from inputremapper.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,
**_,
) -> None:
super().__init__(combination, mapping)
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}"
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
@property
def child(self): # used for logging
return f"maps to: {self.mapping.output_code} at {self.mapping.target_uinput}"
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:
return
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: InputEventHandler) -> 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 {}

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -35,7 +35,8 @@ def is_numlock_on():
"""Get the current state of the numlock."""
try:
xset_q = subprocess.check_output(
["xset", "q"], stderr=subprocess.STDOUT
["xset", "q"],
stderr=subprocess.STDOUT,
).decode()
num_lock_status = re.search(r"Num Lock:\s+(.+?)\s", xset_q)

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -16,24 +16,52 @@
#
# 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 dataclasses import dataclass
from typing import Tuple
from inputremapper.utils import get_evdev_constant_name
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()
# used in combination with as_key, for originally abs or rel events
positive_trigger = enum.auto() # original event was positive direction
negative_trigger = enum.auto() # original event was negative direction
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}")
from inputremapper.exceptions import InputEventCreationError
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}")
@dataclass(
frozen=True
) # Todo: add slots=True as soon as python 3.10 is in common distros
return event
# Todo: add slots=True as soon as python 3.10 is in common distros
@dataclass(frozen=True)
class InputEvent:
"""
the evnet used by inputremapper
"""Events that are generated during runtime.
as a drop in replacement for evdev.InputEvent
Is a drop-in replacement for evdev.InputEvent
"""
sec: int
@ -41,87 +69,149 @@ class InputEvent:
type: int
code: int
value: int
actions: Tuple[EventActions, ...] = ()
origin_hash: Optional[str] = None
forward_to: Optional[evdev.UInput] = None
def __hash__(self):
return hash((self.type, self.code, self.value))
def __eq__(self, other):
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
return False
raise TypeError(f"cannot compare {type(other)} with InputEvent")
@classmethod
def __get_validators__(cls):
"""used by pydantic and EventCombination to create InputEvent objects"""
yield cls.from_event
yield cls.from_tuple
yield cls.from_string
@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) -> InputEvent:
"""create a InputEvent from another InputEvent or evdev.InputEvent"""
def from_event(
cls, event: evdev.InputEvent, origin_hash: Optional[str] = None
) -> InputEvent:
"""Create a InputEvent from another InputEvent or evdev.InputEvent."""
try:
return cls(event.sec, event.usec, event.type, event.code, event.value)
except AttributeError:
raise InputEventCreationError(
f"failed to create InputEvent from {event = }"
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_string(cls, string: str) -> InputEvent:
"""create a InputEvent from a string like 'type, code, value'"""
try:
t, c, v = string.split(",")
return cls(0, 0, int(t), int(c), int(v))
except (ValueError, AttributeError):
raise InputEventCreationError(
f"failed to create InputEvent from {string = !r}"
def from_tuple(
cls, event_tuple: Tuple[int, int, int], origin_hash: Optional[str] = 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"
)
@classmethod
def from_tuple(cls, event_tuple: Tuple[int, int, int]) -> InputEvent:
"""create a InputEvent from a (type, code, value) tuple"""
try:
if len(event_tuple) != 3:
raise InputEventCreationError(
f"failed to create InputEvent {event_tuple = }"
f" must have length 3"
)
return cls(
0, 0, int(event_tuple[0]), int(event_tuple[1]), int(event_tuple[2])
return validate_event(
cls(
0,
0,
int(event_tuple[0]),
int(event_tuple[1]),
int(event_tuple[2]),
origin_hash=origin_hash,
)
except ValueError:
raise InputEventCreationError(
f"failed to create InputEvent from {event_tuple = }"
)
@classmethod
def abs(cls, code: int, value: int, origin_hash: Optional[str] = None):
"""Create an abs event, like joystick movements."""
return validate_event(
cls(
0,
0,
ecodes.EV_ABS,
code,
value,
origin_hash=origin_hash,
)
except TypeError:
raise InputEventCreationError(
f"failed to create InputEvent from {type(event_tuple) = }"
)
@classmethod
def rel(cls, code: int, value: int, origin_hash: Optional[str] = None):
"""Create a rel event, like mouse movements."""
return validate_event(
cls(
0,
0,
ecodes.EV_REL,
code,
value,
origin_hash=origin_hash,
)
)
@classmethod
def btn_left(cls):
return cls(0, 0, evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT, 1)
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 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"""
"""Event type, code."""
return self.type, self.code
@property
def event_tuple(self) -> Tuple[int, int, int]:
"""event type, code, value"""
"""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):
if self.type == evdev.ecodes.EV_KEY:
key_name = evdev.ecodes.bytype[self.type].get(self.code, "unknown")
action = "down" if self.value == 1 else "up"
return f"<InputEvent {key_name} ({self.code}) {action}>"
name = get_evdev_constant_name(self.type, self.code)
return f"InputEvent for {self.event_tuple} {name}"
return f"<InputEvent {self.event_tuple}>"
def __repr__(self):
return f"<{str(self)} at {hex(id(self))}>"
def timestamp(self):
"""Return the unix timestamp of when the event was seen."""
@ -129,20 +219,21 @@ class InputEvent:
def modify(
self,
sec: int = None,
usec: int = None,
type: int = None,
code: int = None,
value: int = None,
sec: Optional[int] = None,
usec: Optional[int] = None,
type_: Optional[int] = None,
code: Optional[int] = None,
value: Optional[int] = None,
actions: Optional[Tuple[EventActions, ...]] = None,
origin_hash: Optional[str] = None,
) -> InputEvent:
"""return a new modified event"""
"""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,
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,
actions if actions is not None else self.actions,
origin_hash=origin_hash if origin_hash is not None else self.origin_hash,
)
def json_str(self) -> str:
return ",".join([str(self.type), str(self.code), str(self.value)])

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -35,17 +35,23 @@ Beware that pipes read any available messages,
even those written by themselves.
"""
import asyncio
import json
import os
import time
import json
from typing import Optional, AsyncIterator, Union
from inputremapper.logger import logger
from inputremapper.configs.paths import mkdir, chown
from inputremapper.logger import logger
class Pipe:
"""Pipe object."""
"""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."""
@ -53,6 +59,9 @@ class Pipe:
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")
mkdir(os.path.dirname(path))
@ -87,16 +96,23 @@ class Pipe:
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 helper
# 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 helper. Only messages that can be converted to json
privileged reader-service. Only messages that can be converted to json
are allowed.
"""
if len(self._unread) > 0:
@ -106,18 +122,21 @@ class Pipe:
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 helper.
# the reader-service.
logger.debug("Ignoring old message %s", parsed)
return None
return parsed[1]
def send(self, message):
"""Write an object to the pipe."""
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().
@ -140,5 +159,25 @@ class Pipe:
return len(self._unread) > 0
def fileno(self):
"""Compatibility to select.select"""
"""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__()

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -21,9 +21,10 @@
"""Share a dictionary across processes."""
import multiprocessing
import atexit
import multiprocessing
import select
from typing import Optional, Any
from inputremapper.logger import logger
@ -88,14 +89,14 @@ class SharedDict:
"""Clears the memory."""
self.pipe[1].send(("clear",))
def get(self, key):
def get(self, key: str):
"""Get a value from the dictionary.
If it doesn't exist, returns None.
"""
return self.__getitem__(key)
return self[key]
def is_alive(self, timeout=None):
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)
@ -104,10 +105,10 @@ class SharedDict:
return False
def __setitem__(self, key, value):
def __setitem__(self, key: str, value: Any):
self.pipe[1].send(("set", key, value))
def __getitem__(self, key):
def __getitem__(self, key: str):
self.pipe[1].send(("get", key))
select.select([self.pipe[1]], [], [], self._timeout)

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -42,22 +42,23 @@ are much easier to handle.
# Issues:
# - Tests don't pass with Server (reader) and Client (helper) instead of Pipe
# - Tests don't pass with Server and Client instead of Pipe for reader-client
# and service communication or something
# - Had one case of a test that was blocking forever, seems very rare.
# - Hard to debug, generally very problematic compared to Pipes
# The tool works fine, it's just the tests. BrokenPipe errors reported
# by _Server all the time.
import json
import os
import select
import socket
import os
import time
import json
from typing import Union
from inputremapper.logger import logger
from inputremapper.configs.paths import mkdir, chown
from inputremapper.logger import logger
# something funny that most likely won't appear in messages.
# also add some ones so that 01 in the payload won't offset
@ -120,7 +121,7 @@ class Base:
if len(chunk) == 0:
# select keeps telling me the socket has messages
# ready to be received, and I keep getting empty
# buffers. Happened during a test that ran two helper
# buffers. Happened during a test that ran two reader-service
# processes without stopping the first one.
attempts += 1
if attempts == 2 or not self.reconnect():
@ -136,7 +137,7 @@ class Base:
if parsed[0] < self._created_at:
# important to avoid race conditions between multiple
# unittests, for example old terminate messages reaching
# a new instance of the helper.
# a new instance of the reader-service.
logger.debug("Ignoring old message %s", parsed)
continue
@ -146,7 +147,7 @@ class Base:
"""Get the next message or None if nothing to read.
Doesn't transmit pickles, to avoid injection attacks on the
privileged helper. Only messages that can be converted to json
privileged reader-service. Only messages that can be converted to json
are allowed.
"""
self._receive_new_messages()
@ -164,8 +165,8 @@ class Base:
self._receive_new_messages()
return len(self._unread) > 0
def send(self, message):
"""Send jsonable messages, like numbers, strings or objects."""
def send(self, message: Union[str, int, float, dict, list, tuple]):
"""Send json-serializable messages."""
dump = bytes(json.dumps((time.time(), message)), ENCODING)
self.unsent.append(dump)
@ -225,7 +226,7 @@ class _Client(Base):
return True
def fileno(self):
"""For compatibility with select.select"""
"""For compatibility with select.select."""
self.connect()
return self.socket.fileno()

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -17,19 +17,16 @@
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Logging setup for input-remapper."""
import logging
import os
import sys
import shutil
import time
import logging
import pkg_resources
from datetime import datetime
from typing import cast
from inputremapper.user import HOME
import pkg_resources
try:
from inputremapper.commit_hash import COMMIT_HASH
@ -40,44 +37,79 @@ except ImportError:
start = time.time()
previous_key_debug_log = None
previous_write_debug_log = None
def parse_mapping_handler(mapping_handler):
indent = 0
lines_and_indent = []
while True:
if isinstance(handler, str):
lines_and_indent.append([mapping_handler, indent])
break
if isinstance(mapping_handler, list):
for sub_handler in mapping_handler:
sub_list = parse_mapping_handler(sub_handler)
for line in sub_list:
line[1] += indent
lines_and_indent.extend(sub_list)
break
lines_and_indent.append([repr(mapping_handler), indent])
try:
mapping_handler = mapping_handler.child
except AttributeError:
break
indent += 1
return lines_and_indent
class Logger(logging.Logger):
def debug_mapping_handler(self, mapping_handler):
"""Parse the structure of a mapping_handler and log it."""
if not self.isEnabledFor(logging.DEBUG):
return
lines_and_indent = parse_mapping_handler(mapping_handler)
for line in lines_and_indent:
indent = " "
msg = indent * line[1] + line[0]
self._log(logging.DEBUG, msg, args=None)
def write(self, key, uinput):
"""Log that an event is being written
Parameters
----------
key
anything that can be string formatted, but usually a tuple of
(type, code, value) tuples
"""
# pylint: disable=protected-access
if not self.isEnabledFor(logging.DEBUG):
return
global previous_write_debug_log
def debug_key(self, key, msg, *args):
"""Log a spam message custom tailored to keycode_mapper.
Parameters
----------
key : tuple of int
anything that can be string formatted, but usually a tuple of
(type, code, value) tuples
"""
# pylint: disable=protected-access
if not self.isEnabledFor(logging.DEBUG):
return
global previous_key_debug_log
msg = msg % args
str_key = str(key)
str_key = str_key.replace(",)", ")")
spacing = " " + "·" * max(0, 30 - len(msg))
if len(spacing) == 1:
spacing = ""
msg = f"{msg}{spacing} {str_key}"
if msg == previous_key_debug_log:
# avoid some super spam from EV_ABS events
return
str_key = repr(key)
str_key = str_key.replace(",)", ")")
previous_key_debug_log = msg
msg = f'Writing {str_key} to "{uinput.name}"'
self._log(logging.DEBUG, msg, args=None)
if msg == previous_write_debug_log:
# avoid some super spam from EV_ABS events
return
previous_write_debug_log = msg
logging.Logger.debug_key = debug_key
self._log(logging.DEBUG, msg, args=None)
logger = logging.getLogger("input-remapper")
# https://github.com/python/typeshed/issues/1801
logging.setLoggerClass(Logger)
logger = cast(Logger, logging.getLogger("input-remapper"))
def is_debug():
@ -127,16 +159,16 @@ class ColorfulFormatter(logging.Formatter):
logging.FATAL: 9,
}
def _get_ansi_code(self, r, g, b):
def _get_ansi_code(self, r: int, g: int, b: int):
return 16 + b + (6 * g) + (36 * r)
def _word_to_color(self, word):
def _word_to_color(self, word: str):
"""Convert a word to a 8bit ansi color code."""
digit_sum = sum([ord(char) for char in word])
index = digit_sum % len(self.allowed_colors)
return self.allowed_colors[index]
def _allocate_debug_log_color(self, record):
def _allocate_debug_log_color(self, record: logging.LogRecord):
"""Get the color that represents the source file of the log."""
if self.file_color_mapping.get(record.filename) is not None:
return self.file_color_mapping[record.filename]
@ -151,15 +183,18 @@ class ColorfulFormatter(logging.Formatter):
def _get_process_name(self):
"""Generate a beaitiful to read name for this process."""
name = sys.argv[0].split("/")[-1].split("-")[-1]
return {
"gtk": "GUI",
"helper": "GUI-Helper",
"service": "Service",
"control": "Control",
}.get(name, name)
def _get_format(self, record):
process_path = sys.argv[0]
process_name = process_path.split("/")[-1]
if "input-remapper-" in process_name:
process_name = process_name.replace("input-remapper-", "")
if process_name == "gtk":
process_name = "GUI"
return process_name
def _get_format(self, record: logging.LogRecord):
"""Generate a message format string."""
debug_mode = is_debug()
@ -193,7 +228,7 @@ class ColorfulFormatter(logging.Formatter):
"\033[0m" # end style
).replace(" ", " ")
def format(self, record):
def format(self, record: logging.LogRecord):
"""Overwritten format function."""
# pylint: disable=protected-access
self._style._fmt = self._get_format(record)
@ -207,10 +242,11 @@ logger.setLevel(logging.INFO)
logging.getLogger("asyncio").setLevel(logging.WARNING)
VERSION = ""
# using pkg_resources to figure out the version fails in many cases,
# so we hardcode it instead
VERSION = "2.0.0-rc"
EVDEV_VERSION = None
try:
VERSION = pkg_resources.require("input-remapper")[0].version
EVDEV_VERSION = pkg_resources.require("evdev")[0].version
except Exception as error:
# there have been pkg_resources.DistributionNotFound and
@ -219,6 +255,9 @@ except Exception as error:
logger.info("Could not figure out the version")
logger.debug(error)
# check if the version is something like 1.5.0-beta or 1.5.0-beta.5
IS_BETA = "beta" in VERSION
def log_info(name="input-remapper"):
"""Log version and name to the console."""
@ -292,5 +331,5 @@ def trim_logfile(log_path):
except PermissionError:
# let the outermost PermissionError handler handle it
raise
except Exception as e:
logger.error('Failed to trim logfile: "%s"', str(e))
except Exception as exception:
logger.error('Failed to trim logfile: "%s"', str(exception))

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -21,8 +21,8 @@
"""Figure out the user."""
import os
import getpass
import os
import pwd
@ -65,5 +65,3 @@ def get_home(user):
USER = get_user()
HOME = get_home(USER)
CONFIG_PATH = os.path.join(HOME, ".config/input-remapper")

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
@ -20,221 +20,42 @@
"""Utility functions."""
import math
import sys
from hashlib import md5
from typing import Optional
import evdev
from evdev.ecodes import (
EV_KEY,
EV_ABS,
ABS_X,
ABS_Y,
ABS_RX,
ABS_RY,
EV_REL,
REL_WHEEL,
REL_HWHEEL,
)
from inputremapper.logger import logger
from inputremapper.configs.global_config import BUTTONS
# other events for ABS include buttons
JOYSTICK = [
evdev.ecodes.ABS_X,
evdev.ecodes.ABS_Y,
evdev.ecodes.ABS_RX,
evdev.ecodes.ABS_RY,
]
# drawing table stylus movements
STYLUS = [
(EV_ABS, evdev.ecodes.ABS_DISTANCE),
(EV_ABS, evdev.ecodes.ABS_TILT_X),
(EV_ABS, evdev.ecodes.ABS_TILT_Y),
(EV_KEY, evdev.ecodes.BTN_DIGI),
(EV_ABS, evdev.ecodes.ABS_PRESSURE),
]
# a third of a quarter circle, so that each quarter is divided in 3 areas:
# up, left and up-left. That makes up/down/left/right larger than the
# overlapping sections though, maybe it should be 8 equal areas though, idk
JOYSTICK_BUTTON_THRESHOLD = math.sin((math.pi / 2) / 3 * 1)
PRESS = 1
# D-Pads and joysticks can have a second press event, which moves the knob to the
# opposite side, reporting a negative value
PRESS_NEGATIVE = -1
RELEASE = 0
def sign(value):
"""Return -1, 0 or 1 depending on the input value."""
if value > 0:
return 1
if value < 0:
return -1
return 0
def classify_action(event, abs_range=None):
"""Fit the event value to one of PRESS, PRESS_NEGATIVE or RELEASE
A joystick that is pushed to the very side will probably send a high value, whereas
having it close to the middle might send values close to 0 with some noise. A value
of 1 is usually noise or from touching the joystick very gently and considered in
resting position.
"""
if event.type == EV_ABS and event.code in JOYSTICK:
if abs_range is None:
logger.error(
"Got %s, but abs_range is %s",
(event.type, event.code, event.value),
abs_range,
)
return event.value
# center is the value of the resting position
center = (abs_range[1] + abs_range[0]) / 2
# normalizer is the maximum possible value after centering
normalizer = (abs_range[1] - abs_range[0]) / 2
threshold = normalizer * JOYSTICK_BUTTON_THRESHOLD
triggered = abs(event.value - center) > threshold
return sign(event.value - center) if triggered else 0
# non-joystick abs events (triggers) usually start at 0 and go up to 255,
# but anything that is > 0 was safe to be treated as pressed so far
return sign(event.value)
def is_key_down(action):
"""Is this action a key press."""
return action in [PRESS, PRESS_NEGATIVE]
def is_key_up(action):
"""Is this action a key release."""
return action == RELEASE
DeviceHash = str
def is_wheel(event):
"""Check if this is a wheel event."""
return event.type == EV_REL and event.code in [REL_WHEEL, REL_HWHEEL]
def is_service() -> bool:
return sys.argv[0].endswith("input-remapper-service")
def will_report_key_up(event):
"""Check if the key is expected to report a down event as well."""
return not is_wheel(event)
def should_map_as_btn(event, preset, gamepad):
"""Does this event describe a button that is or can be mapped.
If a new kind of event should be mappable to buttons, this is the place
to add it.
Especially important for gamepad events, some of the buttons
require special rules.
Parameters
----------
event : evdev.InputEvent
preset : Preset
gamepad : bool
If the device is treated as gamepad
"""
if (event.type, event.code) in STYLUS:
return False
is_mousepad = event.type == EV_ABS and 47 <= event.code <= 61
if is_mousepad:
return False
if event.type == EV_ABS:
if event.code == evdev.ecodes.ABS_MISC:
# what is that even supposed to be.
# the intuos 5 spams those with every event
return False
if event.code in JOYSTICK:
if not gamepad:
return False
l_purpose = preset.get("gamepad.joystick.left_purpose")
r_purpose = preset.get("gamepad.joystick.right_purpose")
if event.code in [ABS_X, ABS_Y] and l_purpose == BUTTONS:
return True
if event.code in [ABS_RX, ABS_RY] and r_purpose == BUTTONS:
return True
else:
# for non-joystick buttons just always offer mapping them to
# buttons
return True
if is_wheel(event):
return True
if event.type == EV_KEY:
# usually all EV_KEY events are allright, except for
if event.code == evdev.ecodes.BTN_TOUCH:
return False
return True
return False
def get_abs_range(device, code=ABS_X):
"""Figure out the max and min value of EV_ABS events of that device.
Like joystick movements or triggers.
"""
# since input_device.absinfo(EV_ABS).max is too new for (some?) ubuntus,
# figure out the max value via the capabilities
capabilities = device.capabilities(absinfo=True)
if EV_ABS not in capabilities:
return None
absinfo = [
entry[1]
for entry in capabilities[EV_ABS]
if (
entry[0] == code
and isinstance(entry, tuple)
and isinstance(entry[1], evdev.AbsInfo)
)
]
if len(absinfo) == 0:
logger.warning(
'Failed to get ABS info of "%s" for key %d: %s', device, code, capabilities
)
return None
absinfo = absinfo[0]
return absinfo.min, absinfo.max
def get_device_hash(device: evdev.InputDevice) -> DeviceHash:
"""get a unique hash for the given device"""
# the builtin hash() function can not be used because it is randomly
# seeded at python startup.
# a non-cryptographic hash would be faster but there is none in the standard lib
s = str(device.capabilities(absinfo=False)) + device.name
return md5(s.encode()).hexdigest().lower()
def get_max_abs(device, code=ABS_X):
"""Figure out the max value of EV_ABS events of that device.
def get_evdev_constant_name(type_: Optional[int], code: Optional[int], *_) -> str:
"""Handy function to get the evdev constant name for display purposes.
Like joystick movements or triggers.
Returns "unknown" for unknown events.
"""
abs_range = get_abs_range(device, code)
return abs_range and abs_range[1]
# using this function is more readable than
# type_, code = event.type_and_code
# name = evdev.ecodes.bytype[type_][code]
name = evdev.ecodes.bytype.get(type_, {}).get(code)
if isinstance(name, list):
name = name[0]
if name is None:
return "unknown"
def is_service():
return sys.argv[0].endswith("input-remapper-service")
return name

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-03-27 13:08+0200\n"
"POT-Creation-Date: 2022-11-14 10:29+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -17,152 +17,163 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: inputremapper/gui/editor/editor.py:608
#, python-format
msgid "\"%s\" already mapped to \"%s\""
#: data/input-remapper.glade:543
msgid " (recording ...)"
msgstr ""
#: inputremapper/gui/user_interface.py:609
msgid ", CTRL + DEL to stop"
#: data/input-remapper.glade:1388
msgid "About"
msgstr ""
#: data/input-remapper.glade:1070
msgid "About"
#: data/input-remapper.glade:574
msgid "Add"
msgstr ""
#: inputremapper/gui/user_interface.py:606
#, python-format
msgid "Applied preset %s"
#: data/input-remapper.glade:605 data/input-remapper.glade:1585
msgid "Advanced"
msgstr ""
#: inputremapper/gui/user_interface.py:427
msgid "Applied the system default"
#: data/input-remapper.glade:772 data/input-remapper.glade:1147
msgid "Analog Axis"
msgstr ""
#: data/input-remapper.glade:307
#: data/input-remapper.glade:318
msgid "Apply"
msgstr ""
#: inputremapper/gui/user_interface.py:251
#, python-format
msgid "Are you sure to delete preset %s?"
#: data/input-remapper.glade:410
msgid "Autoload"
msgstr ""
#: inputremapper/gui/editor/editor.py:545
msgid "Are you sure to delete this mapping?"
#: data/input-remapper.glade:889
msgid "Available output axes are affected by the Target setting."
msgstr ""
#: data/input-remapper.glade:504
msgid "Autoload"
#: data/input-remapper.glade:352
msgid "Copy"
msgstr ""
#: data/input-remapper.glade:583 data/input-remapper.glade:627
msgid "Buttons"
#: data/input-remapper.glade:189
msgid "Create a new preset"
msgstr ""
#: data/input-remapper.glade:65
msgid "Cancel"
#: data/input-remapper.glade:954
msgid "Deadzone"
msgstr ""
#: inputremapper/gui/user_interface.py:524
msgid "Cannot apply empty preset file"
#: data/input-remapper.glade:368 data/input-remapper.glade:619
msgid "Delete"
msgstr ""
#: data/input-remapper.glade:860 inputremapper/gui/editor/editor.py:253
msgid "Change Key"
#: data/input-remapper.glade:623
msgid "Delete this entry"
msgstr ""
#: data/input-remapper.glade:326
msgid "Copy"
#: data/input-remapper.glade:372
msgid "Delete this preset"
msgstr ""
#: data/input-remapper.glade:350
msgid "Create a new preset"
#: data/input-remapper.glade:162
msgid "Device Name"
msgstr ""
#: data/input-remapper.glade:365 data/input-remapper.glade:889
msgid "Delete"
#: data/input-remapper.glade:148
msgid "Devices"
msgstr ""
#: data/input-remapper.glade:894
msgid "Delete this entry"
#: data/input-remapper.glade:356
msgid "Duplicate this preset"
msgstr ""
#: data/input-remapper.glade:370
msgid "Delete this preset"
#: data/input-remapper.glade:1175
msgid "Editor"
msgstr ""
#: data/input-remapper.glade:193
msgid "Device"
#: data/input-remapper.glade:1728
msgid "Event Specific"
msgstr ""
#: data/input-remapper.glade:331
msgid "Duplicate this preset"
#: data/input-remapper.glade:1038
msgid "Expo"
msgstr ""
#: data/input-remapper.glade:996
msgid "Gain"
msgstr ""
#: inputremapper/gui/user_interface.py:618
#, python-format
msgid "Failed to apply preset %s"
#: data/input-remapper.glade:1702
msgid "General"
msgstr ""
#: data/input-remapper.glade:239
#: data/input-remapper.glade:1279
msgid "Help"
msgstr ""
#: data/input-remapper.glade:149
#: data/input-remapper.glade:509
msgid "Input"
msgstr ""
#: data/input-remapper.glade:1263
msgid "Input Remapper"
msgstr ""
#: data/input-remapper.glade:584 data/input-remapper.glade:628
msgid "Joystick"
#: data/input-remapper.glade:1116
msgid "Input cutoff"
msgstr ""
#: data/input-remapper.glade:566
msgid "Left joystick"
#: data/input-remapper.glade:759 data/input-remapper.glade:870
msgid "Key or Macro"
msgstr ""
#: data/input-remapper.glade:581 data/input-remapper.glade:625
msgid "Mouse"
#: data/input-remapper.glade:185
msgid "New"
msgstr ""
#: data/input-remapper.glade:654
msgid "Mouse speed"
#: data/input-remapper.glade:720
msgid "Output"
msgstr ""
#: data/input-remapper.glade:345
msgid "New"
#: data/input-remapper.glade:891
msgid "Output axis"
msgstr ""
#: inputremapper/gui/user_interface.py:689
#: inputremapper/gui/user_interface.py:761
msgid "Permission denied!"
#: data/input-remapper.glade:287
msgid "Preset Name"
msgstr ""
#: data/input-remapper.glade:399
msgid "Preset"
#: data/input-remapper.glade:272
msgid "Presets"
msgstr ""
#: inputremapper/gui/editor/editor.py:249
msgid "Press Key"
#: data/input-remapper.glade:589
msgid "Record"
msgstr ""
#: data/input-remapper.glade:864
#: data/input-remapper.glade:593
msgid "Record a button of your device that should be remapped"
msgstr ""
#: data/input-remapper.glade:438
msgid "Rename"
#: data/input-remapper.glade:1677
msgid "Release input"
msgstr ""
#: data/input-remapper.glade:610
msgid "Right joystick"
#: data/input-remapper.glade:1650
msgid "Release timeout"
msgstr ""
#: data/input-remapper.glade:469
#: data/input-remapper.glade:1742
msgid "Remove this input"
msgstr ""
#: data/input-remapper.glade:394
msgid "Rename"
msgstr ""
#: data/input-remapper.glade:451
msgid "Save the entered name"
msgstr ""
#: data/input-remapper.glade:1098
#: data/input-remapper.glade:1416
msgid ""
"See <a href=\"https://github.com/sezanzeb/input-remapper/blob/HEAD/readme/"
"usage.md\">usage.md</a> online on github for comprehensive information.\n"
@ -174,139 +185,113 @@ msgid ""
"\n"
"Macros allow multiple characters to be written with a single key-press. "
"Information about programming them is available online on github. See <a "
"href=\"https://github.com/sezanzeb/input-remapper/blob/HEAD/readme/macros.md"
"\">macros.md</a> and <a href=\"https://github.com/sezanzeb/input-remapper/"
"href=\"https://github.com/sezanzeb/input-remapper/blob/HEAD/readme/macros."
"md\">macros.md</a> and <a href=\"https://github.com/sezanzeb/input-remapper/"
"blob/HEAD/readme/examples.md\">examples.md</a>"
msgstr ""
#: inputremapper/gui/editor/editor.py:108
msgid "Set the key first"
msgstr ""
#: data/input-remapper.glade:221
msgid ""
"Shortcut: ctrl + del\n"
"Gives your keys back their original function"
msgstr ""
#: data/input-remapper.glade:1239
#: data/input-remapper.glade:1557
msgid "Shortcuts"
msgstr ""
#: data/input-remapper.glade:1141
#: data/input-remapper.glade:1459
msgid ""
"Shortcuts only work while keys are not being recorded and the gui is in "
"focus."
msgstr ""
#: data/input-remapper.glade:312
#: data/input-remapper.glade:322
msgid "Start injecting. Don't hold down any keys while the injection starts"
msgstr ""
#: inputremapper/gui/user_interface.py:567
msgid "Starting injection..."
#: data/input-remapper.glade:201 data/input-remapper.glade:334
msgid "Stop"
msgstr ""
#: data/input-remapper.glade:217
msgid "Stop Injection"
#: data/input-remapper.glade:205 data/input-remapper.glade:338
msgid ""
"Stops the Injection for the selected device,\n"
"gives your keys their original function back\n"
"Shortcut: ctrl + del"
msgstr ""
#: inputremapper/gui/user_interface.py:481
#, python-format
msgid "Syntax error at %s, hover for info"
#: data/input-remapper.glade:811
msgid "Target"
msgstr ""
#: inputremapper/gui/user_interface.py:226
msgid "The helper did not start"
#: data/input-remapper.glade:1107
msgid ""
"The Speed at which the Input is considered at maximum.\n"
"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs "
"(e.g. gamepad)"
msgstr ""
#: data/input-remapper.glade:879
#: data/input-remapper.glade:802
msgid "The type of device this mapping is emulating."
msgstr ""
#: data/input-remapper.glade:499
msgid "To automatically apply the preset after your login or when it connects."
#: data/input-remapper.glade:1796
msgid "Trigger threshold"
msgstr ""
#: inputremapper/gui/user_interface.py:779
#, python-format
msgid "Unknown mapping %s"
#: data/input-remapper.glade:742
msgid "Type"
msgstr ""
#: data/input-remapper.glade:1122
#: data/input-remapper.glade:1440
msgid "Usage"
msgstr ""
#: inputremapper/gui/editor/editor.py:519
msgid "Use \"Stop Injection\" to stop before editing"
#: data/input-remapper.glade:1770
msgid "Use as analog"
msgstr ""
#: data/input-remapper.glade:1015
#: data/input-remapper.glade:1333
msgid "Version unknown"
msgstr ""
#: data/input-remapper.glade:582 data/input-remapper.glade:626
msgid "Wheel"
#: data/input-remapper.glade:851
msgid "What should be written. For example KEY_A"
msgstr ""
#: data/input-remapper.glade:1032
#: data/input-remapper.glade:1350
msgid ""
"You can find more information and report bugs at\n"
"<a href=\"https://github.com/sezanzeb/input-remapper\">https://github.com/"
"sezanzeb/input-remapper</a>"
msgstr ""
#: inputremapper/gui/user_interface.py:526
msgid "You need to add keys and save first"
msgstr ""
#: inputremapper/gui/editor/editor.py:620
msgid "Your system might reinterpret combinations "
msgstr ""
#: inputremapper/gui/editor/editor.py:622
msgid "break them."
msgstr ""
#: data/input-remapper.glade:1173
#: data/input-remapper.glade:1491
msgid "closes the application"
msgstr ""
#: data/input-remapper.glade:1161
#: data/input-remapper.glade:1479
msgid "ctrl + del"
msgstr ""
#: data/input-remapper.glade:1185
#: data/input-remapper.glade:1503
msgid "ctrl + q"
msgstr ""
#: data/input-remapper.glade:1197
#: data/input-remapper.glade:1515
msgid "ctrl + r"
msgstr ""
#: inputremapper/gui/editor/editor.py:619
msgid "ctrl, alt and shift may not combine properly"
msgstr ""
#: inputremapper/gui/editor/editor.py:78 inputremapper/gui/editor/editor.py:394
msgid "new entry"
#: data/input-remapper.glade:530
msgid "no input configured"
msgstr ""
#: data/input-remapper.glade:1209
#: data/input-remapper.glade:1527
msgid "refreshes the device list"
msgstr ""
#: data/input-remapper.glade:1221
#: data/input-remapper.glade:1539
msgid "stops the injection"
msgstr ""
#: inputremapper/gui/editor/editor.py:621
msgid "with those after they are injected, and by doing so "
msgstr ""
#: data/input-remapper.glade:1052
#: data/input-remapper.glade:1370
msgid ""
"© 2021 Sezanzeb proxima@sezanzeb.de\n"
"© 2023 Sezanzeb proxima@sezanzeb.de\n"
"This program comes with absolutely no warranty.\n"
"See the <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General "
"Public License, version 3 or later</a> for details."

@ -150,7 +150,7 @@ msgid "Rename"
msgstr "Rinomina"
#: data/input-remapper.glade:120
msgid "Stop Injection"
msgid "Stop"
msgstr "Ripristina impostazioni predefinite"
#: data/input-remapper.glade:509
@ -349,12 +349,12 @@ msgstr "scrive un evento"
#: data/input-remapper.glade:880
msgid ""
"© 2021 Sezanzeb proxima@sezanzeb.de\n"
"© 2023 Sezanzeb proxima@sezanzeb.de\n"
"This program comes with absolutely no warranty.\n"
"See the <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General "
"Public License, version 3 or later</a> for details."
msgstr ""
"© 2021 Sezanzeb proxima@sezanzeb.de\n"
"© 2023 Sezanzeb proxima@sezanzeb.de\n"
"Questo programma non ha assolutamente alcuna garanzia.\n"
"Vedi il <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">Generale GNU "
"Licenza pubblica, versione 3 o successiva</a> per i dettagli."

@ -7,164 +7,175 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-03-27 13:08+0200\n"
"PO-Revision-Date: 2022-10-09 00:34+0200\n"
"Last-Translator: Sviatoslav Vorona <jorrvaskr@rambler.ru>\n"
"POT-Creation-Date: 2022-11-14 10:29+0200\n"
"PO-Revision-Date: 2023-01-11 21:48+0100\n"
"Language-Team: \n"
"Language: ru_RU\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.0\n"
"Last-Translator: Sviatoslav Vorona <jorrvaskr@rambler.ru>\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n"
"X-Generator: Poedit 3.0\n"
#: inputremapper/gui/editor/editor.py:608
#, python-format
msgid "\"%s\" already mapped to \"%s\""
msgstr "\"%s\" уже привязан к \"%s\""
#: inputremapper/gui/user_interface.py:609
msgid ", CTRL + DEL to stop"
msgstr ", CTRL + DEL чтобы остановить"
#: data/input-remapper.glade:543
msgid " (recording ...)"
msgstr " (запись ...)"
#: data/input-remapper.glade:1070
#: data/input-remapper.glade:1388
msgid "About"
msgstr "О программе"
#: inputremapper/gui/user_interface.py:606
#, python-format
msgid "Applied preset %s"
msgstr "Применённая предустановка %s"
#: data/input-remapper.glade:574
msgid "Add"
msgstr "Добавить"
#: data/input-remapper.glade:605 data/input-remapper.glade:1585
msgid "Advanced"
msgstr "Дополнительные"
#: inputremapper/gui/user_interface.py:427
msgid "Applied the system default"
msgstr "Применены системные настройки по умолчанию"
#: data/input-remapper.glade:772 data/input-remapper.glade:1147
msgid "Analog Axis"
msgstr "Аналоговая Ось"
#: data/input-remapper.glade:307
#: data/input-remapper.glade:318
msgid "Apply"
msgstr "Применить"
#: inputremapper/gui/user_interface.py:251
#, python-format
msgid "Are you sure to delete preset %s?"
msgstr "Вы уверены что хотите удалить предустановку %s?"
#: inputremapper/gui/editor/editor.py:545
msgid "Are you sure to delete this mapping?"
msgstr "Вы уверены что хотите удалить эту привязку?"
#: data/input-remapper.glade:504
#: data/input-remapper.glade:410
msgid "Autoload"
msgstr "Автозагрузка"
#: data/input-remapper.glade:583 data/input-remapper.glade:627
msgid "Buttons"
msgstr "Кнопки"
#: data/input-remapper.glade:65
msgid "Cancel"
msgstr "Отменить"
#: data/input-remapper.glade:889
msgid "Available output axes are affected by the Target setting."
msgstr "Доступные оси вывода затрагиваются настройками Цели."
#: inputremapper/gui/user_interface.py:524
msgid "Cannot apply empty preset file"
msgstr "Нельзя применить пустой файл предустановок"
#: data/input-remapper.glade:860 inputremapper/gui/editor/editor.py:253
msgid "Change Key"
msgstr "Изменить клавишу"
#: data/input-remapper.glade:326
#: data/input-remapper.glade:352
msgid "Copy"
msgstr "Копировать"
#: data/input-remapper.glade:350
#: data/input-remapper.glade:189
msgid "Create a new preset"
msgstr "Создать новую предустановку"
#: data/input-remapper.glade:365 data/input-remapper.glade:889
#: data/input-remapper.glade:954
msgid "Deadzone"
msgstr "Мёртвая зона"
#: data/input-remapper.glade:368 data/input-remapper.glade:619
msgid "Delete"
msgstr "Удалить"
#: data/input-remapper.glade:894
#: data/input-remapper.glade:623
msgid "Delete this entry"
msgstr "Удалить эту запись"
#: data/input-remapper.glade:370
#: data/input-remapper.glade:372
msgid "Delete this preset"
msgstr "Удалить эту предустановку"
#: data/input-remapper.glade:193
msgid "Device"
msgstr "Устройство"
#: data/input-remapper.glade:162
msgid "Device Name"
msgstr "Имя устройства"
#: data/input-remapper.glade:331
#: data/input-remapper.glade:148
msgid "Devices"
msgstr "Устройства"
#: data/input-remapper.glade:356
msgid "Duplicate this preset"
msgstr "Скопировать эту предустановку"
#: inputremapper/gui/user_interface.py:618
#, python-format
msgid "Failed to apply preset %s"
msgstr "Не удалось применить предустановку %s"
#: data/input-remapper.glade:1175
msgid "Editor"
msgstr "Редактор"
#: data/input-remapper.glade:1728
msgid "Event Specific"
msgstr "Специфичные для событий"
#: data/input-remapper.glade:1038
msgid "Expo"
msgstr "Expo"
#: data/input-remapper.glade:239
#: data/input-remapper.glade:996
msgid "Gain"
msgstr "Усиление"
#: data/input-remapper.glade:1702
msgid "General"
msgstr "Общие"
#: data/input-remapper.glade:1279
msgid "Help"
msgstr "Помощь"
#: data/input-remapper.glade:149
#: data/input-remapper.glade:509
msgid "Input"
msgstr "Ввод"
#: data/input-remapper.glade:1263
msgid "Input Remapper"
msgstr "Input Remapper"
#: data/input-remapper.glade:584 data/input-remapper.glade:628
msgid "Joystick"
msgstr "Джойстик"
#: data/input-remapper.glade:1116
msgid "Input cutoff"
msgstr "Входная отсечка"
#: data/input-remapper.glade:566
msgid "Left joystick"
msgstr "Левый джойстик"
#: data/input-remapper.glade:759 data/input-remapper.glade:870
msgid "Key or Macro"
msgstr "Клавиша или Макрос"
#: data/input-remapper.glade:581 data/input-remapper.glade:625
msgid "Mouse"
msgstr "Мышь"
#: data/input-remapper.glade:654
msgid "Mouse speed"
msgstr "Скорость мыши"
#: data/input-remapper.glade:345
#: data/input-remapper.glade:185
msgid "New"
msgstr "Новая"
#: inputremapper/gui/user_interface.py:689
#: inputremapper/gui/user_interface.py:761
msgid "Permission denied!"
msgstr "Доступ запрещён!"
#: data/input-remapper.glade:720
msgid "Output"
msgstr "Вывод"
#: data/input-remapper.glade:891
msgid "Output axis"
msgstr "Ось вывода"
#: data/input-remapper.glade:399
msgid "Preset"
msgstr "Предустановка"
#: data/input-remapper.glade:287
msgid "Preset Name"
msgstr "Имя предустановки"
#: inputremapper/gui/editor/editor.py:249
msgid "Press Key"
msgstr "Нажмите клавишу"
#: data/input-remapper.glade:272
msgid "Presets"
msgstr "Предустановки"
#: data/input-remapper.glade:864
#: data/input-remapper.glade:589
msgid "Record"
msgstr "Запись"
#: data/input-remapper.glade:593
msgid "Record a button of your device that should be remapped"
msgstr "Записать клавишу вашего устройства которая должна быть привязана"
#: data/input-remapper.glade:438
#: data/input-remapper.glade:1677
msgid "Release input"
msgstr "Освободить"
#: data/input-remapper.glade:1650
msgid "Release timeout"
msgstr "Таймаут освобождения"
#: data/input-remapper.glade:1742
msgid "Remove this input"
msgstr "Удалить этот ввод"
#: data/input-remapper.glade:394
msgid "Rename"
msgstr "Переименовать"
#: data/input-remapper.glade:610
msgid "Right joystick"
msgstr "Правый джойстик"
#: data/input-remapper.glade:469
#: data/input-remapper.glade:451
msgid "Save the entered name"
msgstr "Сохранить введённое имя"
#: data/input-remapper.glade:1098
#: data/input-remapper.glade:1416
msgid ""
"See <a href=\"https://github.com/sezanzeb/input-remapper/blob/HEAD/readme/"
"usage.md\">usage.md</a> online on github for comprehensive information.\n"
@ -192,26 +203,14 @@ msgstr ""
"Макросы позволяют написать множество символов нажатием одной клавиши. "
"Информация об их программировании доступна в сети на github. Смотрите <a "
"href=\"https://github.com/sezanzeb/input-remapper/blob/HEAD/readme/macros."
"md\">macros.md</a> и <a href=\"https://github.com/sezanzeb/input-remapper/"
"md\">macros.md</a> and <a href=\"https://github.com/sezanzeb/input-remapper/"
"blob/HEAD/readme/examples.md\">examples.md</a>"
#: inputremapper/gui/editor/editor.py:108
msgid "Set the key first"
msgstr "Сначала посмотреть клавишу"
#: data/input-remapper.glade:221
msgid ""
"Shortcut: ctrl + del\n"
"Gives your keys back their original function"
msgstr ""
"Комбинация: ctrl + del\n"
"Возвращает ваши клавиши назад в исходную функцию"
#: data/input-remapper.glade:1239
#: data/input-remapper.glade:1557
msgid "Shortcuts"
msgstr "Комбинации"
#: data/input-remapper.glade:1141
#: data/input-remapper.glade:1459
msgid ""
"Shortcuts only work while keys are not being recorded and the gui is in "
"focus."
@ -219,60 +218,67 @@ msgstr ""
"Комбинации работают только пока клавиши не записываются и интерфейс "
"приложения в фокусе."
#: data/input-remapper.glade:312
#: data/input-remapper.glade:322
msgid "Start injecting. Don't hold down any keys while the injection starts"
msgstr "Начать ввод. Не зажимайте никакие клавиши пока ввод запускается"
#: inputremapper/gui/user_interface.py:567
msgid "Starting injection..."
msgstr "Запуск ввода..."
#: data/input-remapper.glade:201 data/input-remapper.glade:334
msgid "Stop"
msgstr "Остановить"
#: data/input-remapper.glade:217
msgid "Stop Injection"
msgstr "Остановить привязку"
#: data/input-remapper.glade:205 data/input-remapper.glade:338
msgid ""
"Stops the Injection for the selected device,\n"
"gives your keys their original function back\n"
"Shortcut: ctrl + del"
msgstr ""
"Останавливает Ввод для выбранного устройства,\n"
"возвращает функции ваших клавиш назад\n"
"Комбинация: ctrl + del"
#: inputremapper/gui/user_interface.py:481
#, python-format
msgid "Syntax error at %s, hover for info"
msgstr "Синтаксическая ошибка в %s, наведите для информации"
#: data/input-remapper.glade:811
msgid "Target"
msgstr "Цель"
#: inputremapper/gui/user_interface.py:226
msgid "The helper did not start"
msgstr "Помощник не запустился"
#: data/input-remapper.glade:1107
msgid ""
"The Speed at wich the Input is considered at maximum.\n"
"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs "
"(e.g. gamepad)"
msgstr ""
"Скорость при которой Ввод считается максимальным.\n"
"Актуально только при эмуляции относительных устройств ввода (например, "
"мыши) как абсолютных устройств вывода (например, геймпада)."
#: data/input-remapper.glade:879
#: data/input-remapper.glade:802
msgid "The type of device this mapping is emulating."
msgstr "Тип устройства которое эмулируется этой привязкой."
#: data/input-remapper.glade:499
msgid "To automatically apply the preset after your login or when it connects."
msgstr ""
"Чтобы автоматически применить установку после входа в систему или "
"присоединении устройства."
#: data/input-remapper.glade:1796
msgid "Trigger threshold"
msgstr "Порог срабатывания"
#: inputremapper/gui/user_interface.py:779
#, python-format
msgid "Unknown mapping %s"
msgstr "Неизвестная привязка %s"
#: data/input-remapper.glade:742
msgid "Type"
msgstr "Тип"
#: data/input-remapper.glade:1122
#: data/input-remapper.glade:1440
msgid "Usage"
msgstr "Использование"
#: inputremapper/gui/editor/editor.py:519
msgid "Use \"Stop Injection\" to stop before editing"
msgstr ""
"Используйте \"Остановить ввод\" чтобы остановиться перед редактированием"
#: data/input-remapper.glade:1770
msgid "Use as analog"
msgstr "Использовать как аналоговое"
#: data/input-remapper.glade:1015
#: data/input-remapper.glade:1333
msgid "Version unknown"
msgstr "Неизвестная версия"
#: data/input-remapper.glade:582 data/input-remapper.glade:626
msgid "Wheel"
msgstr "Колёсико"
#: data/input-remapper.glade:851
msgid "What should be written. For example KEY_A"
msgstr "Что должно быть написано. Например KEY_A"
#: data/input-remapper.glade:1032
#: data/input-remapper.glade:1350
msgid ""
"You can find more information and report bugs at\n"
"<a href=\"https://github.com/sezanzeb/input-remapper\">https://github.com/"
@ -282,62 +288,42 @@ msgstr ""
"<a href=\"https://github.com/sezanzeb/input-remapper\">https://github.com/"
"sezanzeb/input-remapper</a>"
#: inputremapper/gui/user_interface.py:526
msgid "You need to add keys and save first"
msgstr "Вам необходимо сперва добавить клавиши и сохранить"
#: inputremapper/gui/editor/editor.py:620
msgid "Your system might reinterpret combinations "
msgstr "Ваша система может истолковать комбинации иначе "
#: inputremapper/gui/editor/editor.py:622
msgid "break them."
msgstr "нарушая их."
#: data/input-remapper.glade:1173
#: data/input-remapper.glade:1491
msgid "closes the application"
msgstr "закрывает приложение"
#: data/input-remapper.glade:1161
#: data/input-remapper.glade:1479
msgid "ctrl + del"
msgstr ""
msgstr "ctrl + del"
#: data/input-remapper.glade:1185
#: data/input-remapper.glade:1503
msgid "ctrl + q"
msgstr ""
msgstr "ctrl + q"
#: data/input-remapper.glade:1197
#: data/input-remapper.glade:1515
msgid "ctrl + r"
msgstr ""
msgstr "ctrl + r"
#: inputremapper/gui/editor/editor.py:619
msgid "ctrl, alt and shift may not combine properly"
msgstr "ctrl, alt и shift могут неправильно сочетаться"
#: data/input-remapper.glade:530
msgid "no input configured"
msgstr "ввод не сконфигурирован"
#: inputremapper/gui/editor/editor.py:78 inputremapper/gui/editor/editor.py:394
msgid "new entry"
msgstr "новая запись"
#: data/input-remapper.glade:1209
#: data/input-remapper.glade:1527
msgid "refreshes the device list"
msgstr "обновляет список устройств"
#: data/input-remapper.glade:1221
#: data/input-remapper.glade:1539
msgid "stops the injection"
msgstr "останавливает ввод"
#: inputremapper/gui/editor/editor.py:621
msgid "with those after they are injected, and by doing so "
msgstr "с теми, которые после привязки, и тем самым "
#: data/input-remapper.glade:1052
#: data/input-remapper.glade:1370
msgid ""
"© 2021 Sezanzeb proxima@sezanzeb.de\n"
"© 2023 Sezanzeb proxima@sezanzeb.de\n"
"This program comes with absolutely no warranty.\n"
"See the <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General "
"Public License, version 3 or later</a> for details."
msgstr ""
"© 2021 Sezanzeb proxima@sezanzeb.de\n"
"Эта программа не включает никакой гарантии.\n"
"Смотрите <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General "
"Public License, version 3 or later</a> для большей информации."
"© 2023 Sezanzeb proxima@sezanzeb.de\n"
"Программа распространяется без какой либо гарантии.\n"
"Смотрите подробности по ссылке <a href=\"https://www.gnu.org/licenses/"
"gpl-3.0.html\">GNU General Public License, версия 3 или новее</a>."

@ -1,5 +1,5 @@
# Slovak translation of input-remapper.
# Copyright (C) 2022.
# Copyright (C) 2023.
# This file is distributed under the same license as the input-remapper package.
# Jose Riha <jose 1711 gmail com>, 2021.
#
@ -225,7 +225,7 @@ msgid "Starting injection..."
msgstr "Spúšťanie injektáže..."
#: data/input-remapper.glade:217
msgid "Stop Injection"
msgid "Stop"
msgstr "Zastaviť injektáž"
#: inputremapper/gui/user_interface.py:477
@ -257,7 +257,7 @@ msgid "Usage"
msgstr "Použitie"
#: inputremapper/gui/editor/editor.py:482
msgid "Use \"Stop Injection\" to stop before editing"
msgid "Use \"Stop\" to stop before editing"
msgstr "Pred editovaním použite \"Zastaviť injektáž\""
#: data/input-remapper.glade:1015
@ -328,12 +328,12 @@ msgstr "kombinácie pôvodných a emulovaných kláves a takto "
#: data/input-remapper.glade:1052
msgid ""
"© 2021 Sezanzeb proxima@sezanzeb.de\n"
"© 2023 Sezanzeb proxima@sezanzeb.de\n"
"This program comes with absolutely no warranty.\n"
"See the <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General "
"Public License, version 3 or later</a> for details."
msgstr ""
"© 2021 Sezanzeb proxima@sezanzeb.de\n"
"© 2023 Sezanzeb proxima@sezanzeb.de\n"
"Tento program je poskytovaný bez akýchkoľvek záruk.\n"
"Podrobnosti nájdete v podmienkach licencie <a href=\"https://www.gnu.org/"
"licenses/gpl-3.0.html\">GNU General Public License, version 3 alebo novšej</"

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

Loading…
Cancel
Save